说实话,Redis的命令处理机制我一直觉得是被低估的源码阅读入口。很多人一上来就啃数据结构、持久化,可我自己的经验是,理解服务器怎么“接住”一条命令、怎么认出它、怎么放行它、怎么执行它、再怎么把结果送回去——这一整条链路通了,Redis的很多设计决策都会跟着豁然开朗。最近我又一次排查线上命令耗时异常,借这个机会把整条链路从头到尾重读了一遍,然后把代码路径、关键函数和我在阅读时觉得最容易踩坑的地方整理成这篇文章。
这篇文章适合这么几类人:准备啃Redis源码但不知道从哪下手的开发者,对“单线程为什么还能扛高并发”这件事有疑惑的人,以及正在排查命令延迟、输出缓冲区异常等线上问题的运维/后端同学。文章里我读的是7.x分支,更早版本里部分函数名和调用链会略有差异,但核心思路基本一致,按我给的入口去找一定能对上。
1. 调试准备:从一条GET命令的完整旅程确定源码追踪路径
1.1 源码入口地图:先知道该翻哪些文件
在读源码之前,第一件事不是打开某个函数就开始读,而是先建立一张“入口地图”。Redis的命令处理链路横跨了好几个文件,但每个文件承担的职责非常清晰:
src/server.c:服务端核心逻辑,包括命令表注册、processCommand调度、call执行器都在这里。src/networking.c:客户端连接、输入缓冲读取、协议解析、回复输出都在这里。src/ae.c:事件循环,Redis的“心脏”,所有IO事件都是在这里被轮询、分发。src/db.c和src/t_*.c:各种具体命令的实现,比如GET命令的逻辑在t_string.c里。src/aof.c和src/replication.c:命令传播相关,AOF追加和从库同步都靠这两个文件。src/slowlog.c:慢查询日志,命令执行完了如果超时会记到这里。
这里有个特别有意思的点:命令表一开始并不在server.c,而是在命令实现旁边定义好,再由一个统一的注册过程把它们全部塞进一个字典。这点我在第2节仔细讲,先说清楚入口结构。
1.2 一条命令从socket到返回的十一步全景
为了不让自己读代码时迷失方向,我习惯先把一条命令的完整生命周期画成一条“追踪链”。我以最简单的GET key为例,从头到尾大致是这样的:
- 客户端发送
GET key\r\n到TCP连接。 - Redis事件循环检测到客户端socket可读,触发
acceptTcpHandler或已连接客户端的readQueryFromClient回调。 readQueryFromClient把socket里的字节读进客户端的输入缓冲区c->querybuf。processInputBuffer按RESP协议解析出参数数组,即argv[0]="GET"、argv[1]="key"。processCommand被调用,第一件事是查命令表,找到GET对应的redisCommand结构体。- 做一系列前置检查:鉴权、参数个数、内存、集群重定向、ACL权限等。
- 检查通过后,调用
call(c, CMD_CALL_FULL),这是真正的执行入口。 call内部通过c->cmd->proc(c)调用getCommand函数,从哈希表里查key并构造返回值。- 命令执行完成后做善后:统计执行次数、记录耗时、AOF传播、从库传播、慢日志记录等。
addReply把返回值写成RESP格式,先尝试塞进客户端固定缓冲区,塞不下就挂到回复链表。- 事件循环发现该客户端有数据要写,触发写事件回调
sendReplyToClient,最终通过writeToClient把数据写回socket。
这十一步就是Redis命令处理的完整骨架。后面所有章节,本质上是把每一步拆开揉碎,讲清楚“为什么是这个函数”“为什么这样设计”。
2. 命令表:Redis怎么知道“能执行什么命令”
2.1 redisCommand结构体与命令身份信息
先问一个看起来没什么问题的问题:Redis怎么知道客户端发来的字符串“GET”对应哪个处理函数?
答案是:每个命令在启动时都会被注册成一个redisCommand结构体,然后塞进一张全局命令表。打开server.h,你会看到类似这样的定义:
struct redisCommand { const char *name; /* 命令名,比如 "get" */ redisCommandProc *proc; /* 命令处理函数指针,比如 getCommand */ int arity; /* 参数个数约束 */ uint64_t flags; /* 命令属性标志 */ long long calls; /* 被调用次数 */ ... int key_specs_num; /* key位置描述符数量 */ redisCommandKeySpec *key_specs; /* 描述哪些参数是key */ ... };我当初第一次看到这个结构体时,最惊讶的是它居然还记录了“被调用次数”和“key位置描述”。后来才明白,这根本不是为了统计而统计,而是为了让之后的慢日志、ACL检查、集群分片逻辑都能复用同一份元数据。比如7.0之后引入的key_specs字段,可以告诉系统“这个命令的第1个参数是key、第2个参数是value”,这样ACL权限检查和Cluster模式的key提取就不用再去写一堆硬编码的判断了。
这个设计思路值得单独划重点:把命令的“身份信息”和“行为信息”集中放在一张表里,等于把分散在代码里的判断都收拢成了数据。后面你要增加一个新命令,或者在模块里注册一个自定义命令,都只需要往这张表里加一条记录,而不是去改十几个if分支。
2.2 命令注册:从静态表到字典
那这张表是怎么生出来的?在server.c里有一个静态数组redisCommandTable,里面罗列了所有内置命令。数组的末尾用{NULL}哨兵表示结束。核心注册函数是populateCommandTable,它会遍历这个数组,把命令名作为key、redisCommand结构体指针作为value,全部塞进server.commands这个字典里。
为什么要用字典而不是在代码里写一堆strcmp或者switch/case?两个原因:一是O(1)查找性能,命令分发是每条命令必经路径,必须快;二是扩展性,Redis模块可以命令表里注册自己的命令,如果是硬编码分支就完全没法扩展了。
这里有个容易忽略的细节:命令表的key是小写命令名。也就是说,无论客户端发来的是GET、Get还是get,最终查字典用的都是小写形式。这个大小写统一操作在processCommand里完成,所以我记忆里这部分的经典代码是先把argv[0]的首个字符做一次遍历转小写,再调用lookupCommand去查。
顺带提一句命令别名。有些命令经历了改名,但Redis为了兼容老客户端,会在命令表里同时注册新旧两个名字,指向同一个处理函数。你读源码时会看到这种“两个名字、一个proc”的条目,这种历史包袱并不丑陋,反而说明命令表这个设计给演进留了足够的空间。
2.3 arity与flags:参数约束和命令属性如何生效
命令表中每个命令都有arity字段,它描述了这个命令能接受多少个参数。这里有个约定经常有人搞错:arity的含义包含了命令名本身,也就是整个argv数组的长度。
- 正数:参数个数必须严格等于这个值。比如
GET的arity是2,所以GET后面只能跟1个key,否则返回“wrong number of arguments for 'get' command”。 - 负数:绝对值为参数个数的最小值。比如
SET的arity是-3,表示SET key value至少有3个参数,但还允许额外的参数(比如EX、PX、NX等选项)。
再看flags。这个字段在旧版本里是一串数字位掩码,新版本则改成了可读字符串,比如"write use-memory @string"这种。核心的flag包括:
| flag | 含义 | 影响 |
|---|---|---|
write | 命令可能改写数据 | 只读从库会拒绝执行,OOM时可能被拦截 |
read-only | 命令不会修改数据 | 过期键清理等场景会特殊对待 |
denyoom | 命令需要额外内存 | maxmemory触发时,内存不足则拒绝 |
fast | 命令时间复杂度低 | 事件循环判断长耗时命令时参考 |
loading | 允许在持久化加载期间执行 | 数据载入中只有特定命令能跑 |
stale | 允许在从库数据过期状态下执行 | 配合slave-serve-stale-data配置 |
这些flag看起来只是属性标记,但真的是只读从库、内存淘汰、持久化加载状态这些“旁路逻辑”的主要依据。我在后面第3节会结合processCommand的前置检查,带你看这些flag是怎么被一条条用起来的。
3. processCommand:命令执行前的“调度大厅”
3.1 命令名统一小写:lookupCommand的分发逻辑
到了processCommand这一步,命令已经不是一个“请求文本”,而是一个已经解析好的argv数组。我前面提到过,这里的第一件事是统一命令名大小写,然后查字典拿到对应的c->cmd指针。如果查不到,就会返回“unknown command”错误。
看到这里我建议你留意一个细节:命令查找发生在参数个数校验之前。也就是说,你输入一个不存在的命令,Redis会先告诉你“命令不存在”,而不是“参数个数不对”。这个顺序是刻意的——参数校验依赖arity,而arity必须从命令表拿到,所以命令不存在就没有校验参数个数的依据。
lookupCommand的代码非常简洁,核心就两句话:从server.commands字典里查argv[0],查到就把value转成redisCommand*返回,查不到返回NULL。但简单归简单,这个函数是整个命令分发的咽喉。
3.2 一路检查:鉴权、参数、内存、节点状态
拿到命令对象之后,processCommand就进入了一长串“if检查”。千万不要觉得这些检查是随意堆出来的,它们的顺序其实很讲究。我按执行顺序给你梳理一遍,并解释为什么是这个顺序:
第一步是鉴权检查。如果服务器设置了requirepass,而当前客户端还没通过AUTH认证,那么它只能执行AUTH、HELLO、QUIT、RESET这几个命令,其他一律返回NOAUTH。把鉴权放在最前面,避免未授权用户用其他命令探测服务器状态。
第二步是ACL权限检查。Redis 6.0之后引入了细粒度ACL,不仅要校验用户能不能执行某个命令,还要校验用户有没有权限访问命令里涉及的那些key。这个检查依赖命令表的key_specs元数据。这一步放在鉴权后面,是因为ACL校验需要先知道“当前用户是谁”,而已认证是“知道用户是谁”的前提。
第三步是参数个数检查。用c->cmd->arity比对c->argc,不满足就返回“wrong number of arguments”。这一步相对便宜,放在ACL后面是因为命令不存在或者用户无权限时,暴露参数个数信息没有意义。
第四步是集群模式检查。如果Redis运行在Cluster模式,会先根据key_specs或解析函数提取key,计算hash slot,判断这个slot是不是由当前节点负责。不是的话返回MOVED或ASK重定向错误,让客户端去正确的节点。这一步必须在真正执行前做,否则会有跨节点写数据的一致性问题。
第五步是内存检查。如果配置了maxmemory,Redis会尝试执行内存淘汰策略。淘汰之后如果内存仍然不够,对于那些带denyoom标志的写入命令,会直接返回OOM command not allowed when used memory > 'maxmemory'。注意只读命令不会被内存检查拦下来。
第六步是持久化加载和从库状态检查。服务器正在加载RDB或AOF时,只放行带loading标志的命令;从库开启了slave-serve-stale-data no时,只放行带stale标志的命令。
这六关走完之后,理论上这条命令已经可以执行了,但processCommand在真正调用执行器之前,还有一步很关键的操作:如果monitor命令正在监听,需要把当前命令广播给所有monitor客户端。而且这一步只是用于观察,不阻塞执行。
3.3 执行器入口:call()如何接管控制权
所有检查通过之后,processCommand会调用call(c, CMD_CALL_FULL)。这个call函数就是执行器的统一入口,flags参数指示了执行完之后要做哪些善后工作。
比如CMD_CALL_FULL代表全套:记录统计、传播给AOF和从库、触发键空间通知、写入慢日志。对于某些内部调用场景,会传入部分flags,省掉多余步骤。
这里要强调一个很多人容易理解偏的地方:processCommand本身不执行任何具体的命令逻辑。它只负责“放行”,真正干活的是call里面通过函数指针c->cmd->proc(c)调到的那个函数。这种分离严格贯彻了“分发”和“执行”解耦的思想——你看代码时,看到processCommand这么长一串,但它的终点就是一行call,不要被前面大段的检查搞晕。
4. 执行与善后:call()函数里的“前台后台”
4.1 proc调用瞬间:真正的读写数据操作
进入call之后,代码会先记录一下当前时间(用于后续算耗时),然后执行最关键的一行:
c->cmd->proc(c);这一行会跳到命令实现函数。比如GET就跳到t_string.c里的getCommand,SET就跳到setCommand。这些命令实现函数会从c->argv里取参数,去server.db(不是直接操作文件)里读写数据,然后把结果通过addReply函数写进客户端的回复缓冲区。
这一瞬间是整条链路里唯一真正“摸到数据”的地方。前面那些检查、查找、解析,全部是在为这一行做准备。读代码时如果你觉得processCommand太长,记住一句话:那都是前戏,真正的命令逻辑就在这透明的一行函数指针调用里。
4.2 命令传播:AOF和从库怎样同步同一份命令
proc执行完毕,call里的善后逻辑才真正开始,这里面最有意思的是命令传播。所谓传播,就是“把这条命令也告诉其他人”——谁需要知道?主要是两个对象:AOF文件和从库。
AOF的传播流程是这样的:如果命令被判定为需要写入AOF,call会调用类似propagate的函数,把命令参数重新组装成RESP格式,追加到server.aof_buf这个缓冲区。注意,这里的追加只是写进内存缓冲区,不代表已经落盘。真正的fsync由beforeSleep或后台定时任务统一触发。
这样做的好处是聚合写IO,避免每来一条命令就刷一次磁盘。性能和环境安全之间靠这个缓冲区取得了平衡。
从库的传播也大同小异:replicationFeedSlaves函数会把命令追加到每个已连接从库的输出缓冲区,由事件循环异步发送给从库。这也是“主从复制”的核心原理——从库不是拿数据快照,而是不断重放主库收到的写命令。
我在阅读时特别关注过这个传播姿势,因为它解释了一个很常见的现象:为什么AOF和从库有时会“慢半拍”,但数据最终不会丢——因为传播是先进入缓冲区,再由事件循环统一刷出,天然就是“批量”的。
4.3 统计与慢查询:每次执行都会被记录
call善后的另一个重要部分统计。每次命令执行完,会做类似这些操作:
server.stat_numcommands++,总命令数。c->cmd->calls++,这个具体命令的调用次数。- 计算执行耗时,累加到
server.stat_total_command_duration之类的累计值里。 - 如果耗时超过
slowlog-log-slower-than配置的阈值,就调用slowlogPushEntryIfNeeded把这条命令写进慢查询日志。
慢查询日志的实现非常朴素:它就是一个链表,新记录插到头部,超过slowlog-max-len(默认128)就把尾部记录丢掉。你可以用SLOWLOG GET查看,也可以用SLOWLOG RESET清空。开发时如果怀疑某条命令慢了,这就是第一手排查数据。
但是这里有个线上经验要提醒你:慢查询日志统计的是命令在proc里的执行时间,并不包括AOF的fsync等待时间。换句话说,如果磁盘突然变慢,命令本身执行很快,慢日志里可能什么都看不到,但客户端就是感觉卡了。这也是为什么线上排查Redis卡顿,不能只盯慢日志,还要看INFO persistence里的aof_last_write_status、延迟监控等指标。
4.4 命令执行后的隐形工作:键空间通知与watch监控
除了统计和传播,call里还有两个容易被忽略的善后逻辑:键空间通知和watch监控。
键空间通知是你开启了notify-keyspace-events之后才生效的。命令如果修改了某个key,会触发对应的事件通知,比如set、expired、del等。这个机制的实现点在notifyKeyspaceEvent,它会把事件发给所有订阅了相关频道的客户端。
watch监控更隐蔽。WATCH命令是Redis实现乐观锁的基础,如果某个客户端WATCH了某个key,而这个key在事务执行前被其他客户端改掉了,事务应该失败。这个检查不是每次命令执行都做全量比对,而是在touchWatchedKey里标记一下“这个key被改了”,等EXEC时再去核查版本号。call在命令执行后也会调用相关逻辑去touch这些key,从而保证WATCH语义正确。
这两项“隐形工作”不需要每条命令都触发额外开销——键空间通知只在开启时走,watch只在有客户端watch了对应key时才走。源码里到处都是这样的“先判断是否有人在监听,再决定要不要广播”的模式,这也是Redis在单线程模型下还能保持低延迟的原因之一。
5. 回复的旅程:客户端等的结果是怎么写回的
5.1 回复数据的两种形态:固定缓冲区和对象链表
命令执行完了,数据也取到了,现在要面对最后一公里:把结果写回客户端。很多人以为就是send()一下,实际上没那么简单。
Redis给每个客户端结构体分配了一个固定缓冲区和一条回复链表。固定缓冲区是一块连续内存,通常比较小(明显小于一条大回复),适合存OK、5、短字符串这类轻量回复。大多数命令的回复都是几十字节,直接塞进固定缓冲区,零额外分配,这是Redis极快的重要原因之一。
如果回复很大,比如MGET取出一堆大value,固定缓冲区装不下,Redis就会走第二条路径:把回复对象按块链入c->reply链表,等待后续逐步写出。这个过程通过addReply这条主线完成,你去查代码会发现addReply的最终归宿都是这两条路径之一。
你可能会问:为什么不一次性分配一块合适大小的缓冲区?因为命令结果的大小在构造之前往往不可预知,一次性分配可能造成不必要的内存复制和浪费。链表追加的方式配合“分批写出”,能保证不管回复多大都不会阻塞太久。
5.2 写事件调度:从reply到socket的最后一公里
回复数据准备好之后,Redis会调用prepareClientToWrite之类的函数告诉自己:这个客户端有数据要写。事件循环看到这个信号后,会给对应的socket注册写事件,之后一旦socket可写,就触发sendReplyToClient回调,最终由writeToClient把缓冲区里的字节真正送到内核。
这里有个设计上的精妙之处:写事件不会在命令执行完的瞬间立刻触发,而是放在当前事件循环的下一个轮回里。这样一来,即使同一时间有多个客户端都在等回复,Redis也来得及把数据合并、统筹发送,而不是每执行完一条命令就陷入一次write系统调用。
writeToClient也不是一个while把数据全写光,它会以socket发送缓冲区能接受的速度分次写,一旦写不进去就停下来,等下一次写事件。这种“能写多少写多少,写不完下次继续”的模型非常优雅,也解释了为什么Redis单线程还能同时服务大量慢速客户端。
不过这里有一个常见的坑:如果某个客户端一直不读数据,它的输出缓冲区会越积越多,最终触发client-output-buffer-limit限制,Redis会把连接强制断开。所以你在源码里会看到一个看起来有点无情的逻辑——不管回复还有没有发完,超过输出缓冲限制就直接关闭客户端。这个策略是刻意的,否则一个慢读客户端就能拖垮服务端内存。
在整个回复链条里,我印象最深的其实是addReply的幂等感:命令实现函数只管把值交出去,完全不关心那句值最终是走固定缓冲区还是链表,也不关心网络什么时候发出去。这种“只管造数据、不管发数据”的分工,让我理解了为什么Redis各个命令的代码都那么简洁。
最后再分享一个我自己的阅读体会:读这段源码时,不要试图一口气背下每个函数名,而是抓“入口、调度、执行、回复”这条主线。我每次排查线上问题,其实只会在两三个关键函数上打断点或者加日志,比如processCommand看命令有没有进来,call看执行了什么,writeToClient看回复有没有卡住。只要这条主线在你脑子里是清晰的,再复杂的Redis异常,你都能顺着这条线一步步定位到具体环节。