--- tags: [Redis, 底层数据结构, SDS, 字符串] create time: 2026-05-26 13:41 author: 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 的内存布局 ### 基本结构 ```mermaid 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 ``` ```c // 以 sdshdr8 为例(适用于字符串长度 < 256 字节的情况) // 注意:len 和 alloc 都是 unsigned,能表示 0~255 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 字节,对齐填充会浪费大量空间。 > [!note] 版本历史:这套体系从 Redis 3.2 开始 > Redis 3.2 之前只有一个统一的 `struct sdshdr`(8B header,`len` 和 `alloc` 都是 `int`),无论字符串多短都要付出 8B header 的代价。antirez 在 3.2 引入了 5 级 header + `packed` 属性,**让短字符串的内存开销直接砍掉了几个字节**。在亿级 key 的场景下,这个优化可以节省 GB 级内存。 ### 五种 sdshdr 类型 ```mermaid 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 | **1B** | 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` 中用于存储字符串长度。 ```c // 从 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**。 ```go // 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`: ```go // 追加后又缩短——惰性释放的典型场景 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 // 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 ```c // 创建 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 这种每秒百万次访问的场景下,这个优化的影响是**数量级的**。 ```c // 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 函数——**零拷贝兼容**。 ### 更多 API ```c // printf 风格格式化(避免先 snprintf 再 sdscat 的两次拷贝) s = sdscatfmt(s, "age:%i name:%s", 25, "Tom"); // 修剪:移除指定字符集中的前后字符 sdstrim(s, " \t\n"); // 去除空白 // 子串(原地截取 buf[start..end],更新 len) sdsrange(s, 1, -2); // 只保留第 1 到倒数第 2 个字节(去掉首尾) ``` ## 五、SDS 在 Redis 中的无处不在 > [!question] SDS 只用在 String 类型里吗? > 不。SDS 是 Redis 的"通用字符串基础设施",几乎所有需要存储字符串的地方都用它。 ```mermaid 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 设计就是在这种场景下尽可能压缩每一字节的开销。 > [!warning] 实际内存开销要算上 jemalloc 对齐 > Redis 使用 **jemalloc** 作为默认内存分配器,它会按 size class 对齐分配。上面理论值 30B,但 jemalloc 的最小分配粒度是 **8B**,小对象按 8/16/32/48/64B 对齐。所以这个 30B 的 SDS 实际会占用 **32B**(对齐到 32B 的 size class)。在用 `redis-cli --memory` 或 `MEMORY USAGE` 命令排查内存时要注意这点。 ## 六、SDS 与 String 编码的关系 > [!question] SDS 就是 Redis String 类型的底层实现吗? > 是,但不完全是。Redis String 类型有三种底层编码,SDS 只在其中两种中出现: ```mermaid flowchart TB CMD["SET key value"] --> CHECK{"value 是什么?"} CHECK -->|"能解析为 ≤ 20 位整数"| INT["OBJ_ENCODING_INT, 不使用 SDS"] CHECK -->|"字符串 ≤ 44 字节"| EMB["OBJ_ENCODING_EMBSTR, SDS + redisObject 连续内存"] CHECK -->|"字符串 > 44 字节"| RAW["OBJ_ENCODING_RAW, SDS + redisObject 分开分配"] classDef intC fill:#e8f5e9,stroke:#4caf50 classDef embC fill:#e1f5fe,stroke:#2196f3 classDef rawC fill:#fff3e0,stroke:#ff9800 classDef checkC fill:#f3e5f5,stroke:#9c27b0 class INT intC class EMB embC class RAW rawC class CHECK checkC ``` | 编码 | 条件 | 是否使用 SDS | 特点 | |------|------|:----------:|------| | `int` | 值能表示为 ≤ 20 位整数 | ❌ | 直接用 `redisObject` 的 `ptr` 字段存整数,零 SDS 开销 | | `embstr` | 字符串 ≤ 44 字节(Redis ≥ 3.2) | ✅ | `redisObject` + SDS header + `buf` **一次 `malloc`** 连续分配,CPU cache 友好 | | `raw` | 字符串 > 44 字节 | ✅ | `redisObject` 和 SDS **分开 `malloc`**,两次分配 | > [!tip] embstr 的阈值变化 > Redis 3.2 之前 embstr 阈值是 **39 字节**(旧 SDS header 8B)。引入 sdshdr8 后 header 缩到 3B,多出 5B 空间给数据,所以阈值提升到 **44 字节**。embstr 是只读的——一旦你 `APPEND` 或修改 embstr 编码的值,Redis 会自动升级为 `raw` 编码。 > [!insight] SDS 在 active defragmentation 中的角色 > Redis 4.0+ 支持主动碎片整理(activedefrag)。当 jemalloc 报告某个内存页碎片率高时,Redis 会对 SDS 执行 `sdsdup` ——分配新 buffer → 拷贝数据 → 释放旧 buffer,把碎片化的内存"压实"。这是 SDS 在内存优化层面的又一个重要应用场景。 ## 七、与 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 类型,最短仅 1 字节 header(sdshdr5),最长 17 字节 > 3. **预分配 + 惰性释放减少系统调用**——对 Redis 这种单线程模型,每次系统调用都可能引入延迟抖动 > 4. **兼容但不受限于 C**——保留 `\0` 能用 C 库,但核心逻辑不依赖它 > > 理解 SDS,就理解了 Redis 处理字符串的底层逻辑——后面无论看 Hashtable 的 key、AOF 的缓冲区、还是客户端的输入,背后都是 SDS。 ## 关联笔记 - [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景 - [[hhs/Redis/02-核心数据类型/02-3-hashtable]] — dict 中 key 使用 SDS 存储 - [[hhs/Redis/02-核心数据类型/02-1-ziplist与listpack]] — 紧凑编码中的字符串存储 - [[hhs/Redis/04-RDB持久化]] — SDS 在序列化时的行为