命令解析和String命令实现,这两块放在一起讲其实是挺有道理的。命令解析是Redis处理客户端请求的第一道关口,所有命令都从这里过;而String命令又是所有数据类型里用得最多、最容易看懂底层设计的一类。把这两块啃下来,就能理解Redis内部一条命令从网络收包到真正修改内存数据的完整链路。这篇文章适合正在读Redis源码的人,也适合用过很久Redis但一直对“命令到底怎么被找到、怎么被检查、怎么落库”感到模糊的读者。
1. 先把命令解析这件事拆开看
1.1 命令表:一张决定入口的表
Redis启动时会初始化一张全局命令表,核心结构就是redisCommand。你去看server.c里的redisCommandTable,会发现每个命令都对应一条记录,记录里字段不多但每个都关键:
{"set",setCommand,-3,"write use-memory @string",CMD_WRITE|CMD_DENYOOM|CMD_FAST,...}第一个字段是命令名,setCommand是处理函数指针,-3是arity参数规则。arity这里有个约定:正数表示参数个数必须严格等于这个值,负数表示参数个数必须大于等于这个数的绝对值。为什么是负数而不是单独表达“至少三个”?因为类型里正负数正好可以区分“固定参数”和“最小参数”两种语义。比如GET key有两个参数,arity就是2;而SET key value [NX|XX] [EX seconds]这里参数个数是可变的,所以用了-3,意思是“至少三个参数”,命令名也算一个。
这套设计最大的价值在于,参数个数错误可以在一开始就被拦截,不用进入setCommand内部再去做一堆判断。你在源码里看到processCommand中就有对arity的检查,如果命令名匹配但参数个数不满足要求,直接返回参数个数错误,连命令函数都不用调用。我见过不少初读源码的人误以为arity检查是分散在每个命令里的,实际上不是,它是命令框架统一处理的。
命令表本身不是线性的,Redis用一个dict来存储它,key是命令名字符串,value就是redisCommand指针。这样查找命令的时间复杂度是O(1),不会随着命令种类的增加而变慢。命令注册也有讲究,populateCommandTable会把静态表里的条目读出来填充到server.commands字典中,同时解析redisCommand.sflags字符串标志,比如write、readonly、admin这类可读字符串会被解释成CMD_WRITE、CMD_READONLY、CMD_ADMIN这类位掩码标志。所以你看命令的flags字段不是一个字符串,而是一个uint64_t位掩码,后续做各种权限检查和路由判断时只需要做位运算,效率非常高。
1.2 从网络字节到argv数组
一条命令从客户端缓冲区到可执行的argv数组,要经过processInputBuffer入口。这里分两条路:大多数客户端走的是RESP协议流程,也就是processMultibulkBuffer;以前那种telnet直接敲命令的方式走的是processInlineBuffer。
RESP协议网上讲得很多,但实际看源码会发现解析是个状态机。客户端缓冲区里可能一次来多条命令,也可能一条命令只来了一半。processMultibulkBuffer维护一个multibulklen变量,表示这次请求还有多少个参数需要读取。看到*3\r\n先拿到参数数量3,然后循环读取每个$<len>\r\n<参数>\r\n。这里的重点不是读数据,而是如何复用c->argv数组。Redis解析出来的每个参数是一个独立的sds对象,挂在client对象的c->argv上。sds本身是二进制安全的,所以参数里就算有空格、换行、\r\n都不影响,因为RESP协议用长度前缀而不是分隔符来切分参数。这也是为什么Redis能安全地存储二进制数据。
命令名的大小写是个容易忽略的细节。客户端发SET、Set、set时,命令表dict的key是小写。Redis在处理时会把argv[0]统一转成小写再查找命令,否则用户发个大写命令就会直接报“unknown command”。这个动作看起来简单,但如果不做,所有客户端都得强制要求命令小写,那兼容性就非常差。
解析完成后,还有一个环节是把命令里的“键”提取出来。getKeysFromCommand会利用每个命令注册的first_key、last_key、key_step信息来定位键的位置。比如MSET key1 val1 key2 val2,它的key信息描述为“第一个key下标是1,最后一个key下标是3,步长为2”,这样就能把1、3、5…都找出来。提取键的用途很多,最主要的场景是集群模式下检查命令涉及的所有键是否落在同一个slot,以及ACL权限校验时判断用户是否有权访问这些键。Redis 7里甚至把命令键提取从静态参数扩展成了函数指针,因为有些命令的键位置取决于参数值,固定描述覆盖不了。
1.3 执行前的体检:ARITY、ACL、集群路由
走到processCommand时,一条命令要过的关卡比大多数人想象的多。我按源码里的顺序梳理一下:
- 查找命令并校验arity,前面已经说过。
- 如果是主从复制,主节点会拒绝
CMD_WRITE标志的写命令吗?不会,主节点本来就要执行。但从节点开机只读模式下会拒绝写命令。 - 检查ACL权限。
checkCommandACL会验证当前认证用户对命令本身、对键、对频道(pubsub相关)是否有权限。ACL的粒度可以细到“只允许某个用户对某些key执行某类命令”,这是Redis 6引入的,命令表里的@string、@keyspace这种flag就是给ACL分类用的。 - 集群模式下检查键的slot归属,如果命令涉及多个key且不在同一个slot,直接返回CLUSTER错误。
maxmemory检查。如果当前内存超限且有写命令,会尝试淘汰或拒绝。- 检查
CMD_DENYOOM标志和内存是否OOM。
这些检查全部通过后,才会调call()去执行真正的命令函数。很多人误以为“执行命令”就是直接调proc指针,其实call()前面还有一大堆钩子:命令监视器、慢查询日志统计、唤醒等待事件的客户端等。
所以命令解析不是一个孤立的函数,它是一整套框架。理解了这个框架,你再去看任何一条命令的实现,都会清楚它处于哪个位置,什么时候该做什么判断。
2. 通用命令是怎么“通用”的
2.1 DEL / EXISTS / TYPE:直接操作键空间的代表
通用命令的核心是操作键空间dict。server.db[i].dict就是主键空间,key是sds,value是robj指针。
先看DEL。delCommand的实现在db.c里,代码很短:遍历argv里所有key,逐个调用dbSyncDelete或dbAsyncDelete,统计删除成功的数量。这里有个容易踩坑的点:DEL默认是同步删除,删一个几MB的String没事,但删一个包含几十万元素的集合就可能阻塞主线程。Redis 4引入了异步删除概念后,UNLINK会把删除任务丢给后台bio线程,而DEL在lazyfree-lazy-user-del配置开启时也会走异步删除。我看过的生产事故里,不少大key删除阻塞就是在这里发生的。所以如果你线上有超大String,删除时务必用UNLINK或者开启lazy free配置。
EXISTS的实现就更直白了。existsCommand遍历所有key参数,逐个调用dbExists。这里的判断只是查dict里有没有,不会把value取出来,所以时间复杂度是O(N),N是参数个数,而不是key内部元素数量。这跟TYPE类似,typeCommand拿到robj后,根据robj->type字段返回string、list、hash等类型名字。
要注意delCommand返回的是成功删除的键数量,如果key不存在就跳过不计数。所以DEL a b如果a存在b不存在,返回1而不是2。这个语义在业务上用得很普遍,但面试和源码阅读时经常有人搞混。
2.2 EXPIRE / TTL:过期语义的细节
EXPIRE命令的底层统一由expireGenericCommand处理。命令名的差异体现在最后一个参数上:EXPIRE传入的是秒,PEXPIRE传入毫秒,EXPIREAT传入的是绝对时间戳(秒),PEXPIREAT传入绝对时间戳(毫秒)。这样设计的好处是四个命令共用一套核心逻辑,只是时间换算不同。
源码里有个很关键的函数叫getTimeoutFromObjectOrReply,它会把用户传的参数从字符串解析成long long,并检查溢出和非法值。这里有个经验:如果你直接调用EXPIRE key 0,Redis会把它当成“立即过期”,相当于删除这个key。所以避免传0或负数,虽然它们会很快触发过期删除逻辑,但语义并不清晰。
TTL的返回值很有意思。ttlGenericCommand会先看key是否存在,不存在返回-2;再看key是否设置了过期时间,没有设置返回-1;否则返回剩余秒数。PTTL返回毫秒。Redis 7里过期时间的存储结构发生过变化,键的过期时间不再直接放在dictEntry里,而是通过辅助过期dict解决复制一致性问题。理解这点对排查TTL不一致有帮助。比如在Redis 7之前,EXPIRE传播给从节点时可能因为主从时钟不一致产生偏差,新的实现里主节点会把相对过期时间转成绝对时间再传播,从节点直接设置绝对过期时间,减少了误差来源。
还有一个容易被忽略的点:SET key value EX 10之后,再执行PERSIST key会把过期时间去掉;但如果再执行SET key value而不带EX参数,原来的过期时间会保留吗?不会,Redis会清掉过期时间。因为SET的无EX版本会先删除旧key再写入新key,旧key的过期时间自然就没了。这跟SET ... KEEPTTL形成对比,KEEPTTL参数的意义就是保留原有TTL。
2.3 RENAME / COPY:键层面的复杂操作
RENAME old new看起来是个简单的键重命名,内部实现renameGenericCommand也确实是调dbDelete加dbAdd的流程。但它有前置条件:旧key必须存在;新旧key如果是同一个,直接返回OK但不做任何修改;对于集群模式,两个key必须在同一个slot,否则报跨slot错误。
实际生产里RENAME的坑往往在跨库和集群上。单机Redis有多个db(默认16个),RENAME是作用于当前db的,不会跨库。集群模式跨slot错误很多人第一次遇到是在做key迁移脚本时,想用RENAME把一个key从旧槽挪到新槽,结果直接报错。这个场景应该用MIGRATE或客户端双写删除方案,而不是RENAME。
COPY命令是Redis 6.2加入的,用于复制key,可带DB参数指定目标库,可带REPLACE参数允许覆盖目标。它的实现里有个dbCopy函数,底层先把源对象的引用计数加一,然后插入目标库,而不是复制value的内存数据。这就是为什么COPY效率很高,本质上是复制了一个指针。理解引用计数对读源码很重要,robj的refcount字段在大多数情况下是1,但共享对象如小整数可能会被多个key引用。如果你在写一个需要深拷贝的命令,千万别自己手动malloc再memcpy,直接走dupStringObject这类函数更安全。
3. String命令实现解析
3.1 String对象的三层编码
Redis的String不是单纯的字符数组,它有三种内部编码:OBJ_ENCODING_INT、OBJ_ENCODING_EMBSTR和OBJ_ENCODING_RAW。三种编码都是为了应对不同场景下的内存效率。
先说INT编码。当value可以解析成long long(比如"12345")且长度不超过20时,Redis会把字符串转成整数存在robj->ptr里,不再保存sds字符串。这样做有两个好处:整数比较和运算更快;省掉sds的头部和字符串本身的内存。很多命令像INCR能直接在这个整数上做运算,就是因为编码已经是INT了。
EMBSTR编码是Redis 3.2引入的。当一个字符串长度小于44字节时,Redis会把robj和sds分配在同一块连续内存里。为什么是44?这跟jemalloc的内存分配粒度有关:一个robj结构体约16字节,sds头约8字节,加上字符串内容和一个结尾的\0,在16/32/64字节内存桶之间刚好能塞进一个分配单元,减少碎片。EMBSTR的最大特点是一旦创建,它就是只读的。你想对一个embstr编码的字符串执行append或setrange,Redis会先把它转成RAW编码,再执行修改。为什么不直接原地扩展embstr?因为embstr的robj和sds是连续内存,原地扩展可能破坏后面的内存布局,而且需要整体重新分配,代价不小。
RAW编码就是普通sds。字符串长度超过44字节,或者经过修改后的字符串,都会落入这个编码。三种编码的转换关系你可以在tryObjectEncoding里看到:set key 12345时尝试转INT;set key hello时优先用EMBSTR;一旦字符串被修改就退化RAW。实际我看到很多调优文章让用户避免碎片化,就是从这层编码设计入手的。
3.2 SET命令:一个命令吃下所有过期参数
SET命令是String命令里最复杂的,因为它的参数太多了。SET key value [EX seconds|PX milliseconds|EXAT timestamp|PXAT timestamp] [NX|XX] [KEEPTTL] [GET]。
底层统一走setGenericCommand。流程可以概括为:
- 解析并校验过期时间参数。秒和毫秒之间做换算时,Redis用的是
getTimeoutFromObjectOrReply,它会把用户给的秒数乘以1000转成毫秒,同时检查乘法溢出。这一步容易踩坑的是,如果你传的秒数超过LLONG_MAX/1000,会直接报“invalid expire time”。 - 处理NX和XX语义。NX表示只有key不存在时才设置,XX表示只有key存在时才设置。源码里通过
lookupKeyWrite先判断key是否存在,然后决定是否继续。 - 调用
setKey真正写入键空间,同时更新过期时间。这里要注意,原来key的过期时间如果没有传KEEPTTL,会被清掉。 - 如果带了GET参数,设置前会先把旧值取出来返回给客户端。这个行为和
GETSET有点像,但它更强大,因为SET GET可以同时设置过期时间、使用NX/XX条件。
为什么官方推荐用SET替代SETEX/PSETEX?因为SETEX只能设置过期时间,不能设置NX条件,两个命令组合使用还会破坏原子性。而SET命令把这些能力统一了,在分布式锁场景里尤其有用。比如实现一个带过期时间的锁:SET lock_key client_id NX EX 10,如果返回OK则加锁成功,返回nil则说明锁已存在。这条命令是Redis官方文档明确推荐的锁实现方式。
3.3 GET / INCR:类型检查与原子运算
GET的实现很简单,getGenericCommand先调用lookupKeyReadOrReply,如果key不存在,返回nil或执行客户端提供的默认值逻辑;如果存在,还会检查value类型是不是String类型,不是的话返回WRONGTYPE Operation against a key holding the wrong kind of value。这个类型检查不是每个命令各自写的,而是封装在共享的查找函数里,所以Redis能保证“一个key对应的类型错误提示”在所有命令中完全一致。
INCR底层的incrDecrCommand更有看头。它要做两件事:原子性地修改值,并且保证修改后还是合法的整数。由于Redis是单线程事件循环,命令执行过程天然是原子的,不需要加锁。但单线程不代表没有瓶颈,INCR的瓶颈在于编码转换。
如果value是INT编码,源码直接对整数进行加一,然后把结果写回。如果value是RAW编码,它会先把sds转成long long,判断能否转换成功,不能转换就返回“value is not an integer or out of range”。转换成整数后执行加减,然后重新用setLongLongValue写入。问题在于,如果加一后的数字仍然很小,编码可能保持INT;但有时因为某些操作字符串长度变长,编码会变成RAW,之后每次INCR都要先做一次字符串解析。这个解析成本不低,所以如果对某个key频繁执行INCR,尽量保证它的值一直保持在INT编码下。怎么保证?就是别中途用APPEND或其他命令把它的编码搞成RAW。
溢出检查也是个重点。加一后如果超过LLONG_MAX,Redis会返回错误,而不会让你把整数溢出成负数。这个行为与很多语言的整数溢出无感知是不同的,使用时要留意。
3.4 批量与变体:MGET / APPEND / SETRANGE
MGET key1 key2和MSET k1 v1 k2 v2是批量操作的代表。MGET的底层是一个循环,对每个key做lookupKeyRead并收集值返回。这里有个性能细节:MGET可以一次网络往返获取多个key的值,但它在Redis内部仍然是逐key查找,不存在什么“并行”优化。如果你有几千个key要获取,MGET分批,每批50个左右比较稳妥,避免输入输出缓冲区过大。
APPEND key value本质上是把新字符串追加到旧值末尾。如果key不存在,就等同于SET。如果旧值是EMBSTR编码,APPEND会先转RAW再追加。这里我要分享一个实际优化点:对一个需要频繁追加的String,预分配足够空间能减少sds扩容次数。但普通的APPEND调用不会预分配,Redis的sds有一个预分配机制,通常会分配超过实际需要的内存,所以反复追加同一key的内存会增长得比字符串实际长度快,但换来的是追加操作变快。
SETRANGE用于从指定偏移量覆盖字符串。如果当前字符串长度小于偏移量,Redis会用\0字节填充中间区域。我曾见过有人用SETRANGE实现简单的位图,偏移量设置到几百万,结果value瞬间变成一个几百KB的字符串。这个操作虽然合法,但会产生很大的内存占用。GETRANGE则是从字符串中取子串,它的实现里有个细节:如果结束索引超出字符串长度,会自动截断到字符串末尾,所以不用担心越界。
GETSET命令值得一提,它在设置新值的同时返回旧值。这个过程是原子的,常被用来做“读取并清空计数器”之类的操作,比如拿到当前计数器的值然后归零。
4. 命令解析与String命令的常见坑
4.1 命令名大小写与arity误判
有次我在测试环境遇到一个诡异现象:某个客户端发SET foo bar能正常执行,但发Set foo bar就报ERR unknown command 'Set'。查了半天发现是那个客户端封装库在命令名拼接时没有做小写处理,而服务端版本比较老,还没有命令名自动小写化逻辑。这个问题在新版本Redis里已经不是问题,但如果你在维护一个自研的Redis代理或SDK,命令名规范化还是要自己保证。
arity判断的坑则更多出现在二次开发时。比如你自定义了一个命令,参数个数设计成最少三个但最多五个,结果arity只写了-3,忘了在函数内部校验最多参数,用户传六七个参数时多余参数被静默忽略。实际排查这类问题最直接的办法是看日志里有没有unexpected argument提示,或者直接加一段打印argv参数的代码。
4.2 大String的删除和复制
String大key的问题不像Hash、Set那样被频繁讨论,但它非常隐蔽。一个几十MB的String,DEL操作本身就可能在主线程上阻塞十几毫秒。毫秒级阻塞在单线程Redis里已经算大事故了。
所以我在生产环境有一条铁律:任何可能超过1MB的String、Hash等,删除一律用UNLINK;并发的批量删除要限速,避免删除任务堆到后台线程后带来内存瞬增。另外还要注意,复制大String时不要用SETRANGE逐段拷贝,应该用DUMP加RESTORE,或者直接用COPY命令(如果是同一个实例内复制)。COPY命令刚才提到过,它复制的只是引用,所以即使value几十MB,COPY本身也是极快的。
4.3 调试命令解析的三种手段
排查命令解析问题,我常用的有三种手段,按由浅入深排列:
redis-cli MONITOR:实时打印所有执行的命令。它能直观看出客户端到底往Redis发了什么命令,格式是什么,命令名大小写是什么。注意MONITOR在高并发下会显著影响性能,生产环境只在低峰期开一小会儿。INFO commandstats:查看每种命令的调用次数和累计耗时。如果某条命令的usec_per_call特别大,且调用次数不多,基本可以确定是慢命令,再配合slowlog定位是哪些key拖慢的。- gdb或日志调试:读Redis源码时可以直接attach到Redis进程上,在
processCommand和call函数打断点。输入输出参数在c->argv数组里清晰可见,能观察到一条命令从解析到分发的全过程。注意不要在生产环境这么干。
5. 这套设计能学到什么
5.1 函数指针注册表:业务分发的简化版
读Redis命令解析最大的收获,不是记住那些API,而是它把“分发逻辑”和“业务实现”完全解耦了。命令表就是一个配置化的分发器,新增命令只需要增加一行表项,剩下的参数校验、权限检查、集群路由都不需要动。
我在自己写的网关服务里也复刻过这套思路:用一个映射表把消息类型映射到处理函数指针,每个处理器只关心自己的业务逻辑。这样带来的收益很直接:新增一种消息类型,代码变更只涉及新增一个处理器和一个注册项,不会改动分发主流程。如果你在公司里维护过那种几百个if-else分支的消息处理代码,一定会对这种模式有强烈共鸣。
5.2 String命令里的内存友好设计
String命令实现里其实藏着很多内存优化的通用方法论。tryObjectEncoding让我学到一点:不要总是保存原始数据,根据数据的实际形态选择更紧凑的表示。整数就用int,短字符串就用embstr,只有需要修改时再退化到raw。这种“按需升级/退化”的思路,在业务里可以迁移成:尽量用轻量结构保存数据,只有在复杂度增长时才升级到更重的结构。
另外,String命令对原子性的处理也很有参考价值。Redis单线程模型天然提供原子性,但业务系统如果没有单线程模型,就得靠锁、事务或CAS来保证类似INCR的原子操作。我在设计库存扣减时,就参考了INCR的思路:用一个整数字段做原子增减,而不是先查后改。
最后想提醒一句:命令解析部分看似“离业务很远”,但一旦你遇到命令超时、参数解析错误、内存异常增长这类问题,回来读一遍这个链路,往往能比瞎猜快得多地定位根因。我自己就是这么被“教育”过的,所以强烈建议把命令表和String编码这两块源码亲手走一遍。