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

358 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 在序列化时的行为