拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Redis读取与请求链路解析:从epoll到RESP协议的全路径剖析

接触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/pollRedis 面对的场景是海量连接、低频活跃。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 字符串需要到处处理 \0SDS 没有这个问题。读取数据时 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_ERRprocessInputBuffer 退出循环。下一轮 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 查找本身也有隐蔽逻辑如果哈希表正在渐进式 rehashdictFind 需要同时查新旧两张表。哈希表扩容在读取路径上对单个 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 设为 yesreadQueryFromClient 这类读取系统调用也可以交给 IO 线程但命令解析、命令执行这些 CPU 密集步骤仍然在主线程串行完成。所以多线程 IO 优化的是系统调用占用这一个环节它解决的是高并发小请求场景下大量 read/write 中断主线程的问题但它不会让一条慢命令变快。如果你的服务端 CPU 已经 100% 打满靠开 IO 线程救不回来反而可能因为上下文切换变得更慢。这个边界一定要守住不然调参会走弯路。5.4 一套实用的读取链路排查路径结合前面的原理我平时在线上排查读取慢的问题会按这个顺序来步骤命令/工具重点关注看整体延迟redis-cli --latency -h网络 RTT 与 Redis 处理耗时的初步分割看连接与缓冲区INFO clientsbiggest_input_buf、输出缓冲是否异常增大看命令级慢操作CONFIG SET slowlog-log-slower-than 10000; SLOWLOG GET是否存在非 FAST 标志的慢命令看内核热点perf top -p redis_pid热点是 dictFind 还是 write 系统调用盯系统调用strace -p -e traceread,writeread/write 的单次耗时和调用频率这一套做下来基本能分辨是客户端自身消费太慢、是网络链路抖动、还是 Redis 内部真的在执行重量级操作。我自己印象最深的一次线上事故是客户端连接池里的空闲连接被服务端大量关闭导致客户端不断重连Redis 主线程被 accept 和 querybuf 的反复分配拖住看起来像读取变慢实际完全是连接管理问题。用 INFO clients 看到大量连接反复建立后问题就一目了然了。最后一站把调试器开在正确的地方写到这里Redis 读取与请求的核心路径已经完整走了一遍。如果你只能记住三个点我希望是第一读事件优先让 Redis 在网络栈面前始终保持敏锐第二querybuf 的 SDS 设计和增量解析让无拷贝读取、半包续传成为可能第三慢查询日志的统计口径和你想象的并不一样排查时要先搞清楚数据在哪一段变慢的。我个人在学习这套源码时最大的收获是养成了一个习惯遇到 Redis 性能诡异现象不急着改配置先想清楚数据目前停在哪个缓冲里。是内核 socket 缓冲是 querybuf是解析后的 argv是输出 c-buf还是已经进了网卡沿着这条流动路径去观测你总能找到真正的问题环节。建议你把调试器断点设在 readQueryFromClient、processInputBuffer、lookupKeyRead 三个函数上手动发一条 GET 配合观察比读十篇博客都管用。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门