
聊到 Redis 内核很多人第一反应就是那句老话单线程凭什么还能每秒处理十万级请求这个系列写到第 30 章我决定把读取与请求核心这块彻底拆开讲一讲从客户端敲下一条命令开始到 Redis 把结果返回给客户端为止数据在服务端到底走了多少关卡。你会发现Redis 表面上只是查找键、读取数据结构、写回结果但背后牵涉到事件循环、协议解析、命令分发、缓存淘汰策略甚至操作系统网络栈。这篇文章不聊安装下载也不重复数据类型的基础介绍直接带你从源码视角看这条链路顺带分享一些生产环境里用得到的排查思路适合已经用熟 Redis、想往底层再走一步的开发者。1. 从客户端命令到事件循环Redis 请求的全链路1.1 单线程模型的真实面I/O 多路复用与事件循环很多初学者会把“单线程”理解成 Redis 同一时间只能处理一个连接这是错的。Redis 的单线程主要指命令执行阶段不会并发跑但它从来不是傻等一个连接读完数据再处理下一个。真正撑起高并发的是服务器里那个不断转圈的事件循环。事件循环的核心代码在ae.c里入口是aeMain函数。它本质上就是一个while(1)死循环每次循环调用aeProcessEvents去处理两种事件文件事件和时间事件。文件事件来自客户端连接的读写状态比如 socket 上有没有新数据可读、有没有缓冲区可写时间事件则是周期性任务比如serverCron用来做键过期清理、快照触发、重写 AOF 之类的后台工作。aeProcessEvents在 Linux 上走的是aeApiPoll这层封装背后就是epoll_wait。Redis 会把所有客户端的 socket 文件描述符注册到 epoll 实例上然后阻塞等待内核告诉它“某个 fd 可读”或“某个 fd 可写”。内核在这一步做了大量工作它维护着每个 socket 的接收缓冲区状态当网卡收到数据、经过协议栈处理、放入 socket 接收队列后内核才会把等待中的epoll_wait唤醒。整个过程可以用一个生活类比理解Redis 是个效率极高的收银员面前排着上千个顾客但收银员不会挨个问“你有事吗”而是坐在柜台里按铃谁的号到了就过来一个。epoll 就是这个叫号系统内核负责监听所有顾客的动向一旦某个顾客举手系统立刻提示收银员处理。这样收银员永远处于工作状态不会因为等人而空转。这个机制带来的直接结果是Redis 可以在单线程内同时维持数万个连接。每个连接平时只是占着一个 fd没有数据进来时不消耗 CPU。真正消耗 CPU 的只有那些正在发送请求的活跃连接。理解了这一点再看 Redis 的请求核心就不会觉得神秘了。1.2 文件事件、时间事件与命令执行的协同关系Redis 的事件循环不是所有事件一视同仁地处理它有明确的优先级和处理策略。文件事件里读事件优先于写事件被处理因为读事件代表客户端发来了新的请求这是 Redis 的主要工作写事件只是把回复数据尽可能快地送出去晚一会儿问题不大。当一个客户端连接被 accept 后Redis 会为它创建一个client结构体并把 socket 的读事件注册到事件循环里对应的回调函数就是readQueryFromClient。这一步在networking.c的acceptCommonHandler里完成之后createClient会做一堆初始化工作分配输入缓冲区、初始化回复链表、设置数据库编号等等。时间事件也在同一个循环里运行但它采用的是“到期时间 定时检查”的方式。Redis 每次循环会找出最近到期的定时任务计算剩余时间然后把这个时间作为 epoll 的阻塞超时上限。这样做的目的是既保证定时任务能及时执行又不会因为等待定时任务而阻塞网络 I/O。如果文件事件先来了Redis 优先处理文件事件处理完后如果定时事件到期了再去跑serverCron。这个设计里有一个很容易被忽略的关键点事件循环里的所有回调函数都运行在同一个线程上所以任何一个回调如果执行时间过长都会阻塞后面所有事件的响应。读取请求的readQueryFromClient本身只做读数据和解析耗时很小真正占时间的是后续执行命令的回调processCommand。这也是为什么生产环境里一旦出现慢命令影响的不只是那个客户端自己整个实例的延迟都会被拉高。实际调试时可以通过redis-cli info stats查看total_commands_processed、instantaneous_ops_per_sec这些计数器了解事件循环当前的处理压力。如果瞬时 OPS 很高但延迟也高大概率不是事件循环本身的问题而是某条命令执行阶段卡住了。2. 读取核心源码解析readQueryFromClient 的细节2.1 querybuf 缓冲区的动态增长与安全边界readQueryFromClient是 Redis 请求读取的入口函数位置在networking.c每次客户端 socket 可读时被调用。这个函数做的事可以拆成三步从 socket 读取数据、把数据追加到输入缓冲区、调用processInputBuffer尝试解析命令。读数据用的是read系统调用Redis 设置了单次读取的上限一般是 16KBPROTO_IOBUF_LEN。为什么不是一次性把整个 socket 缓冲区读空因为如果某个客户端连接一次性送来超大请求Redis 长时间阻塞在读数据上其他连接的读事件就得不到处理造成饥饿。16KB 这个数值是经过权衡的经验值既能减少系统调用次数又不会让单连接占用过多 CPU 时间。读取到的字节会被追加到client-querybuf里。querybuf 不是固定数组而是一个动态增长的 SDS 字符串随着数据不断进入自动扩容。但 Redis 不会让它无限增长一旦缓冲区的总长度超过配置阈值Redis 会认为客户端在发送畸形请求或恶意包直接关闭连接并打印日志。这个保护机制在实际环境中很常见。我以前排查过一个问题某个业务方用了一个有 bug 的 SDK不断重发超长命令导致 Redis 实例的querybuf内存持续增长最后触发保护逻辑连接被批量断开。从监控上看就是客户端连接数突然掉到很低然后业务立刻报错。所以在做 Redis 内核层面的优化时第一件事就是把 read 的边界理解清楚单次读 16KB动态追加到 querybuf超过阈值断开。这三条规则决定了 Redis 对输入数据的基本态度——来者不拒但胆敢过量就拉黑。2.2 RESP 协议解析状态机是怎么工作的数据进了 querybuf 以后Redis 要把它解析成命令。这一步的核心逻辑在processInputBuffer和processMultibulkBuffer里。Redis 用的是 RESPREdis Serialization Protocol协议命令的格式大致像这样*2\r\n$4\r\nLLEN\r\n$6\r\nmylist\r\n开头的*2表示后面有两部分$4表示接下来一个长度为 4 的字符串内容是LLEN。解析器就是个典型的有限状态机先读*拿到参数个数然后逐个读$拿到每个参数的长度再根据长度截取参数内容。每解析出一条完整命令就生成一个robj结构的参数数组交给execCommand去执行。这个解析过程有个非常值得注意的地方processMultibulkBuffer属于增量解析。也就是说如果客户端发送的命令不完整比如只发了一半就停住查询缓冲区里会保留未解析完的残包等后续字节到达后再继续解析。这保证了一个 TCP 包可以包含多条命令一条命令也可以拆到多个 TCP 包里传输Redis 都能正确处理。我曾经在自研客户端时忽略过这一点——为了简化发送逻辑强制要求每次 TCP 发送必须包含完整命令后来发现大量小包导致网络开销激增。真正高效的做法是像 Redis 的解析器这样按流式数据不断累积解析把多个命令合并到一个包、甚至一次 write 系统调用里发出去。解析完成后还有一个细节如果 querybuf 里的数据已经被完整消费完Redis 会尝试释放空闲缓冲区降低内存占用如果还有剩余数据则保留等待下次事件循环继续解析。这个细节说明 Redis 对内存使用非常敏感一个客户端的输入缓冲都不会轻易浪费。2.3 命令执行前的五道检查关卡命令解析完成后Redis 调用processCommand。这里不是立刻执行而是有一长串检查任何一个环节不通过都会直接返回错误跳过执行。第一道是命令查找通过命令表redisCommandTable匹配命令名。命令名不区分大小写找到后还要检查参数个数是否合法。这一步如果不过返回unknown command或者wrong number of arguments。第二道是权限和身份检查包括 ACL 认证、当前连接是否是订阅模式等。如果客户端在订阅状态下尝试执行普通命令Redis 会拒绝。第三道是内存检查这里非常关键如果开启了maxmemoryRedis 在执行写命令前会先尝试释放内存如果内存仍然超限则会拒绝写入并返回 OOM 错误。很多人在调 Redis 时忽略了这个顺序以为内存淘汰是在命令执行后做的其实是在执行前就拦截了。第四道是集群状态检查。集群模式下如果 key 不在当前节点的负责范围Redis 会返回MOVED或ASK重定向提示让客户端去请求正确的节点。第五道是数据持久化状态检查。如果实例已经停止接收写入比如磁盘出问题后开启了只读策略写命令也会被直接拒绝。全部检查通过后才进入真正的call函数执行命令对应的处理函数。这个过程会记录命令耗时如果超过slowlog-log-slower-than阈值就写入慢日志。这五道检查看起来繁琐其实都是在用一个轻量级单线程模型去承载复杂的管理逻辑。好处是所有状态判断在同一个线程内完成不需要加锁也不会存在并发修改导致的不一致坏处是每一条命令都要顺序走完这一套流程所以命令本身的复杂度对整体性能影响被放得很大。3. 读取路径上的数据结构设计与开销分析3.1 五种基础数据类型的底层编码与读取代价Redis 请求的核心最终都要落到数据结构读取上。五种基础数据类型——String、Hash、List、Set、ZSet——在内部并不总是用教科书上的那几种结构Redis 会根据元素的尺寸和数量选择不同的编码方式目的是用最小代价完成读写。String 类型是最简单的值小于某个阈值时用 embstr 编码直接嵌入对象结构体避免额外内存分配大字符串则用 raw 编码单独分配一批字节如果是整数Redis 甚至直接把它存在指针里面连对象都省了。Hash、ZSet 这类结构在小规模时用紧凑编码listpack把所有字段连续存放节约内存超过阈值后转换成哈希表加跳表。Hash 的读取时间复杂度是 O(1)是因为底层真正成熟后用的是 dictZSet 的排序读取用跳表单点查找依然是 O(logN)扫描时却能按序输出。List 类型在旧版里用 ziplist新版彻底换成 quicklist 结构——它是“多个紧凑链表节点 双向链表”的组合。读取首尾元素是 O(1)按下标访问中间元素时最坏情况是 O(N)所以LINDEX这类命令在大列表上要谨慎。Set 类型内部如果是整数集合用 intset 存储读取是二分查找 O(logN)一旦混入字符串就会升级成 dict读取变成 O(1)。你可以用object encoding key命令查看当前 key 的底层编码。一个常见的坑是同一个 key在小数据量时读取飞快数据增长后编码升级某些命令的耗时会突然翻几倍。这不是 Redis 变慢了是底层数据结构切换后时间复杂度变了。所以线上评估读取性能时不能只盯着命令名看还要关心 key 的实际规模。3.2 明明命令是 O(1)为什么还是会慢很多人有个误区只要命令是 O(1)读取就一定快。实际上 Redis 的读取耗时包含的不只是数据结构查找还有更多隐藏成本。第一层是网络往返。如果你的应用和 Redis 不在同一机房哪怕 Redis 内部只花 0.1 毫秒网络 RTT 可能是 10 毫秒。redis-cli --latency能看到真实网络延迟。很多所谓 Redis 慢其实是网络慢。第二层是系统调用成本。一次简单的GET完成后Redis 需要执行write系统调用把结果写回客户端。如果客户端很多、回复很小频繁的write系统调用本身就会占用大量 CPU。Redis 为此引入了输出缓冲区聚合策略把小回复攒到一定量再一起发送避免每个命令都触发一次系统调用。第三层更隐蔽内存分配器。redis 大量使用 jemalloc 管理内存当 key 大量创建删除时内存碎片可能升高分配和释放内存的开销会变大。O(1) 命令每次执行可能都伴随一次内存分配比如返回一个大字符串给客户端需要复制一份数据。第四层是全局串行化。上一篇讲到所有命令执行都在单线程里哪怕某个命令本身只要 1 微秒如果前面排着 100 条KEYS这样的扫描命令后面的所有请求都得等着。因此观察 OPS 高但延迟也高的场景一定要配合slowlog看有没有长尾命令在阻塞队列。3.3 大 Key 与热 Key读取核心最怕的两类流量Redis 读取链路里最典型的性能杀手就是大 Key 和热 Key。大 Key 指的是单个 key 的 value 特别大比如一个 List 里有几十万个元素或者一个 Hash 里有上百万字段。读取大 Key 时即使使用HGETALL、LRANGE 0 -1这类命令Redis 需要一次性生成大量回复数据这会占用主线程大量时间。传输这些数据也会撑满客户端缓冲区触发输出缓冲限制甚至导致连接被强制关闭。我曾经在线上见过一个 5MB 的 String key每次读取耗时 30 毫秒以上但业务方从不更新它只是每天被定时任务读一次。结果每次定时任务跑的时候整个 Redis 实例延迟从 1 毫秒飙升到 100 毫秒。后来改成把大 value 拆成多个小 key按需读取问题立刻消失。排查大 Key 可以用内置的redis-cli --bigkeys它会对所有 key 做类型识别和长度统计。不过这里要提醒一句在大型实例上跑--bigkeys本身是一个 O(N) 操作会遍历所有 key建议在低峰期执行。热 Key 是指某个 key 被高频访问比如秒杀场景里的商品计数器、热点新闻的详情页缓存。单个热 Key 本身也许只有 1 微秒的读取成本但当它占到一个 Redis 实例总请求的 90%服务器大部分时间都在为一个 key 干活其他请求自然被挤压。更严重的是如果热 Key 对应的 value 很大瞬间并发读会把网络的出方向带宽打满。应对热 Key 的常见手段是本地缓存 Redis 降级或者把 key 打散成多份让请求分布到不同 key 上。内核实测时Key 打散后 OPS 提升非常明显但要注意缓存一致性设计别为了性能把逻辑搞复杂了。4. 读取变慢时该如何定位监控、日志与内核参数4.1 慢日志与延迟监控的底层逻辑排查读取变慢第一步永远是看慢日志。Redis 的慢日志和 MySQL 的慢查询日志不一样它不是记录 SQL而是记录命令执行耗时单位是微秒。默认阈值为 10000 微秒10 毫秒也就是说执行超过 10 毫秒的命令就会进日志。配置用slowlog-log-slower-than保存条数用slowlog-max-len。这两个参数在运行时可以直接通过CONFIG SET修改。实际场景中10 毫秒阈值偏保守我会建议线上业务先设成 2000 微秒观察一周把所有超过 2 毫秒的命令都拉出来梳理一遍再决定要不要收紧。SLOWLOG GET命令能看到每条慢命令的耗时、来源 IP、命令参数。注意它记录的是从命令开始执行到返回结果的时间纯命令执行时间不包含网络传输和排队等待。所以如果慢日志里没有命令但客户端还是觉得慢问题就出在网络或客户端本身。延迟监控是另一个维度。CONFIG SET latency-monitor-threshold 100开启后Redis 会记录事件循环中超过 100 毫秒的事件类型用LATENCY DOCTOR可以查看诊断建议。这个功能对定位进程阻塞非常有用比如fork阻塞、AOF 写入阻塞、过期键删除导致的停顿都能在 latency 里看到痕迹。说一个我踩过的坑info commandstats里能看到每种命令的调用次数和平均耗时。有一次排查性能问题我只看instantaneous_ops_per_sec发现指标正常但客户端大量超时。后来看了commandstats才发现一个冷门的SORT命令被某条定时任务每秒调用一次单次耗时 4 秒但因为调用频率低平均 OPS 不显著。所以低频但极慢的命令比高频但微慢的命令更难排查必须两者都看。4.2 网络内核参数调优的边界与误区Redis 实例所在的操作系统内核参数会直接影响请求读取质量。最常见的是 TCP_NODELAY 设置它控制是否禁用 Nagle 算法。Redis 本身默认开启tcp-nodelay yes目的就是让小命令也能立刻发送出去避免交互式请求被延迟。如果这个值被改成 no你会发现单条命令的 RTT 明显增加尤其在大量小包交互场景下。另一个参数是net.core.somaxconn它决定 TCP 全连接队列的大小。Redis 的listenbacklog 会参考这个值。如果连接建立频率高、并发短连接多全连接队列被占满表现为客户端频繁出现连接超时。通常建议至少设为 1024如果短连接特别多可以结合tcp_max_syn_backlog一起调整。但注意这个值只影响新连接建立的快慢不影响已有连接的读取速度。net.ipv4.tcp_fin_timeout、tcp_tw_reuse这类参数影响到的是 TIME_WAIT 状态连接复用对 Redis 这种长连接为主的服务意义不大乱调反而可能引入连接异常。我的建议是Redis 的内核调优要克制先改 somaxconn、TCP_NODELAY 这种最直接相关的不要一股脑把网上搜来的参数全配上。还有一个常被忽略的内核行为是透明大页THP。Redis 官方文档明确建议关闭 THP因为内存分配不足时分配器可能需要等待页合并造成读取延迟抖动。设置方法是在/sys/kernel/mm/transparent_hugepage/enabled里改成never这个操作对 Redis 读取延迟的稳定性帮助很大尤其是内存接近上限的实例。4.3 一次真实读取超时问题的排查实录讲一个实际的定位过程完整还原一次 Redis 读取超时是怎么找到根因的。现象是业务方反馈单个命令偶尔超过 50 毫秒频率大约每分钟几次大部分时间正常。我先在客户端用redis-cli --latency -h 目标机 -i 1持续采样发现平均延迟 0.8 毫秒但最高延迟有 85 毫秒说明服务端确实存在偶发停顿。接着上机器先看INFO statstotal_commands_processed没有异常增长instantaneous_ops_per_sec平稳。再看SLOWLOG GET发现一条SPOP命令耗时 62 毫秒每次执行的都是同一个 key。这个 key 是一个集合业务方说里面积累了大量会话数据。执行OBJECT ENCODING key一看编码是hashtable底层是 dict。再用SCAN配合STRLEN检查大小确认这个 set 里有上百万个成员。问题清楚了SPOP命令在随机弹出时会维护哈希表的迭代器当集合特别大、前缀被大量删除后哈希表里可能有大量被标记为“已删除”的空桶Redis 遍历空桶找元素时耗时暴增。换成SRANDMEMBER加显式SREM只是缓解最终方案是拆分大集合把活跃会话按日期拆成多个 key单 key 规模控制在十万以内。这个案例说明很多看似内核层面的问题根因在业务数据结构设计。读取请求核心再怎么优化也填不了大 Key 这个坑。排查时一定要把客户端视角、监控指标、底层编码结合成一个整体去看。5. 从读取核心看 Redis 后续演进5.1 io-threads 多线程 IO只分流网络压力不碰命令执行Redis 6.0 引入了多线程 I/O很多人以为 Redis 变成了多线程数据库其实这是误解。多线程版本默认只开io-threads 4但命令执行仍然在主线程串行执行只是把网络数据读写、协议解析这些 I/O 类工作分给了多个线程。为什么不开更多线程做命令执行因为 Redis 所有数据结构和命令执行都假设单线程模型一旦多线程并发执行命令必须给所有全局变量加锁复杂度爆炸而且锁竞争大概率抵消掉多核带来的收益。Redis 团队选择了一个很务实的折中让 CPU 密集的解析和网络收发并行化而命令执行保持串行。实际压测中纯GET/SET场景多线程 I/O 开启后吞吐能提升一倍左右但只对网络包处理压力大的场景有效如果瓶颈在命令本身开启后几乎没有改善。配置时注意两个参数io-threads和io-threads-do-reads。前者控制线程数一般不要超过 CPU 核心数后者默认是 no也就是默认只把“写回复”多线程化读请求仍然在主线程。只有读流量非常大时才需要打开 do-reads因为读线程解析后的命令还是要回主线程执行线程间数据传递本身也有开销。这个参数必须实测对比别直接照抄网上的配置。5.2 版本演进给读取路径带来的变化Redis 7.x 之后读取核心也有一些细节变化。比如listpack完全替代了ziplist小型 Hash、ZSet 的读取路径更加紧凑内存碎片更少。新版本的RESP3协议允许更丰富的数据类型返回但相应对协议解析状态机也更复杂老客户端不兼容所以升级时一定要验证客户端库版本。还有一个值得关注的方向Redis 在读取路径上对“缓存穿透”、“缓存击穿”的防护思路也在演进比如后来出现的CLIENT CACHING、CLIENT TRACKING这种客户端缓存特性把一部分读取压力从服务端转移到了客户端。它的设计思路是当 key 被修改时Redis 主动通知客户端缓存失效这样客户端可以放心地本地缓存数据。这个机制在读取多、写入少的场景收益极高但实现复杂度不小引入时要评估客户端 SDK 的支持程度。从我的角度看Redis 读取和请求核心的内核演进方向不是“把单线程变多线程”而是在保持单线程确定性优势的前提下把网络、协议、内存这些外围环节逐步优化。所以读源码时你会发现这部分代码一直保持着相对稳定的风格——核心逻辑变化慢外围组件持续迭代。5.3 想深入读源码的开发者建议按什么顺序看如果你也想通过源码理解 Redis 的读取与请求核心我建议按这个顺序打开文件先从ae.c看事件循环理解 epoll 封装和事件注册然后跳到networking.c的readQueryFromClient逐步跟进processInputBuffer、processMultibulkBuffer再打开server.c的processCommand看命令分发前的检查逻辑最后深入具体数据结构的t_hash.c、t_list.c、t_zset.c看读取命令最终怎么落到底层结构。读的过程中强烈建议配合 gdb 打断点。实测下来 gdb 在readQueryFromClient处设断点通过p client-querybuf能看到实时解析的请求内容对理解协议状态机的帮助远比死磕源码大。另外抓包工具也能帮忙用tcpdump抓本地回环包看一下 RESP 协议结构先建立协议直觉再回来看代码会更顺畅。这个顺序下来基本就能把 Redis 从“收到网络请求”到“返回结果”这条最核心的链路串起来了。记住一个原则先看数据结构再看处理流程最后看内存管理。因为 Redis 的很多设计都是为了配合特定数据结构的高效访问而做的取舍单纯按代码顺序读容易被细节带偏。我个人在实际操作中的体会是搞懂 Redis 读取与请求核心最好的方式不是把每个函数都背下来而是带着问题去读——比如“为什么这条命令 O(1) 还是慢”“为什么大量连接时延迟会升高”然后顺着调用链一路查下去。每一次排查都会对 Redis 的单线程模型、事件循环、数据结构编码有更具体的认识。以后你再遇到读延迟抖动、连接超时、大 Key 拖垮实例这类问题脑子里就有一套完整的检查清单而不是漫无目的地试参数了。