Redis 为什么要自定义 SDS?

#中间件#面试八股

Redis 为什么要自定义 SDS?

结论:C 语言的 char* 作为字符串有三个致命缺陷——取长度为 O(N)、不能存二进制、拼接易溢出。Redis 几乎所有 key 与大量 value 都是字符串,且需要频繁求长度、追加、截断,所以自己实现了 SDS(Simple Dynamic String,简单动态字符串):用头部记录长度与容量,在保留 C 字符串兼容性的同时做到 O(1) 取长度、二进制安全与受控扩容。

一、C 字符串 vs SDS

能力C 字符串 char*SDS
取长度O(N),遍历到 \0O(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 位保留
类型头部大小适用长度说明
sdshdr51 字节< 32只存长度的高 5 位,不记录 alloc,只读场景
sdshdr83 字节< 256最常用,短 key 与短 value
sdshdr165 字节< 64 KB中等长度
sdshdr329 字节< 4 GB大 value
sdshdr6417 字节更大极少使用

按长度分档的目的是省头部开销:一个 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 的扩容与拷贝。
笔记加载中…