☰
Redis Hash与Set底层实现:从命令解析到编码切换的深度剖析
2026/10/3 23:17:33 网站建设 项目流程

老读者应该熟悉这个系列的节奏了:我们一直在把一个分布式存储的骨架,从协议层到存储引擎层一层层拆开。这一章轮到命令解析,但是我特意把Hash与Set单独拎出来讲,因为这两兄弟在实现上是“同源不同命”:底层可能共用同一套哈希表,但面对的命令语义、编码策略、遍历方式完全不同。很多线上问题,比如某个实例CPU突然飙高、某条命令返回慢,追到根因往往就在这两类命令的解析与数据结构切换上。

这一章我会从命令进入服务器的第一行字节开始,一直讲到Hash和Set在内存里到底长什么样、命令执行时走了哪条路径,最后把排查经验直接整理成速查表。适合正在看存储源码的人,也适合被线上big key、命令超时折磨过的运维同学。

1. 一条命令从网络到内存,解析层做了什么

1.1 命令解析不是“读字符串”那么简单

先看一个最普通的HSET命令在网络上长什么样:

*4\r\n$4\r\nHSET\r\n$1\r\nk\r\n$1\r\nf\r\n$1\r\nv\r\n

这是RESP协议格式:*4表示后面有4个参数,$4表示接下来是一个长度为4的字符串,也就是HSET。服务器拿到这段字节流以后,并不是直接拿去查表执行,而是要过一道完整的解析流水线。

第一件事是解析参数个数和每个参数的长度。这个环节最容易被忽略的坑就是:协议里的长度字段必须和真实字节数一致。如果客户端声明$4但后面只发了3个字节,解析器必须能识别这种畸形请求并拒绝,否则内存越界读是跑不掉的。Redis在这里的处理是严格按\r\n分割,每次读完指定长度的字节后,必须校验后面紧跟的是\r\n,任何不匹配直接返回协议错误。这个细节值得所有自己做网络协议的人抄作业。

第二件事是命令词匹配与大小写归一化。HSET、Hset、hset在协议层是三个不同的字符串,但语义必须完全一样。Redis在命令表里预置了命令名,查找时对每个字符做大小写不敏感比较,而不是先把输入转换成小写再查——这样做省了一次字符串拷贝。小小的设计,对高频命令路径上的性能是有实打实帮助的。

第三件事是arity校验。arity是命令表里的一个整数,正数表示精确的参数个数,负数表示“至少需要这么多参数”。HSET的arity是-4,意思是HSET key field value [field value ...]最少4个参数(含命令词本身)。如果客户端发了HSET key field,命令根本不会进入执行函数,在解析层直接返回:

-ERR wrong number of arguments for 'hset' command

为什么要单独强调这个环节?因为很多线上报错并不是业务逻辑错了,而是客户端拼接命令时参数算错了。把校验前移到解析层,一方面避免无效命令白白浪费执行线程,另一方面也让错误信息足够清晰。

1.2 命令表:一张决定“什么能做什么不能做”的路由表

命令解析完成后,服务器拿到的是一个redisCommand结构体,里面至少包含命令名、处理函数指针、arity、命令属性标志等字段。查找命令的过程本质上是哈希表查找,O(1)复杂度,所以每秒几万甚至几十万条命令到达时,解析层不会成为瓶颈。

但命令表还有一个容易被忽视的作用:权限与语义控制。命令属性标志里标明了这条命令是只读还是写、是否涉及阻塞、是否需要在多机上广播等。比如HSET是写命令,执行前需要判断当前实例是否可写;HGET是只读命令,在集群模式下可以直接路由到从节点。解析阶段把这些信息准备好,执行引擎拿到手的就已经是“洗好的菜”,而不是还得自己去判断。

这里我补充一个从业者的观察:很多人在排查“命令为什么慢”时,习惯性去看执行函数内部的数据结构,比如哈希表冲突率、缩扩容耗时,但往往会漏掉自定义命令或Lua脚本在解析阶段的损耗。如果命令名不在命令表里,Redis会尝试匹配子命令(比如SCRIPT LOAD里还有二级分派),这条路径上的字符串比较次数和分配次数,在高QPS下是会真实拉高CPU的。所以自己扩展命令时,命令表设计得越扁平越好,少做二级分派。

1.3 参数上限与协议安全

解析层还必须回答一个问题:单条命令能带多大的参数?Redis里proto-max-bulk-len默认是512MB,也就是说一个value最大能到512MB。看起来很宽,但实际生产环境中,几十MB的value已经足够把网络和内存打穿了。解析层会先按协议头里的长度声明预分配缓冲区,再读入数据,如果声明长度超过proto-max-bulk-len,直接拒绝。

这个策略的巧妙之处在于:预分配发生在读取完整数据之前。恶意客户端可以声明一个1GB的大参数,如果服务器傻傻地先把1GB内存分配好再读数据,几十个连接就能把内存耗尽。Redis在解析阶段就有保护机制,长度超限立即报错并断开连接。这一点在自研存储或者网关层做协议解析时,属于必须抄的作业。

2. Hash命令的底层实现:一张“会变形的哈希表”

2.1 两种编码:listpack与dict的切换

Hash在内存里并不总是标准的哈希表(dict)。为了省内存,当数据量小时,Redis会使用listpack(紧凑列表)编码。listpack本质上是一块连续内存,里面按顺序存放field和value,每个字段有独立的长度标识,整体非常紧凑,适合几个到几十个字段的小hash。

那么阈值是多少?默认配置是:

hash-max-listpack-entries 128 hash-max-listpack-value 64

意思是:当hash里的field数量超过128个,或者某个field或value长度超过64字节时,编码从listpack转为dict。注意是“或”的关系,任何一个条件触发都会转换。

为什么要有这个转换?因为listpack的查询是线性扫描,O(n)复杂度,100多个字段扫一遍还能接受,如果几千个字段还用线性扫描,每次HGET都扫全表,显然不现实。而dict是哈希表,查询是O(1),代价是每个节点有额外的指针开销,内存占用大。所以小数据用紧凑编码、大数据用哈希表,就是典型的“空间换时间、时间换空间”的工程权衡。

这里有一个值得注意的坑:listpack里存的是field的原始字节,没有做哈希运算,所以查找靠逐个字节比较。如果field是长字符串,即使只有几十个字段,每次查找的字节比较成本也不低。因此配置hash-max-listpack-value时,不建议把阈值调大到大字符串能一直留在listpack里,否则可能出现“字段少但查找慢”的怪状况。

2.2 HSET与HGET的真实执行路径

执行HSET key field value时,命令函数做四件事:

  1. 根据key在主字典里查找hash对象,不存在则创建一个空的listpack编码hash。
  2. 检查当前编码类型:如果是listpack,调listpack的upsert逻辑;如果是dict,调dict的add/replace逻辑。
  3. 返回值:字段是新增的返回1,覆盖旧值返回0。
  4. 如果有keyspace notification监听,发布一条事件。

编码检查为什么放在每次操作里?因为hash对象可能在任意一次写入中触发编码转换,所以每次读写都必须先查当前编码,再走对应逻辑。这个分支判断非常便宜,只是一次整数比较,但它在所有高频命令的路径上,所以即使只省掉一个字节的读取,对整个系统的吞吐都有帮助。

执行HGET key field时稍微有一点不同:先通过key找到hash对象,然后根据编码类型走listpack查找或者dict查找。如果字段不存在,返回nil。如果hash对象本身不存在,直接返回nil,这里不需要创建一个空对象,因为读操作不应该产生任何副作用。

我在实际排查中见过一个经典误区:有人担心listpack编码的hash做HGET是O(n),于是写脚本定期把所有小hash转成dict“提升性能”。这个操作完全多余且有害——转换本身需要分配新内存、重新插入所有字段,是一次O(n)的耗时操作,而且转换后内存占用上升,缓存命中率可能下降。真正应该做的是根据访问模式调整阈值,而不是手动强制转换。

2.3 HINCRBY为什么能原子执行

HINCRBY key field increment是Hash命令里比较有代表性的一条,因为它在“读-改-写”三个操作上实现了原子性。在Redis单线程模型下,命令执行是串行的,所以HINCRBY天然不会出现并发覆盖。但这并不代表实现简单:

  • 首先要把field对应的value从字符串解析成整数;
  • 做加法运算;
  • 把结果写回;
  • 如果value本身不是数字,返回错误:hash value is not an integer。

这里的解析逻辑对格式非常敏感:前导空格、非数字字符、超出long long范围,都要判错。Redis实际使用的是string2ll这类严格解析函数,不允许浮点数,也不允许科学计数法。

有人会问:如果要在多线程的业务逻辑里实现类似“先读后写再回写”的流程,还能用HINCRBY吗?不能。HINCRBY只能做单字段的增减,如果业务需要“读两个字段、算完写回两个字段”,那只能通过Lua脚本或事务来保证原子性,因为那已经超出单条命令的语义范围了。知道这条边界,能帮你少踩很多“用错命令导致数据不一致”的坑。

2.4 渐进式rehash:扩容不是一次做完

当dict里的数据量超过负载因子阈值时,需要扩容。但Redis的dict扩容不是一次性把旧表数据全部搬到新表,而是渐进式rehash:每次对dict进行增删改查时,顺带把旧表的一个桶(bucket)迁移到新表,直到全部迁移完成。

为什么这么做?一个几千万字段的大hash,如果一次性rehash,期间所有命令都被阻塞,对线上是不可接受的。渐进式rehash把搬迁成本摊到多次操作里,让每次命令的延迟增量都小到可以忽略。

理解rehash状态对排查问题很重要。在rehash进行中,查询一个key需要同时查旧表和新表,写入只往新表写。这意味着rehash期间的内存占用会短暂上升(两张表并存),命中率也可能略有变化。我们在看INFO memory时如果发现used_memory出现阶梯式上涨,之后又缓慢回落,往往就是后台rehash或缩容的真实写照。

缩容同样使用渐进式机制。当负载因子降到0.1以下,dict会收缩到更小的表,释放内存。这个过程不阻塞服务,但会消耗CPU。所以如果一个实例上频繁出现“大量写入触发扩容,删除后又触发缩容”,CPU会无谓地烧在rehash上。经验做法是:对这类hash,业务层控制field数量在一个合理区间,避免频繁跨越阈值。

3. Set命令的底层实现:集合语义的两种玩法

3.1 intset编码:有序数组的二分查找

Set在元素少且全部是整数时,使用intset编码。intset的本质是一个有序的整数数组,按从小到大的顺序排列。为什么元素少时要排序?因为排序是二分查找的前提,只有有序数组才能在O(log n)时间内判定元素是否存在。

默认阈值set-max-intset-entries 512,意思是元素个数超过512,即使全是整数,也会转为dict编码。为什么不一直用intset?因为intset插入新元素时,如果插入位置在数组中间,需要移动后续所有元素,是O(n)的;当元素数量变大,这个移动成本就不可接受了。

intset还有一个细节:它内部会记录每个整数占用的编码宽度,比如16位、32位、64位。当插入一个更大的整数超过当前宽度范围时,整个intset需要“升级”,也就是把所有已有元素重新编码为更宽的整数。这个升级是一次O(n)的全量操作,但好在intset的n被限制在512以内,最坏情况也就移动几百个元素,完全可控。

3.2 SADD与SREM背后的“一个整数判断”

执行SADD key member1 member2 ...时,命令函数先找key对应的set对象:如果不存在,创建一个空的intset编码的set。然后对每个member做两件事:

  1. 判断member是不是整数。如果所有member都是整数,且当前元素个数加上新增后不超过512,继续使用intset;
  2. 一旦某个member不是整数,或者元素个数即将超过512,立即将整个set转换为dict编码。

这个“判断member是否为整数”的步骤在编码决策里是强制性的,而且必须在插入之前完成。因为intset只接受整数,如果先把一个字符串插进去才发现编码不对,再转换,状态管理就复杂了。提前判断是典型的防御式编程。

转换之后,set的dict编码里,key是成员本身,value是NULL。这一点和Hash用dict存储field-value完全不同:Set的dict实际是用哈希表的key来去重,value一栏完全是空的。所以Set的SISMEMBER就是一次哈希表查询,O(1)复杂度;而SADD往dict里插入新成员时,如果成员已存在,插入失败不算新增。

SREM的路径类似:先判断编码类型,如果是intset就在有序数组里二分查找并删除,删除后的元素移动成本在512规模内可忽略;如果是dict就直接从哈希表删除。返回值表示实际删除了几个成员,这个数字通常比命令参数少,因为有些成员本来就不存在。

3.3 SINTER与SUNION的计算策略

交集、并集、差集这三个命令是Set类型最“贵”的操作,因为它们要同时处理多个集合。以SINTER为例,Redis不是把每个集合全量读出来再比对,而是采用了一个优化策略:

  • 先按元素个数排序,找出最小的集合A;
  • 遍历A的每个元素,依次在其他集合中执行SISMEMBER;
  • 如果某个元素在所有集合中都存在,加入结果集。

这比“先取第一个集合的所有元素,再和第二个集合做交集,结果再和第三个做交集”更省时间,因为遍历的最小集合决定了查询次数,而每次查询都是O(1)。时间复杂度近似O(m1 * n),其中m1是最小集合的元素数,n是集合个数。

SUNION的策略则是:遍历所有集合,把元素逐一插入结果集,靠哈希表天然去重。SDIFF稍微复杂一点:取第一个集合为基准,遍历它的每个元素,如果该元素出现在其他任何一个集合中,就从结果里排除。

这里有个实际建议:如果业务上只需要“两个集合交集是否有元素”“交集数量是多少”,不要盲目用SINTER把整个结果集拉到客户端。Redis 7.0以后提供了SINTERCARD,直接返回交集基数,配合LIMIT参数可以在验证到一定数量后提前返回,代价小得多。我在线上优化过几次类似场景,就是拿SINTERCARD替换SINTER,命令耗时从几十毫秒降到个位数毫秒,效果立竿见影。

3.4 SMEMBERS与SSCAN:千万别把整表拉出来

SMEMBERS返回集合的所有成员,看起来很简单,但它有两个隐藏风险:

  1. 如果集合非常大,返回的数据包可能撑爆客户端输出缓冲区;
  2. 生成这个大响应时,服务器要连续遍历底层结构,中间如果其他命令被阻塞,整条链路延迟都会拉高。

正确的姿势是用SSCAN。SSCAN基于游标迭代,每次返回少量元素,客户端拿游标继续迭代,直到游标归零。整个过程不阻塞,也不会产生超大响应包。

不过SSCAN的游标不是简单的“第几页”,而是和哈希表的桶索引相关。如果迭代过程中集合发生了rehash或缩容,同一批元素可能被重复返回,这是允许的,客户端必须容忍重复。很多人在使用SSCAN时踩坑,是因为拿“分页”的思维去理解游标,以为游标是单调递增的页码。实际上游标可能跳变、可能往回走,你只需要把整个迭代跑完,并且用set在客户端去重即可。

4. 实战排坑:Hash与Set命令最常见的问题

4.1 big key带来的阻塞,根因往往不在命令本身

线上最常见的Hash/Set问题就是big key。一个字段上百万的Hash,一次HGETALL或SMEMBERS,直接把实例的响应时间拉高一个数量级。排查方法很简单:

redis-cli --bigkeys

它内部会扫描整个实例,统计每种类型最大的key以及对应的编码类型。--bigkeys不会阻塞,因为它用的是SCAN游标迭代,而不是一次性全量遍历。但它只能告诉你“哪个key最大”,具体的字段数量、内存占用,还要进一步查:

MEMORY USAGE key OBJECT ENCODING key HLEN key SCARD key

这里我多说一句:big key阻塞的根因不是数据量大本身,而是命令解析完成后生成的响应包太大。服务器要把整个数据结构序列化成RESP协议,再通过输出缓冲区发送给客户端。如果响应超过client-output-buffer-limit的限制,客户端连接会被直接断开,这是很多“连接突然断开”的隐藏原因。

4.2 编码转换引发的内存与CPU抖动

Hash和Set从紧凑编码转dict时,内存会有一波明显的上涨,因为dict每个节点有额外的指针开销。如果你监控到内存曲线呈现出“台阶式上涨”,同时CPU有小幅尖峰,多半是发生了批量编码转换。常见场景:

  • 业务方批量写入hash,每个hash很快从listpack突破hash-max-listpack-entries触发转换;
  • Set一直存整数,当元素从512变成513时整个set转dict。

前者会让小对象的内存暴涨3到5倍,后者会让大集合的内存上涨。处理思路不是不要转换,而是合理设置阈值。如果你明确知道某个hash的字段数量会在500左右,不如直接把hash-max-listpack-entries调到512以上,让它保持在listpack里,省掉一次转换;如果字段数会到几千,反而不要把阈值调太高,免得listpack里的线性扫描拖慢每次查询。

4.3 “wrong number of arguments”是怎么来的

这个报错属于命令解析层的典型错误。排查时先把命令在交互式客户端里原样打一遍,看参数数量。常见原因是:

  • 客户端参数拼接时,把空字符串也当作一个参数传了;
  • 使用HSET时少传了value;
  • 使用SINTER时传了两个key但期望返回三个集合的交集。

Redis的错误信息不像MySQL那么啰嗦,它只告诉你参数个数不对,不会告诉你是多了还是少了。所以排查思路是对照命令表的arity逐个数参数,HSET最少4个、SINTER最少3个、SADD最少3个。这条经验看着基础,但我在一线支持时碰到过不止一次:一个写入进程突然开始报错,最后定位到是配置中心改动了分隔符,导致field被拆成了两截,命令参数平白多了一个。

4.4 排查命令缓慢的“三段论”

如果一条Hash或Set命令变慢,我建议按三层来定位:

第一层,解析层。看命令参数长度和参数数量,是否存在超大field或超大member。一个1MB的member做SADD,光是解析和内存拷贝就要消耗不少CPU,这种场景下命令慢不是数据结构问题,而是数据本身的问题。

第二层,数据结构层。用OBJECT ENCODING查看编码类型,如果发现本应该很小的hash却是dict编码,或者set在整数场景下用了dict,检查是不是当初批量写入时超过了阈值,导致编码一直没缩回来。注意:编码只会从紧凑转标准化,不会自动转回紧凑。即使你删掉了很多字段,让hash只剩两个字段,它依然是dict编码。

第三层,执行效率层。Hash和Set的共同特点是有大量命令在红黑树和哈希表之间选择数据结构,但在实际执行路径中,主要成本还是哈希函数计算、内存分配和缓存失效。如果单条命令耗时突然升高,优先查系统资源,比如内存是否触发swap、CPU是否有争抢,而不是怀疑数据结构本身。

关于这一章的几点个人体会

这一章扯了不少底层细节,最后分享一条我自己的排查习惯。每次查看线上Hash或者Set命令的行为异常时,我都会先输入OBJECT ENCODING看一眼编码类型,再决定后续的方向。编码类型就像引擎的“档位”:listpack挡还是intset挡,意味着你还在低档位高速运转;dict挡则是进入了大对象模式,这时候再结合MEMORY USAGE估算实际内存,心里就有底了。

Hash和Set这两兄弟,明明底层共用了一张哈希表,却因为语义不同走出了完全不同的实现路径。Hash要在field和value之间建立映射,Set只要成员的唯一性;Hash需要支持对单个field做原子增减,Set更关注集合之间的运算。命令解析的公共框架把它们统一进同一个命令表,剩下的差异全在编码策略和算法选择里。看懂了这些设计,你以后不管是调优、排障,还是自己设计存储结构,都会比别人多一层底气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询