接触Redis源码有一段时间之后,如果你让我挑一条最值得反复琢磨的路径,我的回答一定是"读取与请求"这条链路。从客户端敲下一行 GET key,到屏幕上打印出 value,中间经过网卡、内核协议栈、epoll 事件循环、RESP 协议解析、命令分发、哈希表定位、回复缓冲这层层工序,每一层都有自己的设计取舍。不少朋友装好了 Redis、数据类型也玩得熟,但一遇到性能问题就不知道该从哪里下手,本质原因就是没把这套请求链路吃透。这篇内核解析笔记,我就沿着数据进入 Redis 的完整路径走一遍,把我认为最关键的几个环节拆开讲,顺带把生产环境里真正踩过的坑放在最后。
阅读这篇内容之前,建议你先准备一个可以随时打断点调试的 Redis 源码环境:下载一份 6.2 或 7.0 的源码包,本地编译成非优化版本,再启动一个单节点。光看文档和实际单步调试完全是两种体验,后面提到的函数,你都可以亲自验证。版本差异我不做逐一对比,以 6.2 为主,核心链路在 mainline 上没有本质变化。
1. 请求抵达之前的准备:事件循环如何接管每一条客户端连接
很多讲 Redis 性能的文章张口就是"单线程 + 多路复用",但多路复用具体复用在哪儿、事件循环是怎么把每条连接管起来的,大部分人说不清楚。我们先把这部分地基打牢:数据真正进入 Redis 进程之前,是内核协议栈先收到 TCP 报文,然后 Redis 通过 epoll 感知到"某个 socket 可读了",才会去发起真正的 read 系统调用。
1.1 为什么选 epoll 而不是 select/poll
Redis 面对的场景是海量连接、低频活跃。select 和 poll 每次都要把全部 fd 集合从用户态拷贝到内核态,内核还要线性扫描一遍,fd 数量一旦上万,这就是纯浪费。epoll 的核心优势是注册和等待分离:epoll_ctl 把需要关心的 fd 挂进内核事件表,epoll_wait 只返回内核确认过的就绪事件,用户态处理的永远是"有动静的少数连接",而不是"全部连接"。
这个取舍对 Redis 的意义格外大。Redis 是单线程在主循环里跑,它的 CPU 预算非常宝贵,任何 O(N) 的扫描都可能在连接数上来之后变成瓶颈。用 epoll 之后,一次 epoll_wait 返回的事件数量基本等于实际需要处理的量,主线程可以把精力集中在解析命令和访问内存上。顺带一提,Redis 自己封装了 ae_epoll.c、ae_select.c、ae_kqueue.c 几套后端,编译时按平台选,Linux 上几乎都是 epoll,但如果你在别的系统上跑,知道这个封装结构有助于理解"事件循环是跨平台的"。
1.2 aeProcessEvents 的文件事件分拣顺序
事件循环的核心在 ae.c 的 aeProcessEvents:先算最近的时间事件(比如 serverCron 何时该跑)来决定 epoll_wait 的 timeout,然后调用 aeApiPoll 把就绪事件捞出来,最后逐个回调对应的 handler。这里有个很容易被忽略的细节:如果一个 fd 同时可读又可写,Redis 默认会先调 rfileProc(读处理器),再调 wfileProc(写处理器)。
这段顺序在 aeProcessEvents 的源码里是刻意安排的:如果反过来先处理写,那么一个疯狂生产响应数据的连接就能一直霸占 CPU 去 write,而其他连接的数据一直躺在内核缓冲区里没人 read。读优先的本质是"先保证数据尽量从内核搬走,再考虑把结果发出去",这样可以避免读侧堆积导致的 TCP 窗口收缩、对端重传等连锁反应。特殊场景下你也可以用 AE_BARRIER 标志把顺序反转,但这个用法非常冷门,日常不会碰。
1.3 时间事件与文件事件的竞合
主循环 aeMain 其实就是一个 for(;;) 调用 aeProcessEvents,所以时间事件和文件事件不是并行跑的,而是交替穿插。serverCron 这种定时任务(过期 key 扫描、内存统计、持久化触发检查)会在每次循环里按时间判断是否该执行。这意味着一个极端慢的文件事件处理器会推迟 serverCron 的执行——这也是为什么不能在命令处理里做重活的设计背景。
理解了这层逻辑,你在排查问题时会有一种"通了"的感觉:所谓单线程瓶颈,并不是 Redis 真的在一个线程里做了一切事,而是所有读写系统调用、命令解析、命令执行、过期清理、持久化触发这些工作,都共用同一个循环的 CPU 时间。后面讲多线程 IO 时你会看到,Redis 努力拆出去的只是纯系统调用部分,核心的 CPU 消耗仍然集中在主线程。
2. 从内核协议栈到 Redis 输入缓冲区:readQueryFromClient 的搬运细节
epoll 告诉你某个 fd 能读了,接下来真正干活的是 networking.c 里的 readQueryFromClient。这个函数表面上只是在做 read,但里面藏着 Redis 对"内存效率"的极致追求,值得逐行看。
2.1 非阻塞 socket 与 64KB 读取循环
客户端连接 accept 之后,Redis 会立刻把 fd 设置为非阻塞(anetNonBlock)。原因很简单:如果 read 是阻塞的,万一对方只发了半个字节,整个事件循环就卡死在系统调用里了。非阻塞模式下,read 能读多少读多少,读到最后返回 EAGAIN 就结束本次处理,剩下的数据等下一次 epoll 事件到来。
readQueryFromClient 里有一个 for(;;) 循环,每次尝试读取 PROTO_IO_SIZE 字节,这个常量是 1024*64,也就是 64KB。为什么是 64KB?这是考虑到内核 socket 接收缓冲区和应用层处理速度之间的折中:太小会导致系统调用次数变多,太大又可能让单次事件处理占用 CPU 过久,阻碍其他连接。实际场景中,一次读到 64KB 已经意味着 TCP 包头尾相接,说明客户端在批量灌注命令,这种情况已经够极端了。
2.2 queryBuf:用 SDS 承载输入缓冲区的真实原因
每个 client 结构体里都有一个 sds 类型的 querybuf,这是输入缓冲区的载体。为什么不用裸的 char 数组?三个原因:一是 SDS 自带长度字段,O(1) 就能知道当前缓冲用了多少;二是它可以自动扩容,免去手动管理边界;三是二进制安全,RESP 协议里可以包含任意字节,用 C 字符串需要到处处理 \0,SDS 没有这个问题。
读取数据时 Redis 用了非常讨巧的手法:先调用 sdsMakeRoomFor 给 querybuf 尾部腾出空间,然后把 read 的目标地址直接指向 SDS 的尾部空闲区,最后用 sdsIncrLen 把实际读到的字节数写进长度字段。整条路径上没有一次 memcpy,内核数据直接被搬进了 SDS 的内存区域。这是源码里最让我舒服的细节,建议你调试时在这里打个断点,观察 read 前后的 querybuf 内存布局。
2.3 输入缓冲区的自我保护机制
querybuf 不是无限长的。Redis 在 proto.c 里定义了 PROTO_MAX_QUERYBUF_LEN,默认 1GB,当累积的数据超过这个值会直接断开连接,防止恶意客户端把服务端内存打爆。真正生产环境里更常见的情况是客户端长时间不消费响应,或发送超大命令导致 queryBuf 增长,虽然 1GB 很难触到,但你要知道存在这条红线。
更实用的指标在 INFO clients 输出里:biggest_input_buf 显示了当前最大的输入缓冲区占用。如果你看到某个连接的输入缓冲有几十 MB,基本可以断定"客户端在批量发送命令却处理不过来",这时候应该去检查客户端侧的消费逻辑,而不是 Redis 本身。另外,Redis 还会记录 querybuf_peak,用于后续动态控制缓冲区缩容,避免连接闲置时继续占着大块内存,这个优化在低并发大连接场景下能省不少 RSS。
2.4 断连与懒释放的细节
read 返回 0 表示对端正常关闭,read 返回 -1 且 errno 是 EAGAIN 表示数据读完,其他错误码基本可以判定连接异常。Redis 在断连时不会立刻同步释放 client 结构体,而是走 freeClientAsync 把它丢进待释放链表,在主线程后续的安全时机统一清理。这么做的原因是当前正在执行的回调栈上不能随便释放自己的上下文,否则返回时就会踩野指针。
3. 命令解析器的工作台:RESP 流如何变成可执行指令
readQueryFromClient 把数据搬进 querybuf 之后,紧接着就是 processInputBuffer。这个阶段的任务是"把字节流切成一条一条命令",难点在于 TCP 是流式协议:一条命令可能被拆在多个包里,也可能多个命令挤在一个包里。解析器必须支持增量解析。
3.1 前置分拣:第一条命令如何决定解析模式
querybuf 的第一个字节如果是 *,Redis 判定为 multibulk 格式,走 processMultibulkBuffer;否则走 processInlineBuffer。这个判断只需要看 c->reqtype,第一次解析时定下来,之后在一个连接生命周期内不再变化。从性能角度说,绝大多数客户端走 multibulk,而 inline 模式主要是给 telnet 这种调试手段留的。
processInputBuffer 用一个 while 循环持续工作:只要 qb_pos 还小于 querybuf 长度,就尝试解析下一条命令。解析出完整命令就交给 processCommandAndResetClient 去执行;如果剩余字节不足以构成一条完整命令,就 break 出去,等下一轮读事件把剩余数据补进来。等所有命令处理完,用 sdsrange 把已经消费掉的部分从 querybuf 里裁掉,qb_pos 归零,缓冲区回到待命状态。
3.2 multibulk 的增量解析状态机
multibulk 的格式长这样:*2\r\n$3\r\nGET\r\n$3\r\nkey\r\n。解析器需要先读 * 后面的数字 N 知道一共有几个参数,然后依次读每个参数的长度和内容。关键状态保存在 c->multibulklen 和 c->bulklen 上:前者表示还剩多少参数没解析,后者表示当前参数还剩多少字节没到齐。
举个例子:如果网络包里只到了 *2\r\n$3\r\nGET\r\n,而 $3\r\nkey\r\n 还在路上,processMultibulkBuffer 解析完 GET 后发现当前参数内容不完整,直接返回 C_ERR,processInputBuffer 退出循环。下一轮 read 补上剩余字节后,解析器不是从零开始,而是根据保存的状态接着读。这个"停半截再接着走"的能力,是流式协议解析的基本功,很多自研中间件在这一步处理不好,就会出现"粘包拆包"问题。
3.3 inline 模式:被低估的调试通道
processInlineBuffer 的逻辑简单粗暴:按空格把命令切成参数。但它支持用双引号包裹带空格的参数,比如 SET "hello world" value 会被解析成三个参数。源码里要处理引号配对和转义,不过这个功能只用于调试,生产环境没有任何理由走 inline 模式。我建议你在测试环境用 nc 或 telnet 连上 Redis 手动敲几条 inline 命令,能更直观地理解"解析模式"和"协议格式"的关系。
3.4 解析边界的防御逻辑
解析器不是无条件信任输入的。processMultibulkBuffer 里两个关键校验:一是协议里的参数长度不能超过 server.proto_max_bulk_len(默认 512MB),超过直接报错断连;二是参数个数 N 不能是无脑大数,否则会引发分配或后续校验问题。这些边界往往只在极端异常时触发,但对防御恶意客户端和错误实现至关重要,自己写 Redis 客户端 SDK 时一定要在这些边界上对齐服务端行为。
4. 从命令表到哈希表:GET 读取语义的内核实现
字节流变成了 redisObject 数组(argv),接下来进入 processCommand——这是命令真正执行前的最后一道关卡,也是理解"读取"语义的关键地段。
4.1 processCommand 里的三道闸门
第一道闸门是命令是否存在:lookupCommand 会拿 argv[0] 去命令字典里找,找不到就返回 unknown command 错误。第二道是参数数量校验:每个 redisCommand 结构体里有 arity 字段,正数表示参数精确数量(包含命令本身),负数表示最少需要多少个参数。例如 GET 的 arity 是 2,只允许 GET key 这种形态。第三道是运行时检查:认证状态、ACL 权限、内存是否达到 maxmemory、集群模式下 key 是否属于当前分片,这些不满足都会直接拒绝执行。
很多人只把 processCommand 当成路由分发,但它是读路径上最容易被性能问题击中的地方:如果 ACL 规则表很大、或者命令名校验触发了对错误 key 空间的查找,这些开销都会被算进每次请求。所幸 Redis 用了字典存命令表,lookup 本身是 O(1) 的,大部分场景下这道闸门消耗极低。
4.2 命令表设计:一个 dict 管住所有读写属性
Redis 的 redisCommandTable 本质上是一个把命令名映射到 redisCommand 结构体的字典。结构体里除了函数指针,还定义了命令的属性标志:CMD_READONLY 表示只读、CMD_WRITE 表示写、CMD_FAST 表示低复杂度、CMD_DENYOOM 表示内存超限时不执行。GET 命令的 proc 指向 getCommand,属性是 CMD_READONLY | CMD_FAST。这些属性不仅用来做权限判断,也会影响命令执行后的统计、慢查询归类、复制传播等行为。
这种"表驱动"设计的好处是:新增一个命令几乎不需要改框架代码,只要往表里填一行。它同样方便我们在阅读源码时定位——你想知道某个命令的读取行为,先查表拿到 proc 函数,再看 flags 就能猜出大概的代价。例如同样标为只读的 LRANGE 是 CMD_FAST?不,LRANGE 可能遍历大量节点,所以在命令表里并不带 FAST 标志,这种分类比拍脑袋准确得多。
4.3 lookupKeyRead:一次 GET 背后发生的三件事
GET 命令最终调用 getCommand,核心是 lookupKeyRead。这个函数做了三件容易被忽略的事:第一,检查 key 是否过期,如果过期就触发惰性删除,并返回空结果;第二,在 dict 里做哈希查找定位 dictEntry;第三,命中后更新 key 的 LRU/LFU 访问信息。
关于过期检查有个非常重要的细节:Redis 主库发现 key 过期会真的删除,但从库不会主动删,而是等待主库的 DEL 命令同步过来。所以你在从库上"读"一个过期 key,主库策略不同,可能还能读到——这在读写分离架构下是非常经典的延迟一致性问题。理解了 lookupKeyRead 的语义,你就能解释为什么从库读可能读到已过期数据,而不是简单归咎于 Bug。
dict 查找本身也有隐蔽逻辑:如果哈希表正在渐进式 rehash,dictFind 需要同时查新旧两张表。哈希表扩容在读取路径上对单个 key 的影响极小,但如果是大量 key 即将触发 rehash 的时刻,写入方会感受到明显波动,读侧反而是无辜的。这也是为什么排查"读取变慢"时不要把根因轻易归结到查询索引上。
第四个方面是关于对象编码的。同样是字符串,int 编码的共享整数对象、44 字节以内的 embstr、更大的 raw,它们作为值被返回时,引用计数和内存拷贝行为完全不同。getCommand 拿到 redisObject 后直接通过 addReply 把对象引用传给输出层,整个过程没有深拷贝,而是靠引用计数保证安全,这也是 Redis 读取小的 String 能保持极高吞吐的原因之一。
4.4 addReply 与写回策略:读取结果如何离开 Redis
命令执行完毕,结果不是立刻 write 出去的,而是走 addReply 先把回复写进缓冲区。每个客户端有 16KB 的固定缓冲 c->buf,能装下就直接用;装不下就把回复挂到 c->reply 链表的节点上。然后 prepareClientToWrite 会注册写事件,真正 write 发生在当前事件循环的 beforeSleep 阶段或下一轮事件循环里。
这种"延迟到 beforeSleep 统一刷"的设计,是为了把多次小数据写合并成更少的系统调用。writeToClient 一次性最多尝试写 64KB,写完如果还有剩余就保留写事件,下一轮再写;如果全部写完就删除写事件,防止陷入"无事可写却不停被唤醒"的忙轮询。理解这条链路之后,你会发现客户端读取速度慢,并不只影响那个客户端自己,它的输出缓冲堆积会让 Redis 持续为它执行 write 尝试,消耗主线程时间。
5. 读取链路真实瓶颈与排查经验:慢查询、大 key 与多线程 IO
前面四章是"正常流程",这一章聊聊真正让生产环境难受的异常情况。我在排查过多次线上抖动之后,体会最深的一点是:读取链路的瓶颈通常不在 dictFind,而在解析、回复拼装和网络交互这些"看不见的功夫"里。
5.1 慢查询日志统计区间的真相
很多人用 SLOWLOG 排查读取慢,但必须知道它的统计口径:slowlog 只记录命令在 call 函数里的执行耗时,也就是从命令解析完成、开始执行到产出回复之间的时间。它不包含网络读取数据的时间、不包含命令在输入缓冲区排队等待的时间、不包含回复通过 write 刷出内核的时间。所以如果你看到"客户端整体响应慢但 SLOWLOG 干净的诡异情况",问题多半出在网络链路上,而不是 Redis 执行慢命令上。
反过来,如果 SLOWLOG 里有大批记录了 50ms 以上的命令,那才是真正需要关注的 CPU 型问题,比如大集合操作或者设置了极高复杂度的 LUA 脚本。用这把尺子去量 Redis 读取路径,能避免大量误判。
5.2 大 key 读取:为什么"读一下"也能拖垮进程
GET 一个 5MB 的字符串,dictFind 依然快得离谱,但后面的路完全不同:addReply 要追加 5MB 数据到输出缓冲,writeToClient 要分几十次系统调用把数据往 socket 里塞。如果客户端读取速度跟不上,输出缓冲区还会继续膨胀,直到触发 client-output-buffer-limit 限制,连接被强制断开。
大 key 的真实危害往往不是单次读取,而是它让"读"的思想变了味:读取复杂度确实还是 O(1),但每条读取都变成一次网络传输事件,单位时间能服务的请求量急剧下降。排查大 key 用 redis-cli --bigkeys 扫描一下就能出报告,这是读取链路优化里性价比最高的起步操作。数据类型那一课我们都学过 hash、list、set 底层结构不同,但读到线上性能这里,大 key 带来的传输成本才是真正的教训。
5.3 Redis 6.0 多线程 IO 的边界:到底解放了谁
Redis 6.0 引入的 io-threads 是一个经常被误解的特性。默认配置下 io-threads 为 1,相当于不开启多线程;把它调大之后,Redis 会把哪些客户端需要写回的工作分发给多个 IO 线程处理,每个线程独立完成一部分 write 系统调用。如果是 io-threads-do-reads 设为 yes,readQueryFromClient 这类读取系统调用也可以交给 IO 线程,但命令解析、命令执行这些 CPU 密集步骤仍然在主线程串行完成。
所以多线程 IO 优化的是"系统调用占用"这一个环节,它解决的是高并发小请求场景下"大量 read/write 中断主线程"的问题,但它不会让一条慢命令变快。如果你的服务端 CPU 已经 100% 打满,靠开 IO 线程救不回来,反而可能因为上下文切换变得更慢。这个边界一定要守住,不然调参会走弯路。
5.4 一套实用的读取链路排查路径
结合前面的原理,我平时在线上排查读取慢的问题,会按这个顺序来:
| 步骤 | 命令/工具 | 重点关注 |
|---|---|---|
| 看整体延迟 | redis-cli --latency -h | 网络 RTT 与 Redis 处理耗时的初步分割 |
| 看连接与缓冲区 | INFO clients | biggest_input_buf、输出缓冲是否异常增大 |
| 看命令级慢操作 | CONFIG SET slowlog-log-slower-than 10000; SLOWLOG GET | 是否存在非 FAST 标志的慢命令 |
| 看内核热点 | perf top -p <redis_pid> | 热点是 dictFind 还是 write 系统调用 |
| 盯系统调用 | strace -p -e trace=read,write | read/write 的单次耗时和调用频率 |
这一套做下来,基本能分辨是客户端自身消费太慢、是网络链路抖动、还是 Redis 内部真的在执行重量级操作。我自己印象最深的一次线上事故,是客户端连接池里的空闲连接被服务端大量关闭,导致客户端不断重连,Redis 主线程被 accept 和 querybuf 的反复分配拖住,看起来像"读取变慢",实际完全是连接管理问题。用 INFO clients 看到大量连接反复建立后,问题就一目了然了。
最后一站:把调试器开在正确的地方
写到这里,Redis 读取与请求的核心路径已经完整走了一遍。如果你只能记住三个点,我希望是:第一,读事件优先让 Redis 在网络栈面前始终保持敏锐;第二,querybuf 的 SDS 设计和增量解析让"无拷贝读取、半包续传"成为可能;第三,慢查询日志的统计口径和你想象的并不一样,排查时要先搞清楚数据在哪一段变慢的。
我个人在学习这套源码时最大的收获,是养成了一个习惯:遇到 Redis 性能诡异现象,不急着改配置,先想清楚"数据目前停在哪个缓冲里"。是内核 socket 缓冲?是 querybuf?是解析后的 argv?是输出 c->buf?还是已经进了网卡?沿着这条流动路径去观测,你总能找到真正的问题环节。建议你把调试器断点设在 readQueryFromClient、processInputBuffer、lookupKeyRead 三个函数上,手动发一条 GET 配合观察,比读十篇博客都管用。