Redis 系列 · 第二篇

一个 Hash 只有 3 个字段和有 3 万个字段,底层存的是同一种结构吗?

不是。Redis 里 Hash 和 ZSet(还有 List、Set)都有两副面孔:数据量小的时候,用一种极其紧凑、省内存的结构存;数据量一大,自动换成另一套更适合大规模查找的结构。这篇讲清楚这次切换具体在什么时候发生、切换之后能不能再换回来,以及 Hash 和 ZSet 换的目标结构为什么不一样。

接上一篇 SDS,继续用本机真实运行的 Redis 8.10.1 做实验。

512 / 64
真实测出的 Hash 编码切换阈值(entries / 单值字节数)
单向
编码只会变大,真实验证过——删到只剩 3 个元素也不会变回去

小的时候,大家都是 listpack

Hash、ZSet(以及 List)在元素很少的时候,底层都用同一种紧凑结构——listpack:一段连续内存,元素挨个紧密排列,没有指针、没有哈希桶的开销。

listpack

省内存,但查找是线性的

找一个字段要从头挨个比较——元素少的时候这完全不是问题,順序扫描比维护一整套哈希表/跳表的开销还小。

两个独立阈值

元素个数,或单个值的长度

只要触碰到其中一个上限——哪怕另一个远没到——就会转换,两个条件是"或"的关系,不是"且"。

转换目标

Hash → hashtable,ZSet → skiplist

都是"从紧凑结构换成大规模结构",但换成的具体结构不一样——因为 Hash 和 ZSet 要支持的操作不一样。

真实实测:两个独立的触发条件

本机真实默认阈值:hash-max-listpack-entries=512,hash-max-listpack-value=64

真实实测 HSET myhash field1 value1 ... (10 个字段) OBJECT ENCODING myhash → "listpack" HSET myhash2 f1 v1 ... (513 个字段) OBJECT ENCODING myhash2 → "hashtable" # 超过 512 这一条,触发转换 HSET myhash3 field1 "short" OBJECT ENCODING myhash3 → "listpack" HSET myhash3 field2 (一个 65 字节的值) OBJECT ENCODING myhash3 → "hashtable" # 只有 2 个字段,单纯因为值太长就转换了

第三组实验最能说明"或"关系:整个 Hash 只有 2 个字段,远没到 512 这条线,但因为其中一个值超过了 64 字节,照样触发转换。ZSet 是完全对称的行为(阈值 zset-max-listpack-entries=128,zset-max-listpack-value=64,针对的是 member 而不是 score 的长度):

真实实测 ZADD myzset 1 alice 2 bob 3 carol OBJECT ENCODING myzset → "listpack" ZADD myzset3 1 short OBJECT ENCODING myzset3 → "listpack" ZADD myzset3 2 (一个 65 字节的 member) OBJECT ENCODING myzset3 → "skiplist"

源码里两个类型的判断逻辑结构几乎一模一样:

src/t_hash.c · redis @ 8.10.1, L1577 size_t new_fields = (end - start + 1) / 2; if (new_fields > server.hash_max_listpack_entries) { hashTypeConvert(db, o, OBJ_ENCODING_HT); ... } ... if (len > server.hash_max_listpack_value) { hashTypeConvert(db, o, target_enc); ... }
src/t_zset.c · redis @ 8.10.1, L1655 if (zzlLength(zobj->ptr)+1 > server.zset_max_listpack_entries || sdslen(ele) > server.zset_max_listpack_value || !lpSafeToAdd(zobj->ptr, sdslen(ele))) { zsetConvertAndExpand(zobj, OBJ_ENCODING_SKIPLIST, zsetLength(zobj) + 1); }

还多了一条本文实验没有专门覆盖的第三重保险:lpSafeToAdd——即使前两个阈值都没超,如果这次写入会让 listpack 自身的总字节数超出安全上限,同样会触发转换。

真实验证:转换是单向的

从 513 个字段的 hashtable 编码 Hash 里,删掉 510 个,只剩 3 个字段——编码会不会变回 listpack?

真实实测 OBJECT ENCODING myhash2 → "hashtable" # 513 个字段时的状态 HDEL myhash2 f1 f2 ... (删除 510 个) HLEN myhash2 → 3 OBJECT ENCODING myhash2 → "hashtable" # 还是 hashtable,没有变回去

只剩 3 个字段,远低于 512 这条线,编码依然是 hashtable。翻遍 t_hash.ct_zset.c 也找不到任何一条"hashtable/skiplist 转回 listpack"的代码路径——这不是没测全,是压根不存在这个方向的转换。

这个设计是故意的:反复在 listpack 和 hashtable 之间来回转换的开销(每次转换都要重新分配、重新组织全部数据)可能比"多占一点内存、始终用大结构"还贵,尤其是数据量在阈值附近反复增删的场景。Redis 选择了"宁可多占内存,也不做双向转换"这个简单但可预测的策略。

演示:元素一个个加进去,编码什么时候变

用小得多的玩具阈值复现规则的形状(规则和真实代码一致,数字缩小方便看清楚)。

选择要演示的类型
当前编码:listpack

为什么 Hash 和 ZSet 换的目标结构不一样

Hash 只需要"通过 field 查 value",换成普通哈希表(dict)就够了。ZSet 除了"通过 member 查 score",还要支持"按 score 排序取范围"——这是哈希表完全做不到的能力,所以 ZSet 换的是跳表,而且真实结构是跳表和哈希表一起用:

src/server.h · redis @ 8.10.1, L1816 typedef struct zset { dict *dict; /* 支持 O(1) 按 member 查 score,给 ZSCORE 用 */ zskiplist *zsl; /* 支持 O(log n) 按 score 排序/范围查询,给 ZRANGE、ZRANK 用 */ } zset;

两个结构指向的是同一份 member/score 数据,不是各存一份——dict 负责"这个成员的分数是多少"这类精确查找,zsl 负责"分数在某个范围内的都有谁"这类范围查询。这也是为什么小规模的 ZSet 用 listpack 反而更合适:数据量小的时候,维护两套索引结构的开销,比直接线性扫一遍还贵。

参考与说明

  • 本文所有 OBJECT ENCODING/HLEN/ZCARD/CONFIG GET 结果均在本机真实运行的 Redis 8.10.1 上通过 redis-cli 真实执行得到,未做任何删改;"转换是单向的"一节额外用真实的 HDEL 操作验证过,不是只看了源码就下结论。
  • 源码引用(t_hash.chashTypeTryConversiont_zset.c 的转换检查、server.hzset 结构体)取自 redis/redis 仓库 8.10.1 标签,与本机安装的 Redis 8.10.1 完全一致。
  • 演示动画用的阈值(Hash:4 个字段/6 字节,ZSet:5 个成员/6 字节)是缩小版玩具参数,判定规则(个数超限或长度超限任一命中即转换)和真实代码完全一致,只是数字方便在屏幕上演示。
  • 没有涉及:List 类型的编码切换(list-max-listpack-size,规则和这里类似但控制粒度略有不同)、Set 类型的 intset/listpack/hashtable 三级编码、lpSafeToAdd 具体的安全阈值计算方式。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电