同样是 Redis 命令,内存为什么会突然变大?从对象编码到渐进式 rehash

下面两组命令在业务层看起来都只是“往 Hash 里增加一个字段”:

1
2
HSET user:1 name alice
HSET user:1 bio <一段很长的文本>

第一条执行后,user:1 可能使用紧凑的 listpack;第二条如果让元素大小超过当前配置阈值,Redis 会把它转换成通用哈希表。命令没有变,返回值也没有提醒“底层换结构了”,但内存占用、遍历方式和常数开销已经不同。

这正是理解 Redis 内部数据结构的实际价值:不是为了背诵某个版本的 C 结构体,而是为了回答三个工程问题:当前值为什么采用这种编码?什么操作会触发转换?转换之后对内存和延迟有什么影响?

本文以 Redis 8.8.0 源码和当前官方文档为版本快照。Redis 的内部结构不是稳定 API,旧版教材中的 sdshdr { len, free, buf[] }、ziplist 和“List 直接等于双向链表”等描述只能帮助理解历史演进,不能原样套到现代版本。实际环境必须先用 INFO serverOBJECT ENCODINGCONFIG GET 核对。

1. 为什么 Redis 类型相同,内部编码却可以不同?

客户端看到的是逻辑类型,例如 String、List、Set、Hash 和 Sorted Set;Redis 内部还会为同一逻辑类型选择一种编码(encoding)。可以把关系理解为:

1
2
3
4
数据库键空间
└── key → Redis 值对象
├── type:对外提供哪些命令语义
└── encoding:当前用什么内存结构实现

同一种 type 使用多种 encoding,是为了在“小而密”和“大而通用”之间取舍:

目标 紧凑编码 通用编码
小集合的内存效率 连续存储,指针少,缓存局部性好 节点、桶和指针开销较大
查找和更新的扩展性 常需要线性扫描或移动数据 哈希表、skiplist 等扩展性更好
单个元素很大 不适合反复移动或紧凑打包 更容易独立管理
实现复杂度 编解码和转换逻辑更多 单个操作路径更直接

因此,TYPE key 只告诉你逻辑类型,OBJECT ENCODING key 才告诉你当前内部编码。编码可能随写入自动转换,所以它是诊断信息,不应成为业务正确性依赖。

2. 怎样用一组最小命令观察编码变化?

下面实验需要一个可丢弃的本地 Redis 实例redis-cli。不要在共享或生产实例直接执行实验命令。示例只删除带 blog:encoding: 前缀的测试键,不修改全局配置。

先记录版本:

1
2
redis-server --version
redis-cli INFO server | sed -n '/redis_version/p'

创建几种小对象并查看编码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
redis-cli DEL \
blog:encoding:int blog:encoding:text blog:encoding:list \
blog:encoding:hash blog:encoding:set blog:encoding:zset

redis-cli SET blog:encoding:int 42
redis-cli SET blog:encoding:text hello
redis-cli RPUSH blog:encoding:list a b c
redis-cli HSET blog:encoding:hash name alice age 18
redis-cli SADD blog:encoding:set 1 2 3
redis-cli ZADD blog:encoding:zset 10 alice 20 bob

redis-cli OBJECT ENCODING blog:encoding:int
redis-cli OBJECT ENCODING blog:encoding:text
redis-cli OBJECT ENCODING blog:encoding:list
redis-cli OBJECT ENCODING blog:encoding:hash
redis-cli OBJECT ENCODING blog:encoding:set
redis-cli OBJECT ENCODING blog:encoding:zset

在 Redis 8.8 的常见默认配置下,可能看到:

1
2
3
4
5
6
int
embstr
quicklist
listpack
intset
listpack

这里必须写“可能”,因为编码和阈值会随版本、构建及配置变化。先查询当前 Hash 阈值,而不是背默认数字:

1
2
redis-cli CONFIG GET hash-max-listpack-entries
redis-cli CONFIG GET hash-max-listpack-value

再写入一个明显更长的值:

1
2
3
4
redis-cli HSET blog:encoding:hash oversized \
"$(python3 -c 'print("x" * 1024)')"
redis-cli OBJECT ENCODING blog:encoding:hash
redis-cli MEMORY USAGE blog:encoding:hash

如果 1024 字节超过当前 hash-max-listpack-value,编码通常会变成 hashtable。若没有变化,先看刚才查询到的配置,再选择更长的测试值;不要为了复现实验在生产环境执行 CONFIG SET

实验结束后清理:

1
2
3
redis-cli DEL \
blog:encoding:int blog:encoding:text blog:encoding:list \
blog:encoding:hash blog:encoding:set blog:encoding:zset

MEMORY USAGE 适合比较同一实例中的相对变化,但结果还包含对象头、分配器取整等开销,并受架构、分配器、版本和采样参数影响。不要把单个键的数字直接外推为整个集群容量。

3. SDS 为什么既能保存二进制,又能兼容许多 C 字符串 API?

3.1 真正的问题不是空格,而是 \0

旧笔记把 SDS 的二进制安全解释成“能记录任意位置空格”,这不准确。普通 C 字符串当然可以包含空格;真正的边界是 C 字符串把第一个 NUL 字节 \0 当作结束,而二进制数据可以在中间包含 \0

下面的 C11 示例展示两种长度语义:

1
2
3
4
5
6
7
8
9
10
11
12
#include <stdio.h>
#include <string.h>

int main(void) {
const unsigned char bytes[] = {'A', '\0', 'B', '\0'};
const size_t binary_length = 3;

printf("strlen=%zu\n", strlen((const char *)bytes));
printf("tracked_length=%zu\n", binary_length);
printf("third_byte=%c\n", bytes[2]);
return 0;
}
1
2
3
clang -std=c11 -O2 -Wall -Wextra -Wpedantic \
binary_length.c -o binary_length
./binary_length

预期输出:

1
2
3
strlen=1
tracked_length=3
third_byte=B

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
2
3
4
5
6
7
/* 示意结构:N 可以是 8、16、32 或 64 位。 */
struct sds_header_N {
unsigned_N len; /* 已使用字节数 */
unsigned_N alloc; /* buf 可用容量,不含头部和结尾 NUL */
unsigned char flags;
char buf[];
};

小字符串不需要为长度和容量各付出 64 位字段。Redis 通过 flags 识别 SDS 类型,再从 buf 指针前方找到对应头部。源码还定义了更紧凑的 type-5 布局;其结构主要用于描述布局,长度直接编码在 flags 中。

这种设计同时得到:

  • O(1) 长度查询;
  • 二进制安全;
  • 容量大于长度时可减少连续 append 的重新分配;
  • 对普通 C 字符串接口的有限兼容;
  • 小字符串更低的头部开销。

“有限兼容”很重要:任何依赖 strlenstrcpy 的 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 intembstr raw 不再是整数、字符串变长或发生修改
List quicklist 节点内的 listpack quicklist 仍是对外编码名 元素过大时节点可采用 plain 容器
Set intsetlistpack 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
2
3
4
5
6
quicklist
head tail
↓ ↓
[node] ⇄ [node] ⇄ [node] ⇄ ... ⇄ [node]
│ │ │ │
listpack listpack listpack listpack

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
2
3
4
5
6
7
/* Redis 8.8 dict 的概念示意,不是完整源码。 */
struct dict_schematic {
void *table[2];
unsigned long used[2];
signed char size_exp[2];
long rehash_index; /* -1 表示当前没有 rehash */
};

扩容开始前只有 table 0:

1
2
3
table[0]: [bucket][bucket][bucket][bucket]
table[1]: null
rehash_index = -1

扩容时分配更大的 table 1,然后逐步搬迁桶:

1
2
3
4
5
table[0]: [moved][moved][old bucket][old bucket]

rehash_index

table[1]: [new buckets........................]

在这段时间里:

  • 新增键通常进入新表;
  • 查找、更新和删除必须考虑两张表;
  • 普通字典操作和周期任务会推进一小部分迁移;
  • 全部搬完后释放旧表,新表成为 table 0。

如果一次性迁移百万级键,单次事件循环停顿可能很长。渐进式 rehash 把成本摊到多次操作,降低单次尖峰。但它不是“扩容没有成本”:迁移期间会暂时持有两张桶表,操作路径更复杂,大命令和内存分配仍可能造成延迟。

哈希冲突的直观理解仍是“多个键落到同一桶后继续比较”,但 Redis 8.8 的 entry 表示、无值字典优化、键值打包和预取逻辑已经比旧版链表结构复杂。分析当前内存时,应看对应 tag 的 dict.h/dict.c,而不是依赖十年前的结构体大小。

7. Sorted Set 为什么同时需要字典和跳表?

Sorted Set(ZSet)要同时支持两类能力:

1
2
按 member 快速找到 score
按 score 顺序做范围查询和排名

单靠哈希表擅长第一件事,却不能高效维护顺序;单靠跳表擅长顺序访问,按 member 精确定位又不如哈希直接。因此通用 skiplist 编码会组合哈希查找与有序结构。

跳表(skip list)可以看成带多级“快车道”的有序链表:

1
2
3
level 3: head ───────────────────────→ 40
level 2: head ───────→ 20 ──────────→ 40
level 1: head → 10 → 20 → 30 → 35 → 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 的逻辑类型和内部编码分开:

  1. SDS 通过显式长度实现二进制安全,结尾 NUL 只提供有限 C 字符串兼容。
  2. Redis 会在紧凑编码和通用结构之间自动转换,阈值属于版本与配置,而不是业务 API。
  3. List 的现代实现是 quicklist,Hash/Set 可能使用 listpack、intset 或 hashtable,ZSet 在较大规模使用 skiplist。
  4. 字典通过双表渐进式 rehash 摊薄迁移成本,但扩容仍有内存和延迟代价。
  5. 队列可靠性来自命令协议和确认机制,不由底层是链表还是 listpack 决定。

可以直接实践的一条建议是:从线上对象中选取经过脱敏的代表性样本,在同版本测试实例中记录 TYPEOBJECT ENCODINGMEMORY USAGE、元素数量和 p99,再讨论是否调整阈值。没有数据分布的“Redis 内存优化”,很容易只是在移动成本。

13. 参考资料