不是。Redis 里 Hash 和 ZSet(还有 List、Set)都有两副面孔:数据量小的时候,用一种极其紧凑、省内存的结构存;数据量一大,自动换成另一套更适合大规模查找的结构。这篇讲清楚这次切换具体在什么时候发生、切换之后能不能再换回来,以及 Hash 和 ZSet 换的目标结构为什么不一样。
接上一篇 SDS,继续用本机真实运行的 Redis 8.10.1 做实验。
Hash、ZSet(以及 List)在元素很少的时候,底层都用同一种紧凑结构——listpack:一段连续内存,元素挨个紧密排列,没有指针、没有哈希桶的开销。
找一个字段要从头挨个比较——元素少的时候这完全不是问题,順序扫描比维护一整套哈希表/跳表的开销还小。
只要触碰到其中一个上限——哪怕另一个远没到——就会转换,两个条件是"或"的关系,不是"且"。
都是"从紧凑结构换成大规模结构",但换成的具体结构不一样——因为 Hash 和 ZSet 要支持的操作不一样。
本机真实默认阈值:hash-max-listpack-entries=512,hash-max-listpack-value=64。
第三组实验最能说明"或"关系:整个 Hash 只有 2 个字段,远没到 512 这条线,但因为其中一个值超过了 64 字节,照样触发转换。ZSet 是完全对称的行为(阈值 zset-max-listpack-entries=128,zset-max-listpack-value=64,针对的是 member 而不是 score 的长度):
源码里两个类型的判断逻辑结构几乎一模一样:
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?
只剩 3 个字段,远低于 512 这条线,编码依然是 hashtable。翻遍 t_hash.c 和 t_zset.c 也找不到任何一条"hashtable/skiplist 转回 listpack"的代码路径——这不是没测全,是压根不存在这个方向的转换。
用小得多的玩具阈值复现规则的形状(规则和真实代码一致,数字缩小方便看清楚)。
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 反而更合适:数据量小的时候,维护两套索引结构的开销,比直接线性扫一遍还贵。