读Redis源码这件事,很多人一开始就被event loop、ae.c这些底层骨架吓住,总觉得要把网络框架整个啃穿才敢碰核心。其实切入角度可以更直接:一条命令从客户端发出来到拿回结果,在Redis进程内部到底经过了哪些函数、做了多少次分支判断、埋了多少钩子和统计?这条线串起来,就是Redis命令处理机制的完整骨架,也是理解Redis单线程为什么还能这么稳、主从复制和AOF为什么一致、慢日志为什么有时候查不到人等等一系列线上问题的总钥匙。
这篇文章适合两类人:一是刚接触Redis源码、想找个主线把调用链理顺的开发者;二是被线上诡异问题折磨过、想搞清“命令到底卡在哪一段”的运维或DBA。我按一次完整命令的生命周期来拆,从socket收包、RESP解析,到命令查找、准入校验,再到执行、传播、回复,全程结合源码关键分支和我的调参踩坑经验来讲。
1. 从Socket字节流到完整命令对象:命令处理的第一公里
先明确一个基础:Redis的服务端模型是事件驱动加单线程执行命令。所谓单线程,指的是命令的执行在主线一个线程里串行完成,而网络I/O的读写在Redis 6.0之后已经可以交给多线程辅助,但命令解析和执行的逻辑主体依然在主线程。这对理解整条链路非常关键,因为后面每一步都是在一个线程上下文里顺序完成的。
命令处理的起点是readQueryFromClient函数。当epoll报告某个客户端fd可读时,主线程会调用这个函数,把socket里的数据读入客户端的输入缓冲区client->querybuf。这里有个很关键的工程细节:Redis不是读一条解析一条,而是先用一个动态增长的SDS缓冲区把TCP流里的所有字节先累积起来,再统一按照RESP协议去切分。原因很简单,TCP本身是流式协议,一次read可能只读到半条命令,也可能一次读到了好几条命令拼在一起。如果没有一个缓冲层做粘包和半包处理,命令边界根本没法确定。
数据落入querybuf后,下一步是processInputBuffer。这个函数会按c->qb_pos位置循环扫描缓冲区内容,直到缓冲区里没有完整命令为止。扫描时,如果客户端走的是RESP协议,就走processMultibulkBuffer;如果是最早期的telnet纯文本模式,则走processInlineBuffer。绝大多数客户端都是用RESP协议,但Redis保留内联命令解析并不是没用的老古董,很多运维脚本直接printf 'PING\r\n' | nc连Redis时走的就是这条路。
RESP协议解析的逻辑也值得细看。命令格式是*<参数个数>\r\n$<参数长度>\r\n<参数内容>\r\n...,解析器必须逐字节移动指针,而不是简单按\r\n去split。为什么?因为参数内容本身可能包含\r\n,只有前面限定了长度,解析器才知道哪里是真正的边界。Redis的做法是先解析出参数个数ll,校验在合理范围内,然后根据每个参数的长度前缀去拷贝对应字节,构建出c->argv和c->argc。看到这里你会发现,协议解析本身就是一道防畸形输入的关卡:长度字段为负、数量超过PROTO_MAX_MULTIBULK_LEN、参数长度超过PROTO_MAX_BULK_LEN的请求都会被直接拒绝,并给客户端回一个协议错误。
解析完一批参数后,processInputBuffer会调用processCommand(c)进入命令分发阶段。这里有一个容易被忽略的细节:即使解析成功得到argv,命令是否真实存在、参数个数是否符合要求、当前状态下能不能执行,全都不是解析阶段管的,而是交给processCommand做准入控制。这种“先收包、后集中校验”的设计,让协议解析模块保持极简,也把可扩展的策略判断收敛到一个函数里。
2. commandTable:每一条命令的“户籍档案”
在进入processCommand之前,得先了解命令本身是怎么被组织的。Redis把所有命令登记在一张全局哈希表server.commands里,表的value是struct redisCommand,其中最关键的信息包括:
name:命令名,比如"get"、"set"。proc:执行函数指针,比如setCommand、getCommand。arity:参数个数要求。正数表示参数个数必须等于该值,负数表示参数个数至少为该值的绝对值。这个字段是命令能不能跑的第一道门槛。sflags和flags:命令属性,比如write、readonly、denyoom、admin、pubsub、noscript等。它们决定了这条命令在不同上下文里能不能放行。calls、rejected_calls、failed_calls:这三组计数器记录了命令被调用、被拒绝、执行失败的总量,正是INFO commandstats的数据来源。
populateCommandTable会在启动时把写死的命令表灌入哈希dict。看到这里你可能会问,命令总量也就200多个,为什么不用数组而要额外套一层dict?原因是命令查找是每次请求都要走的路径,用dict能换来O(1)的查找时间。而且dict天然支持命令名的动态注册,模块加载新命令时往dict里一丢就行,不用改主流程。命令名查找还有个细节:Redis对命令名做了大小写归一,比如GET和get都能命中,但key本身是严格区分大小写的,这两个层面的语义千万别混淆。
lookupCommand是查找命令的入口,内部就是dictFetchValue。但注意它只负责“命令在不在表里”,不负责“参数个数够不够”。参数个数校验发生在processCommand里,通过c->cmd->arity和c->argc比对完成。如果命令不存在,Redis会返回unknown command错误,同时把这条非法命令计入rejected_calls统计。这里有个实际排查经验:上线新命令后如果一直报unknown,可以先查COMMAND INFO看命令是否被正确加载,再确认是否连错了版本老的服务端,因为有些命令是新版本才有的。
命令flag体系是准入控制的核心。CMD_WRITE表示这条命令会修改数据,直接影响后续maxmemory检查要不要做;CMD_DENYOOM表示内存超限时禁止执行;CMD_ADMIN标记管理类命令,比如CONFIG、SHUTDOWN,这类命令在受控场景下会被拦截。理解了flag,后面processCommand里那一长串if判断就容易对号入座了。
3. processCommand:一夫当关的准入控制
processCommand(client *c)是整个命令处理链路里最核心的函数,我把它称为“命令准入的安检口”。它按严格顺序执行一系列检查,任何一项不满足就直接返回错误,不再往下执行。
第一步是命令存在性和参数数量校验。lookupCommand查不到命令,直接返回unknown command;查得到但c->argc和arity不匹配,返回wrong number of arguments错误。这一步必须放在最前面,因为后续的ACL、集群重定向等检查都依赖一个能定位到redisCommand的c->cmd。
第二步是ACL权限检查。Redis 6引入的ACL模块在这里以aclCheckCommandPerm的形式介入,它会校验用户名、命令权限、key权限,包括~pattern和%RW这类严格限定。ACL检查放在参数校验之后,是为了先用最廉价的检查挡掉一批明显非法的请求,再做代价更高的权限判定。这个顺序不是随便定的,ACL检查需要遍历规则链,比哈希查找贵得多,能少算就少算。
第三步是集群模式下的重定向检查。如果Redis以集群模式运行,而且key不属于当前节点,getNodeByQuery会计算key的哈希槽对应的节点,如果槽不在本节点,会返回MOVED或ASK重定向错误。这一步只对key相关命令生效,DBSIZE这类全局命令不在重定向范围内。实现上还要考虑多key命令跨槽的情况,比如MGET的两个key在不同槽且没有开启cluster-allow-cross-slot-keys,直接报CROSSSLOT错误。
第四步是内存检查。如果配置了maxmemory,且当前内存达到上限,同时命令带CMD_DENYOOM标志,Redis会拒绝执行并返回OOM错误。注意命令flag里有write但不一定有denyoom,比如DEL是写命令,但在内存超限时一般还允许执行——运维可能正想删数据来释放内存。这个区分非常实用,我在线上就见过有人误以为所有写命令在OOM时都会被拒,结果发现DEL还能用,又以为出了什么bug。补充一点,如果是主从切换时的只读从节点,在内存超限时还会多一层repl_slave_ro判断。
第五步是持久化状态检查。stop-writes-on-bgsave-error配置开启时,如果上次RDB保存失败且还没重试成功,Redis会拒绝所有写命令,防止旧数据和当前数据不一致进一步扩大。这一步在官网上有明确警告,但实际调参时经常被忽略,尤其是磁盘临时满导致RDB写失败后,用户会发现写入全部报错,第一反应往往不是查磁盘而是怀疑Redis坏了。
第六步是只读从节点检查。如果当前节点是只读从节点,并且命令带CMD_WRITE,直接拒绝。为什么在这一步才拒绝而不是更早?因为前面的OOM、磁盘等检查是节点级共性状态,先把它们拦完,再落到主从角色差异,可以让错误返回更精确。另一个反直觉的坑是:从节点在replica-read-only yes下不仅拒绝SET,也拒绝EVAL里内含写的脚本,判断条件是命令flag,而不是解析脚本内容。
第七步是订阅状态检查。当客户端处于CLIENT_PUBSUB模式时,只有SUBSCRIBE、UNSUBSCRIBE、PING、QUIT等少数命令可用,其余一律拒绝。这个限制是为了把订阅通道维持在一种“轻量命令白名单”状态,避免有人在订阅连接上跑大数据量的KEYS拖垮消息分发。
第八步是事务状态检查。如果客户端已经执行过MULTI,接下来的命令不会立即执行,而是进入queueMultiCommand排队,并把CLIENT_MULTI标志挂在client上。只有EXEC、DISCARD、WATCH、RESET这些命令能越过队列直接处理。这一步放在靠后的位置也是合理的:入队前需要确认命令本身合法、参数完整、权限没问题,否则排进去的命令会在EXEC时全部失败,而用户期望尽早暴露错误。
第九步是阻塞命令处理。像BLPOP、BRPOPLPUSH这类命令,如果其目标key没有数据,进程不会继续执行命令体,而是调用blockForKeys把客户端挂进阻塞链表,并返回C_BLOCKED,主线程接着去服务别的客户端。等有人往key里推入数据,handleClientsBlockedOnKeys会唤醒等待的客户端,重新执行之前解析好的argv。
这些检查全部通过后,processCommand会玩一个很有技巧性的把戏:把c->cmd的rejected_calls清零,把一个CLIENT_DENY_BLOCKING之类的临时标记处理掉,最后调用call(c, CMD_CALL_FULL)真正执行命令。我特意提顺序,是因为我自己排查过一个“能PING通但Redis写入超时”的问题,最后发现是maxmemory触发了第OOM检查,而PING不带denyoom所以一直放行。这个经历让我意识到,读懂这篇文章介绍的检查顺序,等于拿到了一张线上诡异问题的排查地图。
4. call():命令执行的“总装线”
processCommand是安检口,call才是真正的命令执行总装线。如果processCommand决定了“这条命令能不能跑”,那么call决定了“这条命令跑完之后,系统要做哪些善后工作”。call的核心逻辑看起来不长,但每行背后都牵着一套庞大的机制。
进入call后,第一件事是更新命令统计信息:c->cmd->calls++,并同时让server.dirty++。dirty这个全局计数器,表面意思是“上次持久化以来数据变更的次数”,实际是AOF和RDB判断“有没有需要落盘的东西”的核心依据。命令执行前先自增,确保即使命令执行过程中发生异常退出,脏标记也已经产生,不会漏掉一次写操作。
接着真正调用函数指针:c->cmd->proc(c)。比如执行SET,实际的setCommand会在这一层完成字符串对象的编码、过期时间设置、键空间通知、信号处理等全部工作。执行完之后,call会根据CLIENT_DIRTY_CAS等标志判断事务是否需要判失败。
执行完命令,call要处理慢日志。它计算这次命令执行的耗时duration = ustime() - call_start,然后传给slowlogPushEntryIfNeeded。重点来了:这里统计的时间是从proc前后取的,也就是只算“执行阶段”耗时,不算排队等待时间,也不算网络解析和回复发送时间。这就造成一个线上经典困惑:客户端明明看到某条命令响应了几秒,但SLOWLOG GET里找不到。真相往往是命令本身执行很快,只是积压在前面排队的命令太多,或者网络往返出现了延迟。搞清楚这点,再排查慢Redis时思路就不会跑偏。
执行完命令后,如果命令产生了写操作,需要把这条命令“传播”出去。propagate函数会同时做两件事:调用feedAppendOnlyFile把命令写成AOF格式追加到AOF缓冲,调用replicationFeedSlaves把命令写入主从复制积压缓冲区和每个从节点的输出缓冲。你可能会问,为什么不直接对每条命令都单独生成一次RESP格式?因为主节点、从节点、AOF三方必须看到完全一致的命令流,统一在传播层生成格式,既保证顺序一致,也让后续XAUTH、SETEX这种合成命令有统一的序列化机会。这个设计是Redis主从数据一致性的底座。
另外,call里还埋了一个容易被忽略的钩子:CMD_CALL_SLOWLOG和CMD_CALL_STATS这两个标志位控制慢日志和统计是否记录。processCommand传入CMD_CALL_FULL时两者全开,但Lua脚本内部在执行子命令时会走CMD_CALL_NONE路径,避免脚本内部命令被当作独立命令重复记慢日志和统计。这个细节在排查“某段时间慢日志暴涨但实际没有高频业务访问”时,能帮你快速区分慢在脚本本身还是脚本里的某条子命令。
命令执行完成后的收尾工作也很关键。call结束后,processCommand不会直接往socket里写数据,而是走到beforeSleep,由事件循环统一把客户端输出缓冲里的数据交给网络层刷出去。这种延迟写的设计,是为了规避单线程模型下在命令执行路径里做阻塞式write——万一客户端读得慢,主线程就会卡在写socket上,那Redis就真的“单线程卡死”了。真正的网络写发生在handleClientsWithPendingWrites里,它会尝试writeToClient逐客户端刷缓冲。
5. 命令的回复与闭环:addReply到底在做什么
执行命令后,结果必须回给客户端。Redis的所有回复都走addReply系列函数:addReply命令会解析出一条RESP格式返回,addReplyBulk返回批量字符串,addReplyError返回错误信息。这些函数最终都汇入prepareClientToWrite,把要发送的数据挂到客户端的输出上。
输出部分的数据结构很有意思:每个client维护一个固定16KB的栈上缓冲c->buf,写满了再挂到c->reply链表上,链表的每个节点也是16KB的reply_chunk。为什么要用这种两级结构?因为大部分命令的回复都很短,比如PONG、+OK\r\n,直接写在栈上缓冲里零拷贝零分配;只有回复很大时,才动态分配链式节点。这种设计既避免了每次回复都malloc的开销,又避免了一个客户端回复超大时把别的客户端内存挤爆。
发送阶段,writeToClient会把c->buf和c->reply链表里的内容尽量写入socket。但单线程服务端绝对不能在一个慢客户端身上无限等,写不完的数据就留在客户端输出缓冲区里,等下次事件循环再写。这个机制本身没问题,问题出在输出缓冲膨胀:如果某个客户端一直不读数据,Redis给它积压的数据会越来越大,直到触发输出缓冲限制。
输出缓冲区的强制约束在clientsCronHandleTimeout和writeToClient里等多个地方执行。硬限制是字节数直接超过就断开连接;软限制是缓冲区持续一段时间超过阈值才断开。默认配置client-output-buffer-limit normal 0 0 0表示普通客户端不限,但pubsub和slave客户端有明确默认限制。这里有个血泪教训:我接过一个大并发订阅场景,某消费者组里有一个成员长期不消费,PUBSUB的输出缓冲越积越大,最后把整个实例的内存消耗顶上去,主节点开始频繁OOM。用CLIENT LIST一看,某个客户端obuf打到几百MB,立刻就能定位是消费端代码停了而不是Redis的问题。
还有一个极其关键的关闭路径细节:如果一个客户端因为输出缓冲超限或协议错误需要被断开,Redis不会立刻在当前位置直接free掉client对象,而是调用freeClientAsync把要释放的客户端挂到一个异步释放队列,等到事件循环的安全点再执行真正的freeClient。为什么这么绕?因为当前执行上下文可能正持有这个client的指针,如果在某个栈帧里直接把client释放,回头再访问c->next或者c->querybuf就是悬垂指针,是非调试模式下极难复现的crash。理解这个设计后,再看一些第三方Redis客户端突然断连的报错,就能意识到不是客户端bug,而是服务端在异步释放路径上主动断开了。
6. 高频问题排查实录:用命令处理机制定位线上毛刺
我积累了几个和上面这套机制强相关的排查案例,写出来供参考。
第一个是“OOM拒绝但不报错”的假象。某业务反馈Redis写入时不时丢数据,日志里又没明显错误。用INFO stats看rejected_calls有数值增长,再查maxmemory和used_memory发现内存已经顶到上限但maxmemory-policy是noeviction。这时写命令其实已经全部被拒,只是因为客户端日志级别太高被吞了。定位到这个机制后,调大内存规格并配一套合理淘汰策略,问题立刻缓解。
第二个是“慢日志找不到慢命令”。客户端监控持续报警,某条命令P99延迟很高,但SLOWLOG GET只有几条系统命令记录。这就是前面说的慢日志只统计call内执行耗时,不统计排队和网络耗时。验证方法是在客户端打点记录发出命令到收到回复的时间戳,同时服务端用INFO commandstats对比单条命令的avg_us。如果两端差异巨大但服务端本身执行耗时很低,问题在连接或客户端线程模型,不在Redis。这个案例说明,理解慢日志统计口径,本身就是一种排障能力。
第三个是“从节点写入失败”。主从切换后,某写入脚本一直在从节点上运行,突然全部写命令失败,但读命令正常。这是因为该从节点replica-read-only yes,写命令全部被processCommand的只读从节点检查拦截。但业务往往先怀疑网络分区或主从同步断掉。看redis-cli INFO replication里的role字段,确认当前是slave后,基本就是这层拦截在起作用。遇到这类问题,翻命令处理机制里的检查顺序比反复重连效率高得多。
第四个是“事务里的命令全失败但EXEC没有报错”。业务反馈用MULTI执行一批写入,单独执行每条都成功,放在事务里却全部失败。实际原因是事务入队阶段,processCommand已经把命令参数、ACL、OOM等前置检查走完了,如果入队时命令已经因为OOM被拒,EXEC时整个事务取消,返回EXECABORT。日志里看不到每条命令的失败原因,只看得到EXEC被取消。解决方法是提高maxmemory或改造事务结构,把可能失败的写操作从事务里拆出来,因为事务本身不适合承载长耗时、易失败的多步写。
第五个是“慢客户端拖垮实例内存”。现象是实例内存持续上涨但used_memory里client_recent_max_input_buffer或client_recent_max_output_buffer特别高。用CLIENT LIST查看obuf列,一旦发现某个客户端obuf几十MB以上,基本就是这个客户端不消费服务端回复。确认后直接CLIENT KILL这个链接,内存立刻回落。这里强调一下,PUBSUB类客户端特别容易触发,因为发布频率高的时候,服务端往订阅客户端写回的消息量可以远远大于该客户端的消费速度。
7. 源码阅读路线与小技巧
如果你想亲自把这条链路读完,我给你一条不劝退的路线。
先读server.c里main函数,找到socket监听和aeMain事件循环的入口,知道acceptTcpHandler、readQueryFromClient、sendReplyToClient这些回调的注册位置。然后跟着本文的顺序:readQueryFromClient->processInputBuffer->processMultibulkBuffer->processCommand->call->addReply->writeToClient。每次只看一个函数,带着“这一层解决了什么问题”的问题去读,比从头到尾看ae.c要高效得多。
调试时建议用GDB在processCommand和call打断点,打印c->argc、c->argv[0]、c->cmd->name、c->flags,很快能建立起“命令进入执行期”的具体感觉。如果不想打断点,也可以临时在processCommand入口加日志,统计收到的命令名和执行耗时。再配合INFO commandstats和SLOWLOG GET,线上定位问题时基本够用。
我还想提一点工具层面的技巧:COMMAND命令能输出整张命令表的信息,包括arity和flags,调试命令匹配问题时特别有用。另外开一个本地从节点,用REPLICAOF连到测试主节点,观察INFO replication里master_repl_offset的增长,能直观看到传播机制在起作用。如果改了源码想验证某个命令是否被正确传播,在从节点上请求对应命令查数据变化就行。
源码阅读最大的门槛不是语法,而是不知道函数之间怎么串起来。我建议你把我上面提到的那几个核心函数名打印贴在显示器边上,遇到新函数就回到主线上问一句“这是从哪个环节调进来的,它要解决什么问题”,读起来会顺畅很多。Redis的注释和命名风格整体很直白,只要主线清晰,其他都是枝节。最后补一句:别在线上Redis实例上试这些排查命令里的危险操作,所有验证都在测试环境做,这是源码探究最基本的自觉。