
命令解析和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是sdsvalue是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 0Redis会把它当成“立即过期”相当于删除这个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或setrangeRedis会先把它转成RAW编码再执行修改。为什么不直接原地扩展embstr因为embstr的robj和sds是连续内存原地扩展可能破坏后面的内存布局而且需要整体重新分配代价不小。RAW编码就是普通sds。字符串长度超过44字节或者经过修改后的字符串都会落入这个编码。三种编码的转换关系你可以在tryObjectEncoding里看到set key 12345时尝试转INTset 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_MAXRedis会返回错误而不会让你把整数溢出成负数。这个行为与很多语言的整数溢出无感知是不同的使用时要留意。3.4 批量与变体MGET / APPEND / SETRANGEMGET 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的StringDEL操作本身就可能在主线程上阻塞十几毫秒。毫秒级阻塞在单线程Redis里已经算大事故了。所以我在生产环境有一条铁律任何可能超过1MB的String、Hash等删除一律用UNLINK并发的批量删除要限速避免删除任务堆到后台线程后带来内存瞬增。另外还要注意复制大String时不要用SETRANGE逐段拷贝应该用DUMP加RESTORE或者直接用COPY命令如果是同一个实例内复制。COPY命令刚才提到过它复制的只是引用所以即使value几十MBCOPY本身也是极快的。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编码这两块源码亲手走一遍。