一文吃透 Redis 压缩列表、listpack 及哈希表扩容与并发查询
Redis 为了在内存和性能之间做极致平衡,在不同数据类型的小数据量场景下,使用了非常紧凑的内存结构:ziplist(压缩列表) → listpack(紧凑列表),以及作为核心的dict(哈希表)在扩容时的渐进式 rehash 机制。
下面从原理、结构、优缺点、演进、源码关键点、实际影响几个维度全面拆解。
1. 压缩列表(ziplist) —— Redis 早期“省内存神器”
适用对象(Redis 6及更早版本默认):
- hash / zset / list(元素少且值短时)
- 阈值示例(可配置):
- hash:
hash-max-ziplist-entries 512、hash-max-ziplist-value 64 - zset:
zset-max-ziplist-entries 128、zset-max-ziplist-value 64 - list:
list-max-ziplist-size等
内存布局(连续内存块)
<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <0xFF>
4字节 4字节 2字节 变长 1字节(结束标志)
每个 entry 结构:
<prevlen> <encoding> <entry-data>
- prevlen:前一个 entry 的字节长度(1 或 5 字节)
- ≤254 → 1 字节
- >254 → 5 字节(0xFE + 4字节长度)
- encoding:当前 entry 的类型和长度(1 字节起)
- 字符串:6bit 长度 + 内容,或特殊编码
- 整数:特殊编码(立即数、int8/16/24/32/64)
- entry-data:实际内容
最大优势:极致省内存,没有指针,全部紧挨着存放。
致命缺陷:连锁更新(cascade update)
当某个 entry 长度变化(例如从小字符串变长):
- 如果 prevlen 由 1 字节 → 5 字节,后续所有 entry 的 prevlen 都要右移
- 右移又可能导致下一个 prevlen 溢出 → 连锁反应
- 最坏情况:O(N) 时间,N 是元素个数
结论:ziplist 适合极小且值长度稳定的场景,一旦频繁更新大值,会出现性能毛刺。
2. listpack —— Redis 7.0 之后彻底取代 ziplist
Redis 7.0 开始,ziplist 完全被 listpack 取代(RDB 加载时也会自动转换)。
设计目标:保留 ziplist 省内存的优势,彻底消灭连锁更新。
listpack 内存布局
<total-bytes> <num-elements> <entry> <entry> ... <entry> <0xFF>
4字节 2字节 变长 1字节
每个 entry:
<encoding-type> <optional length> <data> ← 没有 prevlen!
关键变化:
- 去掉 prevlen(最重要!)
- 不再记录前一个 entry 长度
- 想知道前一个 entry?从头部 total-bytes – 当前 entry 偏移 倒推
- 正向/反向遍历都支持
- encoding 更丰富(1 字节编码 11 种方式) 编码前缀(高位) 类型 长度信息位数 备注 0xxxxxxx 7bit 无符号整数 — 小整数直接存 10xxxxxx 6bit 字符串长度 — 小串 110xxxxx 13bit 整数 — 中整数 1110xxxx 16bit 有符号整数 — 1111xxxx 特殊:32/64bit 整数等 — … 字符串 + 长度字段 变长 大串用额外长度字段
- 整数编码更激进:小整数直接放在 encoding 里,不额外占字节
listpack 优缺点对比 ziplist
| 维度 | ziplist | listpack | 胜出方 |
|---|---|---|---|
| 内存利用率 | 极高 | 略高(去掉 prevlen 后更省) | listpack |
| 连锁更新风险 | 极高(O(N) 最坏) | 无(每个 entry 独立) | listpack |
| 编码复杂度 | 较复杂 | 更简单统一 | listpack |
| 反向遍历效率 | 依赖 prevlen | 通过 total-bytes 倒推 | 相当 |
| Redis 版本 | ≤6.x 主用 | 7.0+ 完全取代 | — |
结论:listpack 是 ziplist 的“进化版”,内存差不多甚至更省,彻底解决性能毛刺,已成为 Redis 7+ 所有小集合的首选结构。
3. Redis 哈希表(dict)扩容与渐进式 rehash
Redis dict 结构(核心是哈希表)
typedef struct dict {
dictType *type;
void *privdata;
dictht ht[2]; // 0: 当前表,1: 扩容/缩容目标表
long rehashidx; // -1 表示不在 rehash,否则是正在迁移的 bucket 索引
...
} dict;
dictht(单个哈希表)
typedef struct dictht {
dictEntry **table; // 桶数组
unsigned long size; // 桶数量(2的幂)
unsigned long sizemask; // size-1,用于 & 取模
unsigned long used; // 已用键值对数量
} dictht;
扩容/缩容触发条件(大致)
- 扩容:
used >= size且 load factor > 1(或 >5 但没在 bgsave) - 缩容:load factor < 0.1(且 used > 一定值)
渐进式 rehash 核心流程
- 触发扩容 → 创建新 ht[1](桶数通常 ×2)
- 设置 rehashidx = 0(开始迁移标志)
- 每次增删改查操作时(_dictRehashStep):
- 把 ht[0] 的 rehashidx 位置 的整个 bucket 迁移到 ht[1]
- rehashidx ++
- 如果迁移完所有 bucket → rehashidx = -1,完成
- 查找/删除 时:
- 如果 rehashidx != -1,必须同时查 ht[0] 和 ht[1]
- 更新操作优先写到 ht[1](写新不写旧)
并发查询视角(单线程 Redis 下的“并发”)
- Redis 是单线程主循环,所有客户端请求串行执行
- 但在渐进式 rehash 期间:
- 查找:可能查两张表(ht[0] + ht[1])
- 插入:只写 ht[1]
- 删除:先查 ht[1],找不到再查 ht[0]
- SCAN 指令:采用高位进位法遍历,保证扩容期间不漏 key、不重复
最坏情况:rehash 期间,单个请求可能多查一次表,但整体分摊到所有操作,不会出现长阻塞。
实际影响:
- 大 key(百万级 field)扩容时,不会瞬间卡死,而是平滑分摊
- 高 QPS 场景下,rehash 会被频繁的操作加速完成
快速总结对比表
| 结构 | 出现版本 | 主要用途 | 最大优点 | 最大缺点 | 当前状态(Redis 7+) |
|---|---|---|---|---|---|
| ziplist | 早期~6.x | hash/zset/list 小对象 | 极致省内存 | 连锁更新 O(N) 最坏 | 已废弃 |
| listpack | 5.0 引入,7.0+ 默认 | 同上 | 省内存 + 无连锁更新 | 略复杂编码 | 当前标准 |
| dict(ht) | 所有版本 | hash 主要实现 | O(1) 平均查询 | 扩容时内存翻倍 | 渐进式 rehash 保护 |
面试/实战常见问题
- 为什么 Redis 7 要把 ziplist 换成 listpack?
→ 消灭连锁更新,解决更新大值时的性能毛刺。 - listpack 怎么实现反向遍历?
→ total-bytes – 当前偏移 → 倒推上一个 entry 起始位置。 - 渐进式 rehash 期间,key 到底在哪张表?
→ 查两张表,写只写新表,迁移完后统一切 ht[0] = ht[1]。 - 大 hash 扩容时会不会卡?
→ 单线程分摊,基本不会明显卡,但 QPS 会短暂下降(多查一次)。 - listpack 比 ziplist 更省内存吗?
→ 大多数场景更省(去掉 prevlen),极端情况下接近。
希望这篇把 Redis 这三个最核心的“省内存 + 高性能”机制讲透了。如果你想深入某个部分的源码片段(例如 listpack 的插入/删除、dict 的 rehashStep 实现),可以告诉我,我继续带你看!