
同样是做业务开发的朋友应该都遇到过这样的场景线上Redis实例明明CPU不算太忙内存也够用响应时间却时不时地往上跳一下。这时候大多数人要么盲目加机器要么重启大法很少有人会去问一个本质问题——Redis从收到一串字节流到真正执行命令中间到底经历了什么我之前就是被这类问题折磨过很多次后来下定决心把相关源码读了一遍结果发现很多“看起来玄乎”的线上问题其实都藏在命令处理机制的几个关键节点里。这篇就把我对这个机制的理解和探究过程完整写出来不光是函数调用链还包括设计者在每个关键位置“故意”做的选择以及这些选择对我们在使用层面产生的影响。1. 命令抵达的“前夜”网络事件如何唤醒Redis很多人觉得Redis是内存数据库性能瓶颈应该在内存或命令本身。但真正去追源码就会发现一条命令从客户端发出去到Redis真正开始解析中间要经过一整套网络事件分发机制。这套机制的核心就是Redis自己实现的一个基于Reactor模式的事件循环代码里叫aeMain。1.1 从aeMain到文件事件命令到达的第一声门铃Redis启动后主线程会进入aeMain()这个循环干的事情非常单纯不断调用aeProcessEvents()等待两类事件——文件事件socket可读可写和时间事件周期任务、定时任务。文件事件是理解命令处理的关键。Redis启动时会创建一个监听socket并通过aeCreateFileEvent()把socket的可读事件挂到事件循环上回调函数是acceptTcpHandler。大家可以把这套机制理解成一个餐厅的服务台监听socket只是门铃真正进来坐下消费的客人是后续accept出来的连接socket每个连接socket都有一张独立的“桌台”client对象。这里有个容易被忽略的设计选择Redis事件循环里用的是I/O多路复用生产环境默认是epoll。它并不是每个连接分配一个线程而是所有连接的文件事件都扔进同一个事件队列里由单线程逐个处理。这就是后来很多人说的“单线程模型”在网络层面的真相。1.2 acceptHandler与client对象的诞生当新的TCP连接到达时acceptTcpHandler被触发往下走是acceptCommonHandler最终调用createClient()。这个函数值得细看它做的事情不只是分配一个结构体client *createClient(connection *conn) { client *c zmalloc(sizeof(client)); if (conn) { c-conn conn; c-addr NULL; connSetReadHandler(conn, readQueryFromClient); ... } c-bufpos 0; c-querybuf sdsempty(); c-querybuf_peak 0; ... }几个关键点connSetReadHandler(conn, readQueryFromClient)这一步把连接的可读事件和readQueryFromClient绑定。之前监听socket绑定的是acceptTcpHandler现在是连接socket绑定readQueryFromClient。也就是说门铃响是一个入口客人入座后点餐又是另一个入口。初始的querybuf是一个空的sds字符串。后续往里面追加数据时会逐步扩容但它不是一次性分配一个很大的缓冲而是用多少涨多少。client对象头顶上带着bufpos、querybuf这类字段这是整条命令处理链路的第一站。每个连接还会分配一个输出缓冲区回复内容先写进缓冲区再统一刷给客户端。所以你看从这里开始一条命令就变成了连接可读 -readQueryFromClient- 读入querybuf- 解析 - 执行。后面所有的事情都是从querybuf里的字节流开始的。2. 协议的“翻译官”从RESP字节流到命令参数客户端和服务端之间必须说同一种“方言”。Redis的方言就是RESP协议全称REdis Serialization Protocol。想知道一条命令是怎么被拆分出来的必须理解协议解析这一段。2.1 RESP协议的基本形态RESP协议在绝大多数场景下长这样*3\r\n $3\r\n SET\r\n $3\r\n key\r\n $5\r\n value\r\n*3\r\n表示后面有3个参数命令名本身也算一个$3\r\n表示接下来是一个长度为3的字符串随后紧跟的SET就是命令名命令名是大小写不敏感的Redis内部会统一转成大写再来查表。参数个数、每个参数的长度全都由*和$两行来决定。有人会问为什么Redis不用JSON或XML做协议原因很简单协议解析要快而RESP是纯文本且每个参数之前都自带长度可以非常快速地分割字段不需要做流式解析和转义处理解析吞吐极其惊人。代价就是表达能力有限但对于键值操作指令来说完全够用。2.2 processInputBuffer积攒到一批就开工来看代码时重点在readQueryFromClient与processInputBuffer之间。前者只负责读数据往querybuf里追加后者才真正开始解析。void readQueryFromClient(connection *conn) { ... nread connRead(c-conn, c-querybuf qblen, readlen); ... if (c-querybuf ... processInputBuffer(c); }但processInputBuffer不是每读到一点数据就立刻执行Redis会攒一批。如果客户端一次发送了很多条命令Redis会把它们都读进缓冲然后在这个函数里循环解析执行。这里有三个隐藏细节值得展开命令不是“一条到达就处理一条”。TCP是字节流没有消息边界所以Redis必须自己从字节流里解析出一个个完整的命令。processInputBuffer内部就是在重复“解析一条、执行一条、再解析下一条”的循环。解析过程中如果遇到不完整的命令就会停下来。比如只收到了*3\r\n$3\r\nSET还没收到后面的部分Redis不会阻塞等待而是直接跳出本轮处理等后续数据到达后再继续。这保证了非阻塞网络模型不被不完整的半包卡死。内联命令也兼容也就是不遵守RESP协议直接发类似SET key value这样的文本行。这种命令会被processInlineBuffer处理但效率上远不如RESP用于某些特殊调试场景。2.3 查询缓冲的上限防的是“无限积压”querybuf是无界增长的吗不是。Redis在readQueryFromClient里处理完数据后会做一次querybuf大小的检查。如果querybuf超过client_max_querybuf_len默认1GB会直接记日志并关闭连接。虽然1GB在实际业务中几乎到不了但设计上确实防着慢消费者把内存拖垮。这里补充一点1GB只是默认值在高并发且单个连接可能发送超大key写入的场景里要提前评估这个值是否需要调整。另外还有一个querybuf_peak字段记录缓冲区的历史峰值方便观测内存使用的“尖峰”。这为后续思考“一个连接占多少内存”提供了直接依据。这一段的结论是协议解析的产出物是一组参数数组argv和参数个数argc其中argv[0]就是命令名。接下来的流程就围绕这组参数展开。3. processCommand命令执行的“总调度室”协议解析完成之后控制权交到了processCommand(server.c)手上。这个函数是整个命令处理机制的中枢任何一条命令想被执行都必须在这里过一遍“准入审查”。3.1 命令查找lookupCommand只是起点processCommand的第一步是调用lookupCommand去命令表里查命令。struct redisCommand *cmd lookupCommand(c-argv, c-argc);这个查找是O(1)的因为命令表是一个哈希表key是命令名统一小写之后的字符串value是redisCommand结构体。每个命令在Redis启动时就注册好了比如SET、GET、LPUSH全都登记在redisCommandTable里。但lookupCommand有个细节如果查询失败返回的不一定是NULL可能是一个叫unknownCommand的特殊命令对象。这样设计的用意是让后面处理统一走“执行命令”的路径而不是单独写一套分支去处理未知命令。未知命令也能有自己的一套回复逻辑。redisCommand结构体里有哪些重要字段核心是这几类proc真正的函数指针比如setCommand、getCommandarity参数个数要求负数表示最少参数个数flags命令的各种属性标记比如写命令、只读命令、管理命令firstkey/lastkey/keystep用于定位命令涉及的第几个参数是key这影响后面的键空间检查3.2 身份与权限检查不是所有命令都有资格进入执行阶段找到命令结构体后processCommand会做一系列检查。按顺序来基本是这样的是否已经认证。如果设置了requirepass或者启用了ACL而客户端还没认证除了AUTH和HELLO等少数命令外一律返回NOAUTH错误。ACL权限校验。现在Redis的ACL可以精确到“某个用户对某个key有没有权限”这一点在命令执行前会检查。ACL的检查逻辑很重涉及用户、命令权限位、key pattern匹配但被压缩在一个aclCheckCommandPerm调用里。命令是否被禁用。rename-command配置可以把某些命令改名或禁用它processCommand会按配置后的名称查找因此查不到就是禁用。我读这段源码时最大的体会是Redis把“认证”和“鉴权”分层了认证解决“你是谁”鉴权解决“你能做什么”。很多二次开发者在给Redis做权限扩展时图省事故意绕过ACL这其实是埋雷。源码里ACL和权限炸弹的嵌套逻辑已经非常缜密最好直接复用不要另起炉灶。3.3 多实例状态约束节点角色与内存水位再往下processCommand会检查当前Redis实例的状态是否允许执行写命令。这轮检查非常有意思每个分支都对应着一个真实的运维场景内存超限检查如果配置了maxmemory且当前内存超限freeMemoryIfNeeded会尝试淘汰。如果还是没有足够内存除了FLUSHALL这类命令外写命令会被拒绝返回OOM错误。这个检查是用“是否能回收”来预判“是否允许写”很巧妙但也带来一个反直觉结果——删除类命令在某些内存策略下也可能被拒绝。持久化状态检查stop-writes-on-bgsave-error开启时如果后台BGSAVE失败了写命令会被拒绝。这是为了保护数据一致性避免主进程继续写而持久化层已经失灵。复制状态检查如果是副本节点且配置了replica-read-only则写命令在processCommand阶段直接拒绝。集群状态检查集群模式下命令涉及的key必须属于当前节点负责的槽位否则直接返回MOVED或ASK重定向错误。这轮“体检”跑完后命令才真正进入执行阶段。但你可能会问顺序为什么这么重要因为检查顺序就是成本顺序先做便宜的操作查表再做贵的操作ACL、内存检查可以让无效请求尽早退出避免浪费资源。4. call函数执行背后的“隐藏义务清单”很多人以为processCommand执行完就是简单调用一下命令函数、拿到结果、写回客户端。实际上在命令真正交给c-cmd-proc(c)之前有一个很小的入口——call()函数。这个函数才是真正“干大事”的地方它在命令前后附加了很多开发文档里不会明说的义务。4.1 为什么会有call这一层包装call函数把“命令执行”包装成一个标准流程不管命令是简单如PING还是复杂如LUA脚本调用都统一走这套流程。注意server.call的宏定义和结构体里的proc不同多了一层包装。试想如果不做这层包装每条命令都得自己关心慢日志、AOF、复制传播那代码就写死了。call()内部做的事情按时间顺序大致如下执行前记录命令开始的时钟用于后续慢日志计算。命令真正执行c-cmd-proc(c)。这是用户写的命令函数第一次被调用。统计server.dirty每条写命令执行后dirty计数器都会累加这个计数决定BGSAVE或AOF重写时“到底有多少新数据发生变化”。很多同步策略都依赖这个累加值。检查是否触发key过期、事件通知等后续动作。如果开启了AOF把命令写入AOF缓冲。如果存在副本节点把命令传播给副本。这个顺序很关键。比如AOF的写入和复制传播都必须是“命令执行成功后”才做否则命令执行失败还写进AOF重放时会出问题。4.2 慢日志与dirty计数一个容易踩坑的细节慢日志的判定来自执行耗时但这个“执行耗时”是在call()里用clock()计算的它不包含网络读取和协议解析的时间。所以你会遇到一种情况一条命令从客户端视角看很慢但慢日志里找不到。原因是慢发生在等待排队或网络传输上而不是命令本身执行时间长。这个细节在我实际定位线上问题时帮过大忙。有一次一堆SET命令偶发超时慢日志里居然什么都没有后来才发现是用了一个极慢的客户端连接池绝大部分时间浪费在连接获取上。如果不是先从源码里确认慢日志统计口径我可能会在错误的方向上排查很久。4.3 回复的写入与发送不是立即send命令执行完结果会通过addReply系列函数写入客户端的输出缓冲区。这里有小缓冲和大缓冲的区分优先写c-buf默认16KB的连续内存如果放不下就进入c-reply链表节点。重点来了这些回复不是立刻send给客户端的而是等当前事件循环的所有事件处理完之后在beforeSleep里统一刷一次。这就是著名的“合并写”优化。想象一下餐厅后厨炒菜不会炒一盘端一盘而是把所有出锅的菜一起端出去减少来回跑的次数。刷缓冲区的函数是handleClientsWithPendingWrites它会遍历所有有待写回复的客户端尝试写数据。如果一次写不完剩余部分注册到下次可写事件里继续写。这条链路上有两个调优点output buffer limit软硬限制以及write 2的tcp-nodelay。但那是另一个深水区这里先不展开。5. 单线程外壳下的并发秩序IO线程与执行边界聊到这里一个绕不开的问题浮现出来既然命令处理是几乎全程单线程的那为什么Redis 6.0之后又引入了多线程IO这部分如果不读源码很容易理解成“Redis变成多线程了”其实完全不是那么回事。5.1 单线程的边界到底划在哪里Redis的核心命令执行即call()以及c-cmd-proc(c)这一层的代码始终是单线程的。这个设计让内部数据结构不存在多线程竞争问题不用加锁、不用考虑内存屏障、性能高且实现简单。单线程不意味着只有一个线程。Redis里其实还有后台线程bio_close_file负责异步关闭文件bio_aof_fsync负责AOF的fsync刷盘bio_lazy_free负责异步释放大对象这些后台任务处理的是“耗时的、不涉及核心数据结构的”操作让主线程不被文件IO阻塞。有一个典型场景删除一个大key比如几百万成员的set时如果同步删除主线程会被卡很久。所以引入了unlink和lazy free机制把释放内存的活交给后台线程。5.2 IO线程只搬运不处理Redis 6.0引入的I/O线程其定位非常明确帮助主线程分担socket的读写工作但绝不参与命令执行。它不是把连接固定分配给某个线程而是继续由主线程accept然后在可读事件触发时把这次需要处理的客户端的读操作交给多个IO线程并行执行。IO线程读到的数据进入querybuf后主线程再做协议解析和执行。同样执行完的结果先攒着由IO线程帮忙并行write给客户端。看源码里io_threads_op这个变量就知道它用状态机控制IO线程的两个阶段IO_THREADS_OP_READ和IO_THREADS_OP_WRITE。每次切换都有严格的内存屏障保证线程之间的可见性。有个数值值得注意io-threads默认是4且官方建议写成线程数的几倍并不合理通常设为4到8就够了再往上谁用谁知道线程上下文切换的成本可能盖过收益。而且io-threads只对socket读写有效如果你把io-threads-do-reads关掉那读还是走单线程只有写走多线程。5.3 全量理解之后对“并发”的再认识读完这些代码我对并发有了新的理解。Redis的观点是核心数据结构的并发冲突成本远大于多线程带来的吞吐收益。与其一个个加锁不如用一个主线程独占数据然后用多线程处理那些不存在共享状态的IO部分。这个思路在我们设计自己的中间件时很值得借鉴。但也要注意单线程执行意味着单条命令不能做CPU密集操作。比如KEYS *在大库上执行会阻塞很久Redis官方甚至会在文档里直接提醒生产环境禁止用KEYS。一旦理解了执行模型你就会明白这不是接口设计问题而是单线程模型的必然推论。6. 源码阅读的“登山路线”从入口到排障实战看源码最怕的不是代码难而是像无头苍蝇一样乱钻。Redis的命令处理相关源码主要集中在server.c、networking.c、db.c、ae.c几个文件里。给想深入探究的朋友一条我实际验证过的路线。6.1 从一条命令的“生老病死”贯穿主链路我建议的阅读顺序从redis-cli发出一条SET命令用gdb或strace观察系统调用connect、send、recv、write。从readQueryFromClient出发顺着processInputBuffer到processCommand最后到call。主线代码量不大但每个函数都值得细读。额外阅读aeMain的事件循环理解非阻塞网络下“事件驱动”的含义。最后啃一下server.c里的初始化流程理解命令表是怎么被填充的。调试时有个实用技巧在processCommand里打断点然后手动发一条命令用bt查看调用栈基本就能把网络到执行的全链路看清。我在调试时还常设置一个条件断点条件是参数首字母或命令名匹配某个字符串这样就不会被其他连接的杂音干扰。6.2 一次线上命令延迟问题的定位过程用一次实际的排障经历来收尾。我之前在某个业务里遇到一个奇怪现象线上实例定期出现几十毫秒的延迟毛刺慢日志里找到一条SORT命令耗时三十多毫秒。一开始怀疑是CPU受到了干扰但看监控并没有明显波动。接着怀疑是网络可单条命令的RTT并没有变化。后来对照源码里慢日志的统计口径确认这个耗时确实发生在命令执行本身于是去看执行路径。最终发现SORT命令没有走sortCommand的快速路径而是触发了磁盘交换和临时内存分配再加上当时命令参数里还带了BY、GET多个字段复杂度完全不一样。这次的启发是不要只看“慢日志里的命令是什么”还要结合命令参数和当时的实例状态去源码里寻找命令实现是否存在某种“慢分支”。很多时候一条命令是否慢不是命令名字决定的而是参数组合和底层数据状态决定的。6.3 我总结的三条源码阅读心法第一带着问题读源码效率远高于按顺序读。你有一个具体的故障现象去追根溯源记忆和经验都更深。第二善用git历史。Redis每一个重大特性都有对应的提交记录和设计讨论比如IO线程的引入、ACL的实现读一遍PR讨论比你盲读十遍代码都有用。第三把源码里的选择翻译成“为什么”。看到querybuf的1GB默认值就问为什么是1GB看到单线程就问为什么不加锁。这种习惯让你学到的不只是代码而是设计决策。回归到命令处理机制本身Redis的设计核心概括起来就三句话事件驱动接客协议解析进门单线程管秩序call层做善后。只要把这条主链路的每个节点吃透线上再遇到神奇的延迟波动、命令不执行、复制卡住这类问题你就不会再靠猜了。