Files
cs-note/hhs/Redis/02-核心数据类型/02-5-SDS.md
T
2026-05-26 13:52:08 +08:00

13 KiB
Raw Blame History

tags, create time, author
tags create time author
Redis
底层数据结构
SDS
字符串
2026-05-26 13:41 hhs

SDS(Simple Dynamic Strings)

概述

[!tip] Redis 为什么不用 C 语言自带的字符串? C 语言的字符串就是一个以 \0 结尾的 char 数组——简单,但在 Redis 这种高性能场景下,有一堆致命缺陷:求长度要 O(N) 遍历、二进制数据不能存、拼接可能缓冲区溢出……

SDS 就是 Redis 对这些问题的"全面回答"。它兼容 C 字符串(末尾保留 \0),但在头部加了元数据,把常见的痛点全抹掉了。

SDS 是 Redis 中最基础、使用最广泛的数据结构——不仅仅是 String 类型的底层实现,所有 Redis 键(key)都是 SDS,Hash 的 field 名、AOF 缓冲区、客户端输入缓冲区……到处都有它的身影。

一、C 字符串的四大痛点

[!question] 直接用 char * 不行吗? 我们来逐个看看 C 原生字符串在 Redis 场景下的问题:

痛点 说明 SDS 怎么解决
O(N) 求长度 strlen() 必须遍历到 \0,百万字符的 value 每次求长度都线性扫描 len 字段记录长度,O(1)
二进制不安全 \0 是结束符,图片、序列化数据里出现 \0 就被截断 用 len 判断结束,\0 仅用于兼容 C 函数
缓冲区溢出 strcat() 不检查目标空间,拼接可能写越界 API 自动检查空间,不够就扩容
重复内存分配 修改字符串长度就要 realloc,N 次修改 = N 次系统调用 空间预分配 + 惰性释放

[!insight] SDS 不是"一种"结构,而是一族结构 Redis 根据字符串长度使用不同大小的 header(sdshdr5/8/16/32/64),短字符串用更小的 header,极致省内存——这是 SDS 最巧妙的设计之一。

二、SDS 的内存布局

基本结构

flowchart LR
    subgraph SDS["SDS 内存布局"]
        direction LR
        H["header: len + alloc + flags"]
        B["buf[]: 实际字符串数据, 末尾保留 '\\0'"]
        H --> B
    end

    classDef header fill:#e1f5fe,stroke:#2196f3
    classDef buf fill:#fff3e0,stroke:#ff9800
    class H header
    class B buf
// 以 sdshdr8 为例(适用于字符串长度 < 256 字节的情况)
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;       // 已使用的字节数(不含末尾 '\0')
    uint8_t alloc;     // 分配的总字节数(不含末尾 '\0' 和 header)
    unsigned char flags; // 类型标志,低 3 位标识 sdshdr 类型
    char buf[];        // 柔性数组,实际数据从这里开始
};

[!question] 为什么 buf[] 末尾要保留 \0? 为了兼容 C 标准库函数。SDS 可以直接把 buf 传给 printf、strcmp 等函数,不需要额外拷贝或转换。这是一个务实的工程决策——既能用 C 生态的库,又不受 C 字符串的限制。

[!tip] __attribute__((packed)) 的作用 编译器默认会对结构体做内存对齐(padding),packed 告诉编译器"不要加任何填充字节"。SDS header 可能只有 3~5 字节,对齐填充会浪费大量空间。

五种 sdshdr 类型

flowchart TB
    S["SDS 字符串"] --> Q{"字符串长度?"}
    Q -->|"0 ~ 31"| T5["sdshdr5, flags 1B, 无 len/alloc 字段"]
    Q -->|"0 ~ 255"| T8["sdshdr8, 3B header, len + alloc 各 1B"]
    Q -->|"0 ~ 65535"| T16["sdshdr16, 4B header, len + alloc 各 2B"]
    Q -->|"0 ~ 4294967295"| T32["sdshdr32, 8B header, len + alloc 各 4B"]
    Q -->|"> 4GB"| T64["sdshdr64, 16B header, len + alloc 各 8B"]

    classDef tiny fill:#e8f5e9,stroke:#4caf50
    classDef small fill:#e1f5fe,stroke:#2196f3
    classDef medium fill:#fff3e0,stroke:#ff9800
    classDef large fill:#ffebee,stroke:#f44336
    classDef check fill:#f3e5f5,stroke:#9c27b0
    class T5 tiny
    class T8 small
    class T16 medium
    class T32,T64 large
    class Q check
类型 len 占用 alloc 占用 flags 占用 header 总大小 最大长度
sdshdr5 0B 0B 1B 3B 31B
sdshdr8 1B 1B 1B 3B 255B
sdshdr16 2B 2B 1B 5B 64KB
sdshdr32 4B 4B 1B 9B 4GB
sdshdr64 8B 8B 1B 17B 2^64B

[!question] sdshdr5 为什么没有 len 和 alloc 字段? 因为它只存 0~31 字节的超短字符串——长度信息直接编码在 flags 的高 5 位里,连 len 字段都省了。Redis 在"能省一个字节就省一个字节"这件事上做到了极致。

sdshdr5 的 flags 高 5 位就是长度值,低 3 位是类型标志。一个 32 字节的 header 变成 1 字节——在 Redis 动辄存上亿 key 的场景下,光这一项就能省几个 GB 内存。

[!insight] 类型选择是自动的 SDS 在创建或扩容时,会根据字符串的实际长度自动选择最小够用的 sdshdr 类型。你不需要(也不能)手动指定。缩容时不会自动降级——和 intset、ziplist 的设计哲学一致,避免在阈值附近反复翻转。

flags 字段的编码方式

flags 的低 3 位表示 sdshdr 类型(SDS_TYPE_5/8/16/32/64),高 5 位在 sdshdr5 中用于存储字符串长度。

// 从 flags 中提取类型
#define SDS_TYPE_MASK  0x07       // 低 3 位:类型
#define SDS_TYPE_5     0
#define SDS_TYPE_8     1
#define SDS_TYPE_16    2
#define SDS_TYPE_32    3
#define SDS_TYPE_64    4

#define SDS_TYPE(t)    ((t) & SDS_TYPE_MASK)

// 从 sdshdr5 的 flags 中提取长度
#define SDS_TYPE_5_LEN(f) ((f) >> 3)   // 高 5 位

三、SDS 的两大核心优化

1. 空间预分配(Pre-allocation)

[!question] 为什么每次扩容不刚好分配所需的大小? 如果字符串从 100 字节增长到 101 字节就要 realloc,那 10000 次增长就是 10000 次系统调用——每次都可能触发内存拷贝。空间预分配的思路是:一次多分点,后面少 realloc。

// SDS 空间预分配策略(伪代码)
func (s *sds) growRequired(need int) {
    if s.len + need <= s.alloc {
        return  // 剩余空间足够,直接返回
    }

    newLen := s.len + need
    if newLen < SDS_MAX_PREALLOC {  // SDS_MAX_PREALLOC = 1MB
        s.alloc = newLen * 2       // 小于 1MB:翻倍
    } else {
        s.alloc = newLen + SDS_MAX_PREALLOC  // 超过 1MB:多分 1MB
    }
    s.buf = realloc(s.buf, s.alloc + 1)  // +1 是 '\0'
}

策略总结:

字符串增长后的长度 预分配大小 示例
< 1MB 翻倍(newLen × 2) 1KB → 2KB,下次追加直接用,无需 realloc
≥ 1MB newLen + 1MB 10MB → 11MB,不会无限翻倍浪费内存

[!insight] 为什么不用纯翻倍策略? 如果一个 500MB 的字符串翻倍就是 1GB——在 Redis 这种内存寸土寸金的场景,翻倍策略的浪费不可接受。所以超过 1MB 后只多加 1MB,这是一个内存和性能的折中。

[!question] 追加操作是 SDS 最常见的"变长"操作。那缩短操作呢? 缩短操作(如 SDS 的 sdssubstr / SET 命令覆写值)会惰性回收多余空间——只修改 len,不立即 realloc 缩小。Redis 在字符串增长时预分配的"余量"这时会被利用起来,下次增长时就不需要再分配了。

但如果缩短幅度很大(len < alloc / 10),SDS 会主动释放多余空间,避免长期持有大量无用内存。

2. 惰性空间释放(Lazy Free)

SDS 缩短字符串时,不立即 realloc 释放多余空间,而是只更新 len:

// 追加后又缩短——惰性释放的典型场景
s = sdsnew("hello")     // len=5, alloc=5
s = sdscat(s, " world") // len=11, alloc=22(翻倍预分配)
sdstrim(s, " world")    // len=5, alloc=22(空间还在,但没有"浪费"——下次追加可以直接用)

[!tip] 这不是 Bug,是设计 Redis 用这种策略避免了"缩了又扩"的系统调用风暴。如果后续还会写入新数据,这块预留空间立刻就能派上用场。

四、SDS 的 API 与二进制安全

二进制安全

[!question] 什么叫"二进制安全"? C 字符串遇到 \0 就认为"字符串到此结束"。但如果你要存一张图片、一段 Protobuf 序列化数据、或者一个压缩后的 JSON——这些二进制数据里到处都是 \0。

SDS 用 len 字段(而非 \0)判断字符串边界,所以什么数据都能存。这是 Redis 能存储任意二进制 key/value 的根本原因。

// C 字符串:遇到 \0 截断
char buf[] = {'h', 'i', '\0', 'y', 'o', '\0'};
strlen(buf);           // → 2  (丢了 "yo")

// SDS:用 len 判断
sds s = sdsnewlen(buf, 6); // → 完整存入 6 个字节
sdslen(s);                  // → 6

常用 API

// 创建
sds s1 = sdsnew("hello");            // 从 C 字符串创建
sds s2 = sdsnewlen(bin, bin_len);    // 从二进制数据创建

// 拼接(自动扩容,二进制安全)
s1 = sdscat(s1, " world");           // 追加字符串
s1 = sdscatlen(s1, bin, bin_len);    // 追加二进制数据

// 求长度(O(1),不遍历)
size_t len = sdslen(s1);

// 释放
sdsfree(s1);

[!tip] sdslen() 的实现 它只是一个简单的字段读取,不涉及遍历。对比 strlen() 的 O(N) 遍历,在 Redis 这种每秒百万次访问的场景下,这个优化的影响是数量级的。

// sdslen 的实际实现(极其简洁)
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];      // flags 在 buf 前面一个字节
    switch (flags & SDS_TYPE_MASK) {
        case SDS_TYPE_5:  return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:  return SDS_HDR(8, s)->len;
        case SDS_TYPE_16: return SDS_HDR(16, s)->len;
        case SDS_TYPE_32: return SDS_HDR(32, s)->len;
        case SDS_TYPE_64: return SDS_HDR(64, s)->len;
    }
    return 0;
}

[!question] s[-1] 是什么魔法? SDS 的 sds 类型指向的是 buf[] 的起始位置(而不是 header 的起始位置)。所以 s[-1] 往回退一个字节就是 flags 字段。这种设计让 sds 指针可以直接作为普通 char * 传给任何 C 函数——零拷贝兼容。

五、SDS 在 Redis 中的无处不在

[!question] SDS 只用在 String 类型里吗? 不。SDS 是 Redis 的"通用字符串基础设施",几乎所有需要存储字符串的地方都用它。

flowchart TB
    SDS["SDS"] --> K["所有 Redis 键 (key)"]
    SDS --> SV["String 类型的值 (value)"]
    SDS --> HF["Hash / Set 的 field 名"]
    SDS --> AB["AOF 缓冲区"]
    SDS --> CB["客户端输入/输出缓冲区"]
    SDS --> LOG["Redis 日志"]

    classDef core fill:#e1f5fe,stroke:#2196f3
    classDef data fill:#fff3e0,stroke:#ff9800
    classDef infra fill:#e8f5e9,stroke:#4caf50
    class SDS core
    class K,SV,HF data
    class AB,CB,LOG infra

内存占用的估算

一个 key 的内存开销 = redisObject(16B) + SDS header + 字符串数据。

以存储键 "user:1001"(10 字节)为例:

组成部分 类型 占用
redisObject ptr 指向 SDS 16B
SDS header sdshdr8(10 字节 < 256) 3B
数据 + \0 "user:1001" 11B
合计 ~30B

[!insight] 为什么小 key 的内存效率很重要? Redis 里可能有上亿个 key。如果每个 key 浪费 10 字节,一亿个 key 就浪费 ~1GB。SDS 的多级 header 设计就是在这种场景下尽可能压缩每一字节的开销。

六、与 C 字符串的对比总结

维度 C 字符串 (char *) SDS
求长度 O(N) 遍历 O(1) 读字段
二进制安全 ❌ \0 截断 ✅ 用 len 判断
缓冲区溢出 ❌ API 不检查 ✅ 自动扩容
内存分配 每次修改都可能 realloc 预分配 + 惰性释放
兼容 C 函数 ✅ 原生 ✅ 末尾保留 \0
内存开销 无 header 3~17B header

七、总结

[!summary] SDS 的设计哲学 SDS 是一个"看起来简单、细节极其讲究"的结构。它的核心思想是:

  1. 用一点 header 空间换取所有常见操作的效率提升——len 字段让长度查询从 O(N) 变 O(1)
  2. 按需选型,极致省内存——5 种 header 类型,短字符串用 3 字节 header,长字符串用 17 字节
  3. 预分配 + 惰性释放减少系统调用——对 Redis 这种单线程模型,每次系统调用都可能引入延迟抖动
  4. 兼容但不受限于 C——保留 \0 能用 C 库,但核心逻辑不依赖它

理解 SDS,就理解了 Redis 处理字符串的底层逻辑——后面无论看 Hashtable 的 key、AOF 的缓冲区、还是客户端的输入,背后都是 SDS。

关联笔记