聊到 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 设置了单次读取的上限,一般是 16KB(PROTO_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 用的是 RESP(REdis 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 会拒绝。
第三道是内存检查,这里非常关键:如果开启了maxmemory,Redis 在执行写命令前会先尝试释放内存,如果内存仍然超限,则会拒绝写入并返回 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),是因为底层真正成熟后用的是 dict;ZSet 的排序读取用跳表,单点查找依然是 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 stats,total_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 拖垮实例这类问题,脑子里就有一套完整的检查清单,而不是漫无目的地试参数了。