
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 aeEventLoopRedis自己的事件循环aeEventLoop是Redis事件驱动模型的核心抽象定义在ae.h里具体实现在ae.c。它内部维护了一个aeFileEvent数组每个fd对应一个读/写事件回调和一个aeTimeEvent链表定时任务配合aeFiredEvent记录本轮就绪的事件。Redis在不同平台上有不同的底层实现Linux下默认是epollmacOS上是kqueue没有这些时退回select由ae.c中的宏决定编译哪个版本。很多人第一次看事件循环会被aeProcessEvents绕晕其实逻辑很直白先算出最近的定时事件还有多久到期把这个时间作为epoll_wait的超时上限然后调用epoll_wait等待可读/可写事件拿到就绪事件后先处理时间事件再按顺序处理文件事件。Redis为了保证低延迟文件事件处理优先级高于时间事件所有回调都是单线程串行执行的所以千万别在命令实现里做阻塞型系统调用。3.2 从listenfd到第一次acceptRedis启动时会在initServer里创建监听套接字并把acceptTcpHandler注册为监听fd的可读事件回调。每次有新连接进来事件循环触发acceptTcpHandler进一步调用acceptCommonHandler和createClient。createClient里做一件关键事把readQueryFromClient注册为新连接fd的可读事件回调。换句话说每个客户端连接从诞生第一天起它的网络读事件就等于命令读取的入口。这意味着你每发一个命令内核都会通过epoll通知Redis然后触发readQueryFromClient。整个命令处理链路真正起点在这里而不是在某个命令解析函数里。3.3 readQueryFromClient把字节流装进querybufreadQueryFromClient做了几件事首先调用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. 协议解析从字节流到argv4.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 tracenetwork观察Redis进程的read、write、epoll_wait等系统调用配合gdb判断命令处理时是否有意外的系统调用。正常情况下列表类大key命令不会引发大量系统调用如果看到异常多的write调用说明输出缓冲可能分配不合理顺着_addReplyToBuffer和_addReplyToBufferList的实现去排查即可。8. 看完源码后我对Redis拦截链路的几点反思读完processCommand那约两百行的检查逻辑你会发现Redis的“快”不只是单线程加epoll的功劳。它用了大量前置拦截来减少昂贵的操作拒绝不存在的命令比执行一条未知命令再报错便宜得多在命令执行前做权限检查避免在实现里散落权限判断逻辑用命令的元信息完成参数个数预检让命令实现可以假定参数一定是合法的。这种“把脏活累活留在前边”的思想在自研网关、缓存中间件时非常值得借鉴。我在实际项目里仿照Redis这套思路写过一个小型命令处理框架核心就是维护一张“命令元信息表”注册时声明参数个数、权限要求、是否写操作分发时统一做校验然后集中调用实现。这套设计让新增命令的工作量降到了最低也让日志、审计、监控都能挂载到同一个入口上省了非常多重复代码。最后分享一个自己总结的源码阅读心得不要只跟成功路径一定还要跟失败路径。比如processCommand每一条rejectCommand分支都值得断下来看一遍因为生产环境里真正影响可用性的往往是这些错误处理分支而不是主流程。把错误路径和成功路径都跑通一遍才算真正理解一个系统的命令处理机制。