vault backup: 2026-05-26 13:52:08

This commit is contained in:
hhs
2026-05-26 13:52:08 +08:00
parent cbfa18a9e5
commit 6a12773cfe
3 changed files with 319 additions and 9 deletions
+13 -8
View File
@@ -12,7 +12,7 @@ Redis 是一个**基于内存的数据结构服务器**(data structure server
本文聚焦于一个核心问题:**Redis 的内部架构长什么样?它是怎么做到单线程还这么快的?** 本文聚焦于一个核心问题:**Redis 的内部架构长什么样?它是怎么做到单线程还这么快的?**
> [!question] 单线程 → 慢?这是直觉误区 > [!question] 单线程 → 慢?这是直觉误区
> 提到"单线程",大多数人的第一反应是"性能差"。但 Redis 单实例可达 **10 万+ QPS**,延迟在微秒级。为什么?因为它的瓶颈从来不在 CPU,而在**网络 IO 和内存**。单线程反而省去了锁竞争和上下文切换的开销——这是 Redis 架构设计中最反直觉、也最关键的一个洞察。 > 提到"单线程",大多数人的第一反应是"性能差"。但 Redis 单实例可达 **10 万+ QPS**,延迟在微秒级。为什么?因为对于常规 KV 操作,瓶颈通常不在 CPU,而在**网络 IO 和内存**。单线程执行命令反而省去了锁竞争和上下文切换的开销——这是 Redis 架构设计中最反直觉、也最关键的一个洞察。
## 整体架构 ## 整体架构
@@ -59,9 +59,11 @@ flowchart TB
| **通信协议简单** | RESP(Redis Serialization Protocol)结构极简,解析无状态、无复杂序列化,开销远低于 JSON/XML 类协议 | | **通信协议简单** | RESP(Redis Serialization Protocol)结构极简,解析无状态、无复杂序列化,开销远低于 JSON/XML 类协议 |
> [!question] 那为什么 MySQL 不用单线程? > [!question] 那为什么 MySQL 不用单线程?
> 因为瓶颈不同。MySQL 的查询可能涉及磁盘 IO(随机读写)、复杂 SQL 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会让 CPU 大量空转等待 IO。而 Redis 操作都在内存中完成,单个命令执行时间在亚微秒级,CPU 根本不会闲着。 > 因为瓶颈不同。MySQL 的查询可能涉及**磁盘 IO**(随机读写)、复杂 SQL 解析、事务锁管理——这些操作耗时从微秒到毫秒不等,单线程会被磁盘 IO 阻塞,无法处理其他请求。多线程能让一个线程等磁盘时,其他线程继续处理请求。
> >
> 简单记:**瓶颈在 IO → 多线程有意义;瓶颈不在 IO → 单线程更简单高效。** > Redis 也有 IO——网络 IO(与客户端之间的 socket 读写),但 IO 多路复用(epoll)已经用**单线程 + 事件驱动**高效地解决了这个问题。真正执行命令的环节是纯内存操作,耗时在亚微秒级,CPU 几乎不会空闲,所以单线程足矣。
>
> 简单记:**瓶颈在阻塞型 IO(如磁盘 IO)→ 多线程有意义,避免单线程被阻塞时无法响应其他请求;内存操作足够快、IO 已被事件驱动解决 → 单线程省去锁和上下文切换的开销,反而更高效。**(当然,Redis 6.0 仍对网络 IO 读写做了多线程优化,说明高并发下网络 IO 本身也会成为瓶颈——详见后文。)
### 单线程的局限 ### 单线程的局限
@@ -189,7 +191,7 @@ flowchart TD
> [!question] 为什么不用 HTTP? > [!question] 为什么不用 HTTP?
> HTTP 是为浏览器设计的文本协议,头部冗余大、解析复杂、无状态但需要反复握手。而 RESP 的设计目标是:**简单、无状态、可逐行流式解析**——正好匹配 Redis "命令 → 响应"的交互模型。 > HTTP 是为浏览器设计的文本协议,头部冗余大、解析复杂、无状态但需要反复握手。而 RESP 的设计目标是:**简单、无状态、可逐行流式解析**——正好匹配 Redis "命令 → 响应"的交互模型。
RESP2(Redis 5.0 及之前)有 5 种数据类型: RESP2 是 Redis 自诞生以来一直使用的默认协议(Redis 6.0+ 仍默认使用 RESP2,客户端需通过 `HELLO` 命令主动升级到 RESP3)。RESP2 定义了 5 种数据类型:
| 前缀 | 类型 | 示例 | 说明 | | 前缀 | 类型 | 示例 | 说明 |
|------|------|------|------| |------|------|------|------|
@@ -249,7 +251,7 @@ sequenceDiagram
> >
> 炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。 > 炒菜仍然是瓶颈,但洗菜装盘并行化后,整体吞吐量提升了。
### 开启方式 ### 开启方式(Redis 6.0+)
```conf ```conf
# redis.conf # redis.conf
@@ -257,6 +259,9 @@ io-threads 4 # IO 线程数(建议 CPU 核心数的一半,最
io-threads-do-reads yes # 是否对读操作也使用多线程(默认 no) io-threads-do-reads yes # 是否对读操作也使用多线程(默认 no)
``` ```
> [!warning] 版本要求
> `io-threads` 和 `io-threads-do-reads` 是 Redis 6.0 新增的配置项,6.0 之前的版本不支持,配置后会报错。
| 配置 | 说明 | | 配置 | 说明 |
|------|------| |------|------|
| `io-threads` | 线程数,包含主线程自身。设 4 表示 1 个主线程 + 3 个 IO 线程 | | `io-threads` | 线程数,包含主线程自身。设 4 表示 1 个主线程 + 3 个 IO 线程 |
@@ -299,7 +304,7 @@ BIO 线程池在 Redis 启动时就已创建(默认 3 个线程),各自负
> - `DEL`:在**主线程**中同步释放内存。如果 Key 是一个包含 100 万元素的 Hash,主线程会被阻塞几毫秒甚至更久 > - `DEL`:在**主线程**中同步释放内存。如果 Key 是一个包含 100 万元素的 Hash,主线程会被阻塞几毫秒甚至更久
> - `UNLINK`:仅在主线程中断开 Key 的引用(O(1)),实际内存释放交给 BIO 的 `lazyfree` 线程异步完成——主线程几乎不受影响 > - `UNLINK`:仅在主线程中断开 Key 的引用(O(1)),实际内存释放交给 BIO 的 `lazyfree` 线程异步完成——主线程几乎不受影响
> >
> 生产环境中,删除大 Key **务必使用 `UNLINK`**。 > 生产环境中,删除大 Key **务必使用 `UNLINK`**(需 Redis 4.0+,4.0 以下版本只能用 `DEL`,此时应考虑分批删除或在从库执行)。
## 连接管理 ## 连接管理
@@ -323,7 +328,7 @@ flowchart LR
| `maxclients` | 10000 | 最大同时连接数,超过后新连接被拒绝 | | `maxclients` | 10000 | 最大同时连接数,超过后新连接被拒绝 |
| `tcp-backlog` | 511 | 内核 TCP 连接队列长度,需与系统 `somaxconn` 对齐 | | `tcp-backlog` | 511 | 内核 TCP 连接队列长度,需与系统 `somaxconn` 对齐 |
| `timeout` | 0(不超时) | 客户端空闲多久后断开,0 表示不主动断开 | | `timeout` | 0(不超时) | 客户端空闲多久后断开,0 表示不主动断开 |
| `tcp-keepalive` | 300 | TCP 心跳探测间隔(秒),及时发现死连接 | | `tcp-keepalive` | 300(Redis 3.2+) | TCP 心跳探测间隔(秒),及时发现死连接;3.2 之前默认为 0(不检测),易导致连接泄漏 |
> [!warning] 连接数暴涨的隐患 > [!warning] 连接数暴涨的隐患
> 连接数过多不仅消耗内存(每个连接约占 ~1KB 结构体 + 输出缓冲区),更重要的是 **epoll 事件通知量增大**,每轮循环需要遍历更多就绪文件描述符,主线程在 IO 上的时间占比上升,直接影响命令处理吞吐量。 > 连接数过多不仅消耗内存(每个连接约占 ~1KB 结构体 + 输出缓冲区),更重要的是 **epoll 事件通知量增大**,每轮循环需要遍历更多就绪文件描述符,主线程在 IO 上的时间占比上升,直接影响命令处理吞吐量。
@@ -415,7 +420,7 @@ flowchart LR
> 最坏情况:**fork 瞬间实际可用内存需要为当前数据量的 2 倍**。如果你的 Redis 实例用了 10GB,系统剩余内存不足 10GB,fork 后可能触发 OOM Killer,导致 Redis 进程被系统杀死。 > 最坏情况:**fork 瞬间实际可用内存需要为当前数据量的 2 倍**。如果你的 Redis 实例用了 10GB,系统剩余内存不足 10GB,fork 后可能触发 OOM Killer,导致 Redis 进程被系统杀死。
> >
> 生产建议: > 生产建议:
> - `maxmemory` 设为物理内存的 **60%~70%**,预留 fork 空间 > - 设置 `maxmemory`(如 `maxmemory 8gb`),并将其控制在物理内存的 **60%~70%**,为 fork COW 预留空间
> - 监控 `latest_fork_usec` 指标,超过 1ms 说明数据量大、fork 开销高 > - 监控 `latest_fork_usec` 指标,超过 1ms 说明数据量大、fork 开销高
> - 写入密集时避免同时触发 RDB 和 AOF rewrite > - 写入密集时避免同时触发 RDB 和 AOF rewrite
+3 -1
View File
@@ -39,8 +39,9 @@ Redis 提供五种基础数据类型,用一句话总结各自的核心价值
| **intset** | 有序整数数组,纯整数集合时使用 | Set(全为整数时) | | **intset** | 有序整数数组,纯整数集合时使用 | Set(全为整数时) |
| **quicklist** | 双向链表,每个节点是一个 ziplist/listpack | List 的统一编码 | | **quicklist** | 双向链表,每个节点是一个 ziplist/listpack | List 的统一编码 |
| **skiplist** | 多层索引的有序链表,范围查询 O(logN) | ZSet(大数据量时) | | **skiplist** | 多层索引的有序链表,范围查询 O(logN) | ZSet(大数据量时) |
| **SDS** | 兼容 C 的动态字符串,O(1) 求长度、二进制安全、自动扩容 | 所有 Redis key、String 值、缓冲区等 |
> 想深入了解? [[02-核心数据类型/02-1-ziplist与listpack|ziplist 与 listpack]] · [[02-核心数据类型/02-2-skiplist|skiplist]] · [[02-核心数据类型/02-3-hashtable|hashtable]] · [[02-核心数据类型/02-4-quicklist|quicklist]] > 想深入了解? [[02-核心数据类型/02-5-SDS|SDS]] · [[02-核心数据类型/02-1-ziplist与listpack|ziplist 与 listpack]] · [[02-核心数据类型/02-2-skiplist|skiplist]] · [[02-核心数据类型/02-3-hashtable|hashtable]] · [[02-核心数据类型/02-4-quicklist|quicklist]]
我们先从最简单的 String 开始,逐个击破。 我们先从最简单的 String 开始,逐个击破。
@@ -340,6 +341,7 @@ Redis 在底层用不同的编码策略(ziplist、intset、skiplist 等)来*
## 关联笔记 ## 关联笔记
- [[02-核心数据类型/02-5-SDS]] — 底层编码:SDS 结构与空间优化策略
- [[02-核心数据类型/02-1-ziplist与listpack]] — 底层编码:ziplist 内存布局与级联更新 - [[02-核心数据类型/02-1-ziplist与listpack]] — 底层编码:ziplist 内存布局与级联更新
- [[02-核心数据类型/02-2-skiplist]] — 底层编码:skiplist 原理与 ZSet 双索引设计 - [[02-核心数据类型/02-2-skiplist]] — 底层编码:skiplist 原理与 ZSet 双索引设计
- [[02-核心数据类型/02-3-hashtable]] — 底层编码:dict 结构与渐进式 rehash - [[02-核心数据类型/02-3-hashtable]] — 底层编码:dict 结构与渐进式 rehash
+303
View File
@@ -0,0 +1,303 @@
---
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 字节的情况)
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 类型
```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 | **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` 中用于存储字符串长度。
```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 函数——**零拷贝兼容**。
## 五、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 设计就是在这种场景下尽可能压缩每一字节的开销。
## 六、与 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。
## 关联笔记
- [[hhs/Redis/02-核心数据类型]] — 五种数据类型的编码切换全景
- [[hhs/Redis/02-核心数据类型/02-3-hashtable]] — dict 中 key 使用 SDS 存储
- [[hhs/Redis/02-核心数据类型/02-1-ziplist与listpack]] — 紧凑编码中的字符串存储
- [[hhs/Redis/04-RDB持久化]] — SDS 在序列化时的行为