1. 从一条命令开始:Redis命令处理的整体路线图
Redis每次被问到“一个GET命令是怎么跑完的?”的时候,大多数人的第一反应都是“查一下哈希表返回结果”。这个答案没毛病,但只停留在数据结构层面。真正把一条命令从网络字节流变成内存数据、再变成响应返回给客户端,中间隔着事件循环、协议解析、命令表查找、权限校验、持久化联动、主从复制等一系列环节。这篇博文就顺着源码把这条路完整走一遍。
先交代一下背景:我自己读的是Redis 7.0左右的源码,基于Linux环境,编译时开启了DEBUG。这篇内容不追求逐行贴代码,而是把关键路径上的核心函数、数据结构、设计动机讲清楚,配合我实际调试时的一些记录和踩坑经验。适合已经会用Redis、想起到底层看看源码,但又被一堆结构体绕晕的读者。
命令处理机制在Redis源码里横跨多个文件:server.c负责命令分发和全局状态,networking.c负责网络读写和输出缓冲,ae.c是事件循环的核心,db.c和object.c则是具体键空间操作的执行层。整个链路可以概括为:事件循环捕获可读事件 -> 从socket读取数据 -> 按RESP协议解析参数 -> 查命令表 -> 执行权限/状态检查 -> 调用命令实现 -> 写入输出缓冲 -> 归还事件循环。听起来步骤不少,但每一步的代码量其实都控制得很克制,这也是Redis代码值得反复读的原因。
我建议读者在正式看源码之前,先在自己的机器上把Redis源码编译一遍,编译时加上CFLAGS="-g -O0",保证调试信息完整、优化关闭,后面用gdb跟流程会轻松很多。接下来正文部分就从数据结构讲起,这是理解后面所有流程的底座。
2. 数据结构的底座:三个struct和一个命令表
2.1 redisServer:所有全局状态都在这里
Redis启动之后,全局就一个redisServer类型的变量server,所有核心状态都挂在它身上。命令处理涉及到的关键字段包括:clients链表保存所有客户端连接、commands字典保存命令名到命令实现的映射、db指向数据库数组、aof_buf保存待写入AOF的缓冲、stat_numcommands统计命令总数、slowlog慢日志链表。你可以把server理解成整个Redis进程的“大脑”,读源码时经常要回到这个结构体里找上下文。
我在读源码时习惯做一件事:把自己的调试脚本里加上一个打印server关键字段的gdb命令,比如p server.clients、p server.commands,这样走到任何函数时都能快速确认当前全局状态是什么样的。这在排查“为什么这条命令走了奇怪分支”的时候特别有用。
2.2 client:一次连接的所有记忆
Redis里每个客户端连接对应一个client结构体(旧版本也叫redisClient)。从命令处理的视角看,这个结构体最重要的字段有这么几个:fd是套接字描述符,querybuf是输入缓冲,argv和argc是解析后的命令参数数组,cmd是指向最终查到的redisCommand的指针,buf和reply是输出缓冲(buf是固定大小快速缓冲,reply是链表形式的大缓冲),authenticated标记是否通过认证,reqtype标记输入协议类型(内联协议或multibulk协议)。
很多人在看命令执行失败的报错时,下意识会觉得错误是在命令实现里返回的。其实一大半错误在processCommand阶段就已经拦截了,比如没有认证、命令不存在、参数个数不对,这些都在走到命令实现之前就返回了。所以理解client结构体,是理解错误处理路径的基础。
2.3 redisCommand:一条命令的元信息
redisCommand结构体包含的不只是函数指针。它至少还记录着:命令名字符串、命令执行函数、参数个数规则(arity,正数表示精确参数个数,负数表示至少需要多少参数)、命令标志位(如CMD_WRITE、CMD_READONLY、CMD_DENYOOM)、首次/最近调用时间、调用次数、总耗时、微秒耗时等统计字段。这些元信息和ACL权限、慢日志统计、键过期判断都强相关。
举个例子,arity为-2的说明这条命令至少需要两个参数。GET的arity是2,因为GET key刚好两个参数;SET的arity是-3,因为至少是SET key value。命令表就是靠这些元信息在分发前做参数合法性预判,避免跑到命令实现里才开始报错。
2.4 redisCommandTable:命令表的前世今生
所有内建命令都定义在redisCommandTable数组里。这个数组位于server.c,每一项对应一个redisCommand实例。启动时,initServerConfig会遍历这个数组,把每个命令以命令名为key注册到server.commands字典里。查找命令本质上是字典查找。
实际开发中如果你自己写了一个Redis模块,注册命令走的是RedisModule_CreateCommand,本质上也是往server.commands字典里塞一个redisCommand。所以记住一点:不管内建还是模块命令,最终都在同一个命令字典里,processCommand不关心命令是内建还是模块提供的,只查字典然后按元信息执行,这是Redis扩展机制的根基。
3. 事件驱动:一条命令如何被操作系统“叫醒”
3.1 aeEventLoop:Redis自己的事件循环
aeEventLoop是Redis事件驱动模型的核心抽象,定义在ae.h里,具体实现在ae.c。它内部维护了一个aeFileEvent数组(每个fd对应一个读/写事件回调)和一个aeTimeEvent链表(定时任务),配合aeFiredEvent记录本轮就绪的事件。Redis在不同平台上有不同的底层实现:Linux下默认是epoll,macOS上是kqueue,没有这些时退回select,由ae.c中的宏决定编译哪个版本。
很多人第一次看事件循环会被aeProcessEvents绕晕,其实逻辑很直白:先算出最近的定时事件还有多久到期,把这个时间作为epoll_wait的超时上限;然后调用epoll_wait等待可读/可写事件;拿到就绪事件后,先处理时间事件,再按顺序处理文件事件。Redis为了保证低延迟,文件事件处理优先级高于时间事件,所有回调都是单线程串行执行的,所以千万别在命令实现里做阻塞型系统调用。
3.2 从listenfd到第一次accept
Redis启动时会在initServer里创建监听套接字,并把acceptTcpHandler注册为监听fd的可读事件回调。每次有新连接进来,事件循环触发acceptTcpHandler,进一步调用acceptCommonHandler和createClient。createClient里做一件关键事:把readQueryFromClient注册为新连接fd的可读事件回调。
换句话说,每个客户端连接从诞生第一天起,它的网络读事件就等于命令读取的入口。这意味着你每发一个命令,内核都会通过epoll通知Redis,然后触发readQueryFromClient。整个命令处理链路真正起点在这里,而不是在某个命令解析函数里。
3.3 readQueryFromClient:把字节流装进querybuf
readQueryFromClient做了几件事:首先调用connRead从socket里读数据到临时缓冲区,然后追加到c->querybuf里,最后检查querybuf长度是否超过client_max_querybuf_len限制,如果超过就记日志并关闭连接。读到数据之后进入processInputBuffer继续解析。
这里有个细节:Redis读socket用的是read循环加EOAGAIN判断。connRead读到EAGAIN时说明本轮的socket数据已经读完了,退出读取,交给协议解析去处理。这个机制直接支撑了pipeline特性:你连续发100条命令,可能一次read事件就把100条命令的字节流全读进来了,然后解析循环会一条一条处理完。
我在实践中的一个体会是,如果遇到大key或者大批量pipeline导致客户端超时,优先怀疑client_max_querybuf_len是否被撑爆,其次再考虑网络带宽。源码里PROTO_MAX_QUERYBUF_LEN是1GB上限,client_max_querybuf_len默认1GB,但实际生产环境通常会把单条命令大小限制调小,避免恶意客户端打爆内存。
4. 协议解析:从字节流到argv
4.1 RESP协议长什么样
RESP是Redis序列化协议,命令请求本质上是一个由*开头的数组,数组里每一项是$开头的字符串。比如SET foo bar在协议层面对应的是*3\r\n$3\r\nSET\r\n$3\r\nfoo\r\n$3\r\nbar\r\n,其中*3表示后面有3个参数,$3表示接下来字符串长度是3。
Redis还支持一种更简单的内联命令格式,就是你在redis-cli里直接输入普通文本,redis-cli会帮你转成RESP格式。但如果你直接用nc连上去发纯文本,Redis也能解析,走的是processInlineBuffer。两种解析路径最终都生成argv数组和argc个数。
4.2 processMultibulkBuffer:主解析路径
processMultibulkBuffer是核心解析函数。它先跳过querybuf开头的空白字符,然后根据当前解析状态逐步消费数据:先用*读取命令参数个数,再用若干次$读取每个参数的长度,最后按长度截取参数内容。整个过程靠c->qb_pos和c->multibulklen维护解析游标,处理不完整协议时(比如TCP粘包后一个命令还没到齐),就返回等待更多数据。
这个函数经常被非议的点在于,它在解析时会修改querybuf,把已解析部分往前移动。源码里用sdsrange来裁剪缓冲区,这个过程涉及到字符串拷贝。新版本Redis想了很多办法优化,比如复用内存、延迟回收等,核心目的都是避免频繁分配和拷贝。这部分值得单独读,对理解Redis性能优化思路很有帮助。
4.3 为什么说pipeline天然高效
由于querybuf一次可能包含很多条命令,processInputBuffer里有一个while循环,在协议完整的前提下不断解析并执行,直到querybuf里没有完整命令为止。每次解析到完整命令后,都会调用processCommandAndResetClient,执行完再回到循环继续解析下一条。
所以pipeline高效的本质不是因为减少了网络往返的次数(当然这也很关键),而是因为Redis在单次事件回调里连续执行了多条命令,中间省掉了一次次epoll_wait和系统调用。这也是为什么批量操作建议用pipeline或Lua脚本而不是一条条发的原因之一。
5. 命令分发与执行:从查表到调用
5.1 processCommand:执行前必须跨过的门槛
processCommand是命令处理链路的枢纽函数,它承担了大量前置检查工作。按照源码顺序,这些检查包括:进程是否正在从AOF/复制流加载数据、客户端是否已认证、命令是否存在、参数个数是否合法、ACL权限是否允许、maxmemory策略是否需要先淘汰(处理CMD_DENYOOM命令)、集群模式下是否需要重定向到其他节点、持久化相关的bgsave/aofrewrite子进程是否在跑、只读副本是否拒绝写命令等。
每一道检查对应一个错误码返回,比如C_OK表示通过,C_ERR配合errno或标志位说明失败原因。失败时通过rejectCommand之类的函数给客户端返回错误信息,同时记录相应的统计计数。这些检查的顺序不是随便排的,比如认证检查一定要放在命令查找之前,否则未认证用户可以通过命令名探测服务器内部状态。
我实际踩过坑的地方是maxmemory淘汰检查。processCommand会在执行写命令前检查是否需要淘汰键,而不是等命令执行完之后。这导致在内存接近上限时,一次写请求可能触发淘汰再写入,整体的延迟波动比预期高。理解这一点后,我在压测分析时就额外关注了淘汰对延迟的贡献。
5.2 lookupCommand:一条命令是如何“验明正身”的
lookupCommand做的事极其简单,就是从server.commands这个字典里调用dictFind按命令名找redisCommand。Redis 6.0之后还加了一层server.orig_commands,用于区分命令被模块覆盖前后的不同实现。processCommand内部会调用lookupCommand找到命令,找不到就返回未知命令错误。
关于命令名的大小写,Redis为了兼容性在协议解析阶段做了处理:processMultibulkBuffer解析完第一个参数后,会调用tolower把命令名转成小写。所以你发GET、get、Get都能执行。但如果命令名本身是大小写敏感的(理论上模块可以注册这样的命令),这个逻辑就会有问题。好在绝大部分命令都不在乎大小写。
5.3 call:命令执行的临门一脚
所有检查通过之后,processCommand会调用call(c, cmd, 0)执行真正的命令逻辑。call函数是整个执行路径上最值得细读的函数,因为它不仅要调用命令实现,还要围绕命令执行做各种联动。执行前,它会记录起始时间、清空c->flags中的某些状态、检查是否需要通知监视器(MONITOR)、累计命令调用次数、处理慢日志检测、如果命令是写命令则调用propagate把命令发给AOF和从节点。
执行时,call直接调用cmd->proc(c),这个proc就是命令实现函数。以GET为例,getCommand在t_string.c里,执行时会调用lookupKeyRead从数据库里查找键,找到则返回值的字符串表示,找不到就返回空批量响应。这套流程看起来简单,但值得注意的是:命令实现只处理逻辑,不处理网络发送,所有响应都是通过addReply系列函数写入输出缓冲的。
call函数里还有一个容易踩坑的点:如果命令是CMD_NOSCRIPT的,在Lua脚本里调用会直接报错;如果命令是CMD_RANDOM的(比如RANDOMKEY、SPOP),在主从复制的从库上执行时不会写入AOF,因为随机结果不保证在从库上复现。这些标志位的设计讲究,读源码时建议留意。
5.4 执行完未必就结束:postCommandCheck不是异步的
命令执行完以后,processCommand还会检查是否需要关闭客户端、是否要阻塞客户端(BLPOP这类阻塞命令执行后客户端会挂起)、是否需要重置客户端状态(比如重置命令统计、清理argv引用计数)。postCommandCheck做的事包括检测输出缓冲是否超过限制、重置CLIENT_REPLY_SKIP标志等。这个阶段保证了单个命令执行完成后,连接状态是干净且一致的。
6. 输出响应与写命令的传播
6.1 addReply:响应不是直接send的
Redis命令产生的响应从来不会在命令实现里直接调用write返回。所有响应数据都通过addReply相关函数写入客户端的输出缓冲区,然后交由事件循环处理。具体来说,client结构体里有一个大小为PROTO_REPLY_CHUNK_BYTES(默认16KB)的固定缓冲buf,数据会优先写到这里;一旦内容超过buf容量,剩余部分就通过_addReplyToBuffer失败后追加到reply链表上,每个节点是一块独立分配的内存。
事件循环检测到客户端fd可写时,会调用sendReplyToClient把buf和reply里的数据发送出去,发送完成后再根据剩余数据量调整fd的事件注册(如果数据没发完,继续监听可写事件)。这个机制允许Redis在响应数据量大的时候,不用阻塞在socket写操作上,而是等内核可写时再继续发送,大大提升了并发稳定性。换个角度理解,Redis的响应写回是被事件循环“调度”的,而不是命令执行时就同步发生的。
6.2 写命令执行后:AOF和主从复制如何各取所需
如果命令是写命令,call函数会调用propagate把命令记录到AOF缓冲和复制积压缓冲区。这里有个细节:propagate并不是直接把命令原文存下来,而是使用命令执行前的argv和argc重新编码成RESP协议格式。Redis 7.0后,主从复制已经改用replstream模块来管理复制流的传播,但整体思路仍是把命令的协议表示广播给从库。
涉及AOF的时候,call内部会把命令追加到server.aof_buf,实际写入文件的操作由后台定时任务或子进程完成,processCommand阶段不会同步写磁盘,这也是Redis高性能的一个原因。还有一点要注意:如果命令是在Lua脚本里执行的,call并不会逐条传播,而是把整个Lua脚本作为一条消息传播,以保证从库执行结果一致。
6.3 慢日志、监视器、统计计数:这些“隐形工作”在哪做
call函数在命令执行前后做了很多统计和旁路工作。执行前记录start时间,执行后计算耗时,如果超过slowlog-log-slower-than阈值就记录慢日志;执行期间如果存在MONITOR客户端,把命令格式化成普通文本广播给监视端;每次执行后更新server.stat_numcommands、cmd->calls、cmd->microseconds等统计字段。这些字段正是INFO commandstats给用户展示的数据来源。
慢日志和监视器的实现都依赖于call这个统一入口。如果没有call承载这些跨切面逻辑,每个命令实现里都得自己写一遍慢日志判断,代码冗余会非常严重。这种“同一入口做横切”的设计思路,在写自己的中间件时同样值得参考。
7. 调试与排障:读源码时最该掌握的几个实操技巧
7.1 用gdb跟一次完整命令流程
我把跟命令处理的断点顺序分享出来:readQueryFromClient->processInputBuffer->processMultibulkBuffer->processCommand->lookupCommand->call->getCommand->addReply->sendReplyToClient。连一次GET,每个断点都打印当前的c->querybuf、c->argv[0]->ptr、c->cmd->name,基本就能把整条链路串起来。
第一次跟的时候,建议用redis-cli或nc发送*1\r\n$4\r\nPING\r\n这种最小协议。因为PING命令非常轻量,没有复杂的键空间操作,跟起来不容易被无关逻辑干扰。等熟悉了全流程,再换SET带淘汰检查、或者BLPOP带阻塞逻辑的命令,逐步增加复杂度。
7.2 常见问题一:Unknown command,但命令明明存在
如果你在Redis 6.0以上版本遇到ERR unknown command,但COMMAND列表里能看到该命令,十有八九是ACL限制了当前用户执行该命令。processCommand检查ACL时,会调用ACLCheckAllPerm根据命令名找到该命令对应的ACL类别,权限不足则返回NOPERM错误,客户端看到的表现就是“命令不存在”。排查这类问题要同时看ACL WHOAMI、ACL GETUSER和服务器日志里的ACL拒绝信息。
7.3 常见问题二:pipeline批量命令中途报错,后续命令还执行吗
答案是不会。processInputBuffer在解析到命令后调用processCommandAndResetClient,这个函数内部执行processCommand,如果返回C_ERR,它会清空querybuf里剩余的数据并关闭客户端或进入后续恢复流程。也就是说,pipeline里一旦有命令在执行前置阶段失败(比如权限、参数错误、OOM拒绝),后续命令就会被丢弃。这一点和MySQL事务不同,Redis没有回滚和继续之分,报错即断链。在大批量插入时尤其要注意先小批量验证数据格式,避免因为一条脏数据中断整个管道。
7.4 常见问题三:响应超时,但命令明明执行很快
如果INFO commandstats里能看到命令累计耗时不高,但客户端还是超时,问题大概率出在输出缓冲区上。响应数据量很大时,sendReplyToClient可能一次发不完,事件循环会持续监听可写事件,在并发连接多的情况下,大响应的发送会被其他事件的处理抢占,造成单条响应延迟变高。调优思路是调大client-output-buffer-limit对应类型(普通客户端、副本、pubsub)的限制,同时控制单次命令返回的数据量。
7.5 一个容易被忽略的“专用网络”技巧
读源码时我习惯用strace -p <pid> -e trace=network观察Redis进程的read、write、epoll_wait等系统调用,配合gdb判断命令处理时是否有意外的系统调用。正常情况下列表类大key命令不会引发大量系统调用,如果看到异常多的write调用,说明输出缓冲可能分配不合理,顺着_addReplyToBuffer和_addReplyToBufferList的实现去排查即可。
8. 看完源码后,我对Redis拦截链路的几点反思
读完processCommand那约两百行的检查逻辑,你会发现Redis的“快”不只是单线程加epoll的功劳。它用了大量前置拦截来减少昂贵的操作:拒绝不存在的命令比执行一条未知命令再报错便宜得多;在命令执行前做权限检查,避免在实现里散落权限判断逻辑;用命令的元信息完成参数个数预检,让命令实现可以假定参数一定是合法的。这种“把脏活累活留在前边”的思想,在自研网关、缓存中间件时非常值得借鉴。
我在实际项目里仿照Redis这套思路写过一个小型命令处理框架,核心就是维护一张“命令元信息表”,注册时声明参数个数、权限要求、是否写操作,分发时统一做校验,然后集中调用实现。这套设计让新增命令的工作量降到了最低,也让日志、审计、监控都能挂载到同一个入口上,省了非常多重复代码。
最后分享一个自己总结的源码阅读心得:不要只跟成功路径,一定还要跟失败路径。比如processCommand每一条rejectCommand分支都值得断下来看一遍,因为生产环境里真正影响可用性的往往是这些错误处理分支,而不是主流程。把错误路径和成功路径都跑通一遍,才算真正理解一个系统的命令处理机制。