Redis 为什么要自定义 SDS?
结论:C 语言的
char*作为字符串有三个致命缺陷——取长度为 O(N)、不能存二进制、拼接易溢出。Redis 几乎所有 key 与大量 value 都是字符串,且需要频繁求长度、追加、截断,所以自己实现了 SDS(Simple Dynamic String,简单动态字符串):用头部记录长度与容量,在保留 C 字符串兼容性的同时做到 O(1) 取长度、二进制安全与受控扩容。
一、C 字符串 vs SDS
| 能力 | C 字符串 char* | SDS |
|---|---|---|
| 取长度 | O(N),遍历到 \0 | O(1),读头部 len |
| 二进制安全 | 否,遇 \0 截断 | 是,靠 len 界定内容 |
| 拼接 | strcat 需手动确保容量,易溢出 | 内部检查 alloc,自动扩容 |
| 内存重分配次数 | 每次追加都可能 realloc | 预分配 + 惰性释放,摊还 O(1) |
| 兼容 libc | 原生 | 末尾仍补 \0,可传给 printf 等 |
| 存储额外字段 | 无 | len、alloc、flags 共 3~9 字节 |
二、结构设计与五种头部
sdshdr8(len/alloc 均不超过 255 时使用)
┌───────┬───────┬───────┬──────────────────────────┐
│ flags │ len │ alloc │ buf[] │
│ 1 Byte│ 1 Byte│ 1 Byte│ "hello\0" + 未用空间 │
└───────┴───────┴───────┴──────────────────────────┘
│
len = 已使用字节数(不含结尾 \0)
alloc = 已分配容量(不含头部与结尾 \0),alloc - len 即剩余空间
flags = 低 3 位标识类型:5/8/16/32/64,高 5 位保留
| 类型 | 头部大小 | 适用长度 | 说明 |
|---|---|---|---|
| sdshdr5 | 1 字节 | < 32 | 只存长度的高 5 位,不记录 alloc,只读场景 |
| sdshdr8 | 3 字节 | < 256 | 最常用,短 key 与短 value |
| sdshdr16 | 5 字节 | < 64 KB | 中等长度 |
| sdshdr32 | 9 字节 | < 4 GB | 大 value |
| sdshdr64 | 17 字节 | 更大 | 极少使用 |
按长度分档的目的是省头部开销:一个 10 字节的 key 若用 17 字节头部,浪费比内容还多。
三、扩容策略:预分配与惰性释放
追加(append)时空间不足:
newlen < 1 MB → 分配 2 * newlen(翻倍,摊还 O(1))
newlen >= 1 MB → 分配 newlen + 1 MB(避免大字符串翻倍浪费)
缩短(trim)时不立即 free:
保留一部分 free 空间,后续 append 可直接复用
真正释放由 sdsRemoveFreeSpace 触发(如 OBJECT ENCODING 变更、保存 RDB 前)
// 简化示意:Redis 内部的 sdsMakeRoomFor 逻辑
sds sdsMakeRoomFor(sds s, size_t addlen) {
size_t avail = s->alloc - s->len;
if (avail >= addlen) return s; // 空间够,直接复用
size_t newlen = s->len + addlen;
if (newlen < SDS_MAX_PREALLOC) // 1 MB
newlen *= 2; // 翻倍
else
newlen += SDS_MAX_PREALLOC; // 加 1 MB
return sdsResize(s, newlen); // 可能更换 header 类型
}
四、与对象编码的关系
SET一个短字符串时,Redis 用 embstr:一次 malloc 把redisObject与 SDS 连续分配,只读、省内存、缓存友好;- 长度超过 44 字节或执行
APPEND后变为 raw:对象头与 SDS 分开分配,可扩容; - 整数值用 int 编码,直接把值存在
ptr指针位里,连 SDS 都不需要; - 三种编码可通过
OBJECT ENCODING观察,且 raw 不会退回 embstr。
五、常见追问
- 问:SDS 一定比 C 字符串省内存吗?答:不一定,短字符串反而多几个字节头部;但换来 O(1) 长度、二进制安全和免去频繁 realloc,整体更划算。
- 问:为什么 embstr 的阈值是 44 字节?答:
redisObject(16) +sdshdr8(3) + 内容 +\0(1) 要落进 jemalloc 的 64 字节分配块,44 = 64 - 16 - 3 - 1。 - 问:SDS 如何保证二进制安全?答:所有读写都以
len为准,不依赖\0;因此可以存图片、Protobuf、含\0的序列化数据。 - 问:SDS 扩容会不会阻塞?答:单次 realloc 在大 value 上可能耗时;可通过控制单 value 大小、避免超大
APPEND来规避,必要时用UNLINK异步释放。 - 问:为什么 SDS 末尾还要补
\0?答:为了兼容 C 标准库函数(如printf("%s")、strlen),属于"多花 1 字节换兼容"的设计。 - 问:
STRLEN为什么快?答:直接读 SDS 头部的len,与字符串长度无关,O(1)。
小结
- 自定义 SDS 解决的是"C 字符串不适合做通用 KV 载体"这个根本问题:长度、二进制、扩容。
- 设计精髓是"用头部换操作复杂度":多存 3~9 字节,换来 O(1) 长度与摊还 O(1) 追加。
- 与大 value 有关的性能问题,追溯到底层往往就是 SDS 的扩容与拷贝。