同样是 Redis 命令,内存为什么会突然变大?从对象编码到渐进式 rehash
下面两组命令在业务层看起来都只是“往 Hash 里增加一个字段”:
1 | HSET user:1 name alice |
第一条执行后,user:1 可能使用紧凑的 listpack;第二条如果让元素大小超过当前配置阈值,Redis 会把它转换成通用哈希表。命令没有变,返回值也没有提醒“底层换结构了”,但内存占用、遍历方式和常数开销已经不同。
这正是理解 Redis 内部数据结构的实际价值:不是为了背诵某个版本的 C 结构体,而是为了回答三个工程问题:当前值为什么采用这种编码?什么操作会触发转换?转换之后对内存和延迟有什么影响?
本文以 Redis 8.8.0 源码和当前官方文档为版本快照。Redis 的内部结构不是稳定 API,旧版教材中的 sdshdr { len, free, buf[] }、ziplist 和“List 直接等于双向链表”等描述只能帮助理解历史演进,不能原样套到现代版本。实际环境必须先用 INFO server、OBJECT ENCODING 和 CONFIG GET 核对。
1. 为什么 Redis 类型相同,内部编码却可以不同?
客户端看到的是逻辑类型,例如 String、List、Set、Hash 和 Sorted Set;Redis 内部还会为同一逻辑类型选择一种编码(encoding)。可以把关系理解为:
1 | 数据库键空间 |
同一种 type 使用多种 encoding,是为了在“小而密”和“大而通用”之间取舍:
| 目标 | 紧凑编码 | 通用编码 |
|---|---|---|
| 小集合的内存效率 | 连续存储,指针少,缓存局部性好 | 节点、桶和指针开销较大 |
| 查找和更新的扩展性 | 常需要线性扫描或移动数据 | 哈希表、skiplist 等扩展性更好 |
| 单个元素很大 | 不适合反复移动或紧凑打包 | 更容易独立管理 |
| 实现复杂度 | 编解码和转换逻辑更多 | 单个操作路径更直接 |
因此,TYPE key 只告诉你逻辑类型,OBJECT ENCODING key 才告诉你当前内部编码。编码可能随写入自动转换,所以它是诊断信息,不应成为业务正确性依赖。
2. 怎样用一组最小命令观察编码变化?
下面实验需要一个可丢弃的本地 Redis 实例和 redis-cli。不要在共享或生产实例直接执行实验命令。示例只删除带 blog:encoding: 前缀的测试键,不修改全局配置。
先记录版本:
1 | redis-server --version |
创建几种小对象并查看编码:
1 | redis-cli DEL \ |
在 Redis 8.8 的常见默认配置下,可能看到:
1 | int |
这里必须写“可能”,因为编码和阈值会随版本、构建及配置变化。先查询当前 Hash 阈值,而不是背默认数字:
1 | redis-cli CONFIG GET hash-max-listpack-entries |
再写入一个明显更长的值:
1 | redis-cli HSET blog:encoding:hash oversized \ |
如果 1024 字节超过当前 hash-max-listpack-value,编码通常会变成 hashtable。若没有变化,先看刚才查询到的配置,再选择更长的测试值;不要为了复现实验在生产环境执行 CONFIG SET。
实验结束后清理:
1 | redis-cli DEL \ |
MEMORY USAGE 适合比较同一实例中的相对变化,但结果还包含对象头、分配器取整等开销,并受架构、分配器、版本和采样参数影响。不要把单个键的数字直接外推为整个集群容量。
3. SDS 为什么既能保存二进制,又能兼容许多 C 字符串 API?
3.1 真正的问题不是空格,而是 \0
旧笔记把 SDS 的二进制安全解释成“能记录任意位置空格”,这不准确。普通 C 字符串当然可以包含空格;真正的边界是 C 字符串把第一个 NUL 字节 \0 当作结束,而二进制数据可以在中间包含 \0。
下面的 C11 示例展示两种长度语义:
1 |
|
1 | clang -std=c11 -O2 -Wall -Wextra -Wpedantic \ |
预期输出:
1 | strlen=1 |
strlen 必须扫描到第一个 \0,既看不到后面的 B,求长度也是 O(N)。SDS 在头部保存已用长度,因此 sdslen() 可以 O(1) 返回真实字节数;同时仍在 buf[len] 放一个终止 NUL,使不含内嵌 NUL 的内容能方便地交给许多 C 字符串函数。末尾放的是 NUL 字节,不是空格。
3.2 现代 SDS 为什么不再只有一种 sdshdr?
Redis 8.8 的 sds.h 根据容量使用多种头部宽度。下面是帮助理解的示意,不是可复制替换源码的 ABI:
1 | /* 示意结构:N 可以是 8、16、32 或 64 位。 */ |
小字符串不需要为长度和容量各付出 64 位字段。Redis 通过 flags 识别 SDS 类型,再从 buf 指针前方找到对应头部。源码还定义了更紧凑的 type-5 布局;其结构主要用于描述布局,长度直接编码在 flags 中。
这种设计同时得到:
- O(1) 长度查询;
- 二进制安全;
- 容量大于长度时可减少连续 append 的重新分配;
- 对普通 C 字符串接口的有限兼容;
- 小字符串更低的头部开销。
“有限兼容”很重要:任何依赖 strlen、strcpy 的 API 仍然无法正确处理内嵌 NUL;SDS 只是保留终止字节,不会改变这些函数的语义。
3.3 扩容规则能不能背成“低于 1 MiB 翻倍,高于后多给 1 MiB”?
经典的贪心扩容路径确实使用 SDS_MAX_PREALLOC(1 MiB)控制额外预留:较小时倾向于成倍增长,较大时限制额外预分配,避免一次浪费过多空间。但现代 SDS 同时存在不同扩容入口和非贪心策略,调用者也可能主动移除空闲空间。
所以这条规则适合理解“用空间换 append 次数”,不应当作所有 SDS 操作的永久 API 保证。容量分析要对照目标版本的 sds.c 和真实调用路径。
4. Redis 对象的常见编码怎样映射到底层结构?
以当前官方 OBJECT ENCODING 文档为准,可以得到下面这张工程视角的映射:
| 逻辑类型 | 紧凑或特殊编码 | 通用编码 | 触发转换的典型原因 |
|---|---|---|---|
| String | int、embstr |
raw |
不再是整数、字符串变长或发生修改 |
| List | quicklist 节点内的 listpack | quicklist 仍是对外编码名 |
元素过大时节点可采用 plain 容器 |
| Set | intset、listpack |
hashtable |
出现非整数、元素数量/大小超过阈值 |
| Hash | listpack |
hashtable |
字段数量或字段/值大小超过阈值 |
| Sorted Set | listpack |
skiplist |
元素数量或成员大小超过阈值 |
| Stream | listpack 保存紧凑条目 | radix tree(rax)组织节点 | 由 Stream 实现管理 |
embstr 把对象头和短 SDS 放在一次分配中,减少分配次数并改善局部性;它适合短且不需要原地修改的字符串。长字符串通常使用 raw,整数形式的字符串可能直接使用 int 编码。Redis 8.8 的具体短字符串界限仍可在源码常量中确认,官方命令文档目前说明常见上限为 44 字节。
这些编码不是用户可以逐键指定的存储引擎。Redis 会根据值和配置自动选择,并在无法保持紧凑表示时转换到通用表示。工程上不要假设转换一定可逆,也不要让业务逻辑判断某个键必须是 listpack。
5. List 还是双向链表吗?为什么文档又说它是 linked list?
Redis List 的命令语义仍然接近链表:两端 push/pop 是 O(1),按索引访问中间位置需要遍历。可这不等于“每个 List 元素都是一个独立双向链表节点”。
现代 Redis 使用 quicklist:
1 | quicklist |
quicklist 外层是双向节点链,内层通常用 listpack 连续保存多个元素。这样在两种极端之间折中:
- 每个元素一个链表节点:插入方便,但指针和分配开销高、缓存局部性差;
- 所有元素一整块连续内存:紧凑,但中间修改可能搬动大量数据;
- quicklist:分块连续存储,两端操作方便,单个节点大小可控,内部节点还可以压缩。
Redis 8.8 的 quicklistNode 还区分 packed/plain 容器和 raw/LZF 编码。超大单元素不一定硬塞进 listpack;是否压缩、每个节点填充多少由配置和实现策略决定。
Redis 源码中仍有通用双向链表结构服务于内部模块,但不能因此推断 Redis List 值仍使用旧的 linkedlist 对象编码。官方文档已明确把 linkedlist 标记为历史编码。
6. 字典为什么需要两张表?渐进式 rehash 解决了什么?
Redis 的键空间、Hash 通用编码、Set 通用编码等场景都需要字典。Redis 8.8 的 dict 已做了很多内存布局和预取优化,dictEntry 甚至在公共头部中保持 opaque;因此不要继续抄旧版 dictEntry { key, value, next } 当作当前 ABI。
但核心 rehash 模型仍可以用下面的简化状态理解:
1 | /* Redis 8.8 dict 的概念示意,不是完整源码。 */ |
扩容开始前只有 table 0:
1 | table[0]: [bucket][bucket][bucket][bucket] |
扩容时分配更大的 table 1,然后逐步搬迁桶:
1 | table[0]: [moved][moved][old bucket][old bucket] |
在这段时间里:
- 新增键通常进入新表;
- 查找、更新和删除必须考虑两张表;
- 普通字典操作和周期任务会推进一小部分迁移;
- 全部搬完后释放旧表,新表成为 table 0。
如果一次性迁移百万级键,单次事件循环停顿可能很长。渐进式 rehash 把成本摊到多次操作,降低单次尖峰。但它不是“扩容没有成本”:迁移期间会暂时持有两张桶表,操作路径更复杂,大命令和内存分配仍可能造成延迟。
哈希冲突的直观理解仍是“多个键落到同一桶后继续比较”,但 Redis 8.8 的 entry 表示、无值字典优化、键值打包和预取逻辑已经比旧版链表结构复杂。分析当前内存时,应看对应 tag 的 dict.h/dict.c,而不是依赖十年前的结构体大小。
7. Sorted Set 为什么同时需要字典和跳表?
Sorted Set(ZSet)要同时支持两类能力:
1 | 按 member 快速找到 score |
单靠哈希表擅长第一件事,却不能高效维护顺序;单靠跳表擅长顺序访问,按 member 精确定位又不如哈希直接。因此通用 skiplist 编码会组合哈希查找与有序结构。
跳表(skip list)可以看成带多级“快车道”的有序链表:
1 | level 3: head ───────────────────────→ 40 |
节点层数随机生成。查找从最高层前进,越过大段区间后逐层下降,平均查找、插入和删除复杂度为 O(log N),但概率结构的最坏情况可以退化。分数相同时,Redis 还要按 member 的字典序确定稳定顺序。
小 ZSet 通常先用 listpack,把 member 和 score 紧凑放在连续内存里。此时线性扫描对少量元素可能比维护大量指针更划算;超过配置阈值后再转换为 skiplist 编码。这里再次体现:复杂度和内存不能脱离元素规模讨论。
8. “队列”是 Redis 的一种底层结构吗?
不是。队列是使用数据类型实现的业务语义,常见选择包括:
| 需求 | 常见命令/类型 | 关键边界 |
|---|---|---|
| 简单 FIFO | LPUSH + BRPOP |
消费者 pop 后崩溃,任务可能丢失 |
| 处理中队列 | BLMOVE 从 pending 移到 processing |
需要显式确认和超时恢复逻辑 |
| 消费组、待确认和重放 | Redis Streams + consumer group | 语义更完整,但运维和清理更复杂 |
| 按优先级取任务 | Sorted Set | 需要处理并发领取的原子性 |
因此,“Redis List 是链表,所以天然是可靠消息队列”是错误推论。底层结构只能解释操作成本,消息是否会重复、丢失、超时重投和确认,取决于命令组合与故障协议。
在 Cluster 中,涉及两个键的移动命令还要满足 hash slot 约束。设计 pending/processing 键时应提前规划 hash tag,而不是上线后才发现跨槽命令失败。
9. 从结构知识走向工程实践:真正要监控什么?
9.1 不要只看 key 数量
一百万个短 String、一百万个只有一个字段的 Hash、一个包含一百万成员的 ZSet,内存和延迟形态完全不同。容量评估至少同时看:
MEMORY USAGE key的样本分布;- 逻辑类型与
OBJECT ENCODING; - 元素数量与单元素大小;
- allocator 碎片和实例总体
INFO memory; - RDB/AOF、复制和 fork 期间的额外内存峰值。
9.2 编码阈值是全局权衡,不是免费的省内存开关
提高 listpack 阈值可能减少指针开销,却会让更大的紧凑结构承担线性扫描、插入搬移和编解码成本。降低阈值可能更早获得通用结构的查找性能,却增加内存。修改 hash-max-listpack-*、zset-max-listpack-*、set-max-* 或 list/quicklist 配置前,要用真实对象分布同时测内存、吞吐和 p99。
9.3 大 key 的问题不只是在内存
一个超大 List、Hash 或 ZSet 会让删除、遍历、序列化、复制、持久化和迁移都更昂贵。即使命令文档给出 O(N),N 到底是 100 还是 100 万决定了线上影响。应限制单 key 元素数量和单元素大小,使用渐进遍历命令,并避免在延迟敏感路径执行全量返回。
9.4 诊断命令也有成本
OBJECT ENCODING 是 O(1),适合定位单键;MEMORY USAGE 对复杂类型可能采样估算,精度和成本受 SAMPLES 参数影响。全库扫描、逐键统计和大范围 DEBUG 命令不能直接在生产高峰运行,应先制定采样和限速方案。
10. 常见误区
10.1 误区:Redis 的 String 就是 C 字符串
错误在于把终止 NUL 当作真实长度。SDS 以显式长度支持内嵌 NUL,结尾 NUL 只是兼容手段。正确理解是“带长度和容量元数据的二进制字符串”。
10.2 误区:Redis List 的每个元素都是一个链表节点
这是旧版 linkedlist 编码留下的印象。现代 List 使用 quicklist,节点内通常是 listpack;命令呈现链表式复杂度,不等于内存里每个元素都有前后指针。
10.3 误区:Hash 一直是哈希表,查找永远完全相同
小 Hash 可能是 listpack,超过阈值后才转 hashtable。两者都满足 HGET/HSET 语义,但内存布局和常数不同。正确做法是通过版本、配置和 OBJECT ENCODING 验证。
10.4 误区:渐进式 rehash 让扩容没有延迟影响
渐进迁移只是在多次操作间摊销成本。迁移期双表内存、额外查找、分配和大命令依然存在。正确结论是“降低单次全量迁移尖峰”,不是“零成本扩容”。
10.5 误区:紧凑编码一定更快
连续内存通常局部性更好、内存更省,但在元素增多时线性扫描和搬移成本会上升。Redis 设置转换阈值正是因为不存在对所有规模都最优的单一结构。
10.6 误区:记住默认阈值就能预测线上编码
默认值会随版本变化,运维也可能修改配置。正确排查顺序是:查版本 → 查配置 → 查对象编码 → 查实际内存和延迟,而不是从记忆中的数字倒推。
11. 什么时候值得研究内部结构?
当你在做容量规划、排查某类 key 内存异常、解释 p99 台阶、设计大集合拆分、评估配置阈值或阅读 Redis 源码时,内部编码非常有价值。它能帮助你建立“数据分布 → 编码 → 操作成本”的证据链。
如果只是选择业务 API,优先依据命令语义、复杂度、原子性和故障模型。不要为了维持某个内部编码而写脆弱业务逻辑,也不要把 OBJECT ENCODING 结果持久化为协议字段。Redis 升级可以改变内部实现,只要对外语义保持兼容。
12. 总结:同一个命令背后可能是两套成本模型
回到开头,长 bio 写入后内存突然变大,并不一定是泄漏;它可能让 Hash 从紧凑 listpack 转成了通用哈希表。理解这一点需要把 Redis 的逻辑类型和内部编码分开:
- SDS 通过显式长度实现二进制安全,结尾 NUL 只提供有限 C 字符串兼容。
- Redis 会在紧凑编码和通用结构之间自动转换,阈值属于版本与配置,而不是业务 API。
- List 的现代实现是 quicklist,Hash/Set 可能使用 listpack、intset 或 hashtable,ZSet 在较大规模使用 skiplist。
- 字典通过双表渐进式 rehash 摊薄迁移成本,但扩容仍有内存和延迟代价。
- 队列可靠性来自命令协议和确认机制,不由底层是链表还是 listpack 决定。
可以直接实践的一条建议是:从线上对象中选取经过脱敏的代表性样本,在同版本测试实例中记录 TYPE、OBJECT ENCODING、MEMORY USAGE、元素数量和 p99,再讨论是否调整阈值。没有数据分布的“Redis 内存优化”,很容易只是在移动成本。
13. 参考资料
- Redis 8.8.0 Releases:https://github.com/redis/redis/releases
- Redis
OBJECT ENCODING:https://redis.io/docs/latest/commands/object-encoding/ - Redis Memory Optimization:https://redis.io/docs/latest/operate/oss_and_stack/management/optimization/memory-optimization/
- Redis 8.8
sds.h:https://github.com/redis/redis/blob/8.8/src/sds.h - Redis 8.8
sds.c:https://github.com/redis/redis/blob/8.8/src/sds.c - Redis 8.8
dict.h:https://github.com/redis/redis/blob/8.8/src/dict.h - Redis 8.8
quicklist.h:https://github.com/redis/redis/blob/8.8/src/quicklist.h - Redis Data Types:https://redis.io/docs/latest/develop/data-types/