Redis 系列(四):底层实现(二)——List、Set、ZSet 与 Stream 的数据结构

Redis 系列(四):底层实现(二)——List、Set、ZSet 与 Stream 的数据结构

核心目标:理解 quicklist、intset、skiplist、listpack、rax 的原理与取舍;用MEMORY USAGE实测回答"为什么相同元素数,ZSet 比 List 贵 10 倍";理解集合编码的升级路径与阻塞命令(BLPOP)的等待机制。

前置知识:完成 Part 3(redisObject、SDS、dict),理解"编码 = 当前实现"与每键固定开销。

验证环境:Redis 8.10.0(cygwin 移植版,127.0.0.1:6379)、redis-py 8.1.0、Python 3.11.6、Windows 11;源码依据官方 Redis 7.4.2(_refsrc/redis-7.4.2)。最后复核日期:2026-08-07。


0. 本篇问题场景:相同 1000 个元素,内存差 10 倍

实测同一实例、同一批 1000 个元素分别装进三种结构:

List 1000 元素 → 5,921 字节 Set 1000 元素 → 30,162 字节 ZSet 1000 元素 → 67,914 字节

同样的数据量,ZSet 是 List 的11 倍。为什么?答案在三种结构的底层设计里:List 用"链表 + 紧凑包"省内存,Set 用"整数压缩"或哈希表,ZSet 为了支持排序付出了"跳表节点 + 哈希表"的双份结构。本篇逐一打开这些实现,并回答三个问题:

  1. List 的"快"与"省"是怎么同时成立的?
  2. Set 的 intset 何时失效?
  3. ZSet 的 skiplist 为什么贵,贵得值不值?

1. List:quicklist = 双向链表 + listpack 节点

1.1 结构

Redis 的 List 既不是简单的双向链表,也不是单个 listpack,而是quicklist:一个双向链表,每个节点(quicklistNode)内部是一个 listpack(紧凑列表)。源码quicklist.h

typedefstructquicklistNode{structquicklistNode*prev;structquicklistNode*next;unsignedchar*entry;// 指向本节点内的 listpacksize_tsz;// listpack 字节数unsignedintcount:16;// 本节点内元素数unsignedintencoding:2;// RAW==1 或 LZF==2(压缩)...}quicklistNode;typedefstructquicklist{quicklistNode*head;quicklistNode*tail;unsignedlongcount;// 全部元素总数unsignedlonglen;// 节点个数signedintfill:QL_FILL_BITS;// 每节点填充策略unsignedintcompress:QL_COMP_BITS;// 两端保留不压缩的深度...}quicklist;

小 List(元素 ≤list-max-listpack-size对应阈值)甚至不需要节点链,直接以单个 listpack 形式存在——这就是实测10 元素 → encoding=listpack的原因;数据变大后拆成多个 listpack 节点组成 quicklist(2000 元素 → encoding=quicklist)。

1.2 为什么"快"与"省"兼得

需求解决结构代价
头尾 O(1) 插入删除quicklist 的头/尾节点链表指针(每节点 16+ 字节,摊到多元素上)
省内存每个节点内是紧凑 listpack(无每元素指针)中间插入/删除需要移动节点内数据,O(N)
大值省内存list-compress-depth压缩中间节点(LZF)访问被压缩节点要先解压

list-max-listpack-size默认-2(每个 listpack 约 8 KB),list-compress-depth 0(默认不压缩)。设计哲学:内存与随机访问性能的平衡——头尾操作永远是 O(1),中间操作按需付出拷贝代价。

1.3 阻塞命令的等待机制

BLPOP/BRPOP在空队列时不返回而是挂起,这是轻量任务队列的核心能力。实测:

$ redis-cli BLPOP nonexistent:list 1 (等待 1 秒后返回 nil)

本机实测挂起 1.06 秒后返回Nonetimeout=1)。机制:客户端被挂到该 key 的等待队列(server.db的阻塞键表),当其他客户端LPUSH/RPUSH该 key 时,事件循环把数据直接推给等待者并唤醒——这就是"生产者-消费者"零轮询的实现基础。阻塞期间客户端连接保持,服务端不受影响(单线程只是"等待",不是"忙等")。


2. Set:intset 的"整数快车道"

2.1 intset 结构

当 Set 的所有成员都是整数且数量不多时,使用intset(整数集合,intset.h):

typedefstructintset{uint32_tencoding;// 元素位宽:16/32/64 位uint32_tlength;// 元素个数int8_tcontents[];// 有序、无重复的整数数组}intset;

contents是有序数组,因此查找用二分查找 O(log N),且天然去重。8 字节一个整数,几乎没有每元素开销——这是所有编码里最省内存的集合形态。

2.2 升级路径:三条路

实测的编码决策(全部真实输出):

SADD 100 个整数 → intset (整数、有序、去重) SADD 512 个整数 → intset (未超阈值) SADD 513 个整数 → hashtable (超 set-max-intset-entries=512) SADD 整数 + "abc" → listpack (出现非整数,转紧凑列表) SADD 100 个字符串 → listpack (非整数,小集合)

升级条件(任一触发即离开 intset):

  1. 元素个数 >set-max-intset-entries(默认 512)→hashtable
  2. 加入非整数元素 → 若元素数仍小则listpack,否则hashtable

注意第二条:混合后 4 个元素的集合是listpack而不是hashtable——intset 的替代品在小规模时是 listpack(更紧凑),数据规模大了才上 hashtable。"intset 升级 = 变 hashtable"是旧版本的印象,7.2+ 的正确路径是 intset → listpack(小)或 hashtable(大)

2.3 对选型的意义

  • 纯整数集合(如"在线用户 ID 集合")在 512 以内享受二分查找 + 极低内存;
  • 一旦混入字符串或增长,编码升级,但命令语义不变——这正是"type 与 encoding 两层"设计的好处:业务代码永远不用关心底层。

3. ZSet:skiplist + dict 的组合

3.1 结构:一份数据,两套索引

ZSet 的每个成员同时出现在两个结构中(server.h:1341):

typedefstructzskiplistNode{sds ele;// 成员(字符串)doublescore;// 分数structzskiplistNode*backward;structzskiplistLevel{structzskiplistNode*forward;// 前向指针unsignedlongspan;// 跨越的节点数}level[];// 多层(随机高度)}zskiplistNode;typedefstructzset{dict*dict;// 成员 → score 的哈希索引(O(1) 查分)zskiplist*zsl;// 按 score 排序的跳表(O(log N) 范围查询)}zset;

为什么是"dict + skiplist"两份:没有哪个单一结构能同时满足两个需求——

  • ZSCORE member(按成员查分数)要 O(1) → dict;
  • ZRANGEBYSCORE(按分数范围遍历)、ZRANK(查名次)要有序遍历 → skiplist。

3.2 skiplist 为什么贵

跳表是"多级索引的链表":每个节点随机一个层高,level[]数组里每层存 forward 指针与 span。查找时从最高层往下跳,期望 O(log N)。代价是每个节点多层指针(平均 ~1.33 层额外指针 × 8 字节)+ 每节点一个 dictEntry(24 字节)+ 两个结构的元数据。

这就是 §0 实测"ZSet 1000 元素 = 67,914 字节,是 List 的 11 倍"的构成:排序能力是用内存换来的。设计取舍:

替代方案为什么不用
平衡树(红黑树)实现复杂、范围遍历不如链表结构直观、需要节点旋转
数组 + 二分插入/删除 O(N) 移动
仅 dict无法按 score 排序遍历
仅 skiplistZSCORE退化为 O(log N) 且无法 O(1) 精确查分

skiplist 在期望 O(log N) 下实现简单、范围遍历自然,是"够用且简单"的工程选择。

3.3 listpack 编码的小 ZSet

小 ZSet(zset-max-listpack-entries默认 128、zset-max-listpack-value默认 64)先以 listpack 存储(成员与 score 交替压缩),超限后整体转 skiplist:

ZADD 100 成员 → listpack ZADD 1000 成员 → skiplist

与 Hash/Set 同理:编码只升不降,删除部分成员后不会自动回到 listpack。


4. Stream:rax 基数树

Stream 的消息按 ID(毫秒时间戳-序号)存储。若用普通哈希表,前缀相同的 ID 会浪费大量空间;Redis 用rax(基数树/压缩前缀树)存储消息索引,rax.h

typedefstructraxNode{uint32_tiskey:1;// 本节点是否是一个 key(有值)uint32_tisnull:1;uint32_tiscompr:1;// 是否压缩路径uint32_tsize:29;// 子节点数,或压缩路径长度...}raxNode;

rax 把公共前缀合并成一个节点iscompr=1表示"这一段路径被压缩成单节点")。Stream 的消息 ID 共享时间戳-前缀,rax 能把数百万条消息的索引压缩到极小;同时保持字典序,让"从某 ID 之后读"(XRANGE)成为天然的前缀遍历。

OBJECT ENCODING对 Stream 返回:

stream type: stream encoding: stream

(Stream 没有多种编码,stream就是它的实现名。)


5. 内存分析实战:为什么"我的 Redis 内存涨得比数据大"

5.1 MEMORY USAGE 对比

§0 的三组数据,拆解其构成:

结构1000 元素内存每元素开销主要构成
List5,921 B~6 Blistpack 紧凑存储,无每元素指针
Set30,162 B~30 Bhashtable 的 dictEntry(24 B/个)+ 桶
ZSet67,914 B~68 BdictEntry + skiplist 节点(多层指针)+ span

结论

  • 需要"只按顺序存取"用 List——最省;
  • 需要"去重/集合运算"用 Set——每个元素一个 dictEntry;
  • 需要"排序+范围"用 ZSet——最贵但功能最强;
  • 能用 List/Set 解决的场景别用 ZSet,反之亦然。

5.2 单键内存观察命令

$ redis-cli MEMORY USAGE z:mem # 只算 value 对象 $ redis-cli MEMORY USAGE z:mem 0 # 0 表示把 key 名也算上 $ redis-cli MEMORY DOCTOR # 内存健康体检(碎片、峰值等) $ redis-cli --bigkeys # 扫描最大的 key(Part 6 详讲)

MEMORY USAGE的"含 key"选项很有用:它直接给出"如果删掉这个 key 能省多少内存"——Part 6 治理大 key 时的第一工具。


6. 版本与环境差异

差异点官方 7.4本机 8.10.0(cygwin 移植版)影响
hash-max-listpack-entries默认512512(CONFIG GET实测)升级阈值以CONFIG GET实测为准
set-max-intset-entries512512一致
zset-max-listpack-entries128128一致
intset 升级目标listpack(小)/hashtable(大)listpack7.2+ 行为,与旧资料"必转 hashtable"不同
list-max-listpack-size-2(~8KB)-2一致
quicklist / skiplist / rax结构稳定一致源码引用 7.4.2

本机与 7.4 在这些结构上行为一致;写作时的关键教训是升级阈值要用CONFIG GET实测,不要背文档里的数字。


7. 测试与验收

  • 新增测试建议:编码断言(intset 512 边界、listpack→skiplist 升级)、MEMORY USAGE量级对比(List < Set < ZSet)、BLPOP超时语义;
  • 内存对比实验保留脚本(数据规模、测量命令、环境)。

本篇验收清单

  • 能画出 quicklist 的结构(双向链表 + listpack 节点)并解释头尾 O(1)/中间 O(N);
  • 能说出 intset 的三个升级触发条件(超 512、非整数、规模)与 7.2+ 的升级目标;
  • 能解释 ZSet"dict + skiplist"双结构的动机与 skiplist 的内存代价;
  • 能解释 Stream 用 rax 存储消息 ID 的好处(公共前缀压缩 + 字典序遍历);
  • 能用MEMORY USAGE对比三种结构的实际内存并解释差异来源;
  • 能说出BLPOP阻塞的机制(挂起 + 写入唤醒)并实测超时行为。

8. 常见误区

  1. “List 是普通双向链表”——是 quicklist:节点是 listpack,小 List 直接就是一个 listpack(§1)。
  2. “intset 升级就一定变 hashtable”——7.2+ 小规模转 listpack(§2.2),旧资料过时。
  3. “ZSet 就是排序的 Set”——底层是 dict + skiplist 两套结构,内存接近 Set 的两倍;排序能力有明确价格(§3、§5)。
  4. “skiplist 因为快所以用”——它比平衡树慢一点但简单很多、范围遍历自然;Redis 的选择是"够用 + 简单"。
  5. “BLPOP 超时期间会占用 CPU”——不会,客户端挂起等待唤醒,不是忙等(§1.3)。
  6. “编码升级后删数据会降回来”——只升不降(§3.3),紧凑编码需要重建键。

9. 本篇小结

回到开篇:同样 1000 个元素,List 5.9KB、Set 30KB、ZSet 68KB——差异不是 bug,是三种能力(顺序存取 / 集合运算 / 排序范围)各自的定价:

  • List 用"链表 + listpack"买到了 O(1) 头尾 + 紧凑存储;
  • Set 用 intset 的整数快车道 + hashtable 兜底;
  • ZSet 用 dict + skiplist 双结构换排序能力,代价是最高内存。

至此,五种核心结构 + 四种扩展结构的内存模型全部建立。下一篇 Part 5:持久化回答"内存里的一切如何落盘、如何恢复":RDB 的 fork+COW、AOF 的 fsync 策略与混合持久化,并用隔离实例做真实的"断电丢数据"实验。


10. 官方资料

  • Redis 7.4.2 源码:https://github.com/redis/redis/tree/7.4.2/src
  • OBJECT/MEMORY USAGE/MEMORY DOCTOR:https://redis.io/docs/latest/commands/
  • BLPOP阻塞语义:https://redis.io/docs/latest/commands/blpop/
  • Memory optimization:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/