0%

字符串实现

Redis 字符串实现详解:SDS 与编码机制(基于 6.0.10 版本)

Redis 中的字符串并非直接使用 C 语言的原生字符串(以 \0 结尾的字符数组),而是通过SDS(Simple Dynamic String,简单动态字符串) 实现。SDS 解决了 C 字符串的诸多缺陷(如长度获取低效、缓冲区溢出风险等),并结合灵活的编码机制,在性能和内存效率上进行了极致优化。本文结合源码结构,详解 SDS 的设计与字符串对象的编码策略。

SDS 的设计动机:为何不使用 C 字符串?

C 语言原生字符串(char*)存在以下缺陷,无法满足 Redis 高性能、高可靠性的需求:

  1. 长度获取低效:需遍历整个字符串(strlen 函数,O (N) 时间复杂度)。
  2. 缓冲区溢出风险:修改字符串时(如 strcat),若未提前分配足够空间,会覆盖相邻内存。
  3. 不支持二进制安全:以 \0 作为结束标志,无法存储包含 \0 的二进制数据(如图片、音频)。
  4. 修改效率低:每次修改都需重新分配内存(扩展或收缩),频繁操作会导致性能损耗。

SDS 针对这些问题进行了优化,核心目标是:高效获取长度、杜绝缓冲区溢出、支持二进制安全、减少内存重分配次数

SDS 的结构体设计

SDS 提供了多种结构体(sdshdr5sdshdr64),根据字符串长度选择合适的类型,避免内存浪费。源码结构如下(简化自 sds.h):

1. 核心结构体(以 sdshdr8 为例)

1
2
3
4
5
6
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已使用长度(字符串实际字节数,不包含结尾的\0)
uint8_t alloc; // 总分配空间(不包含头部和结尾\0,= len + 未使用空间)
unsigned char flags; // 类型标记(低3位表示结构体类型,如 SDS_TYPE_8)
char buf[]; // 柔性数组,存储字符串数据(含结尾\0,确保兼容C函数)
};
字段详解:
  • len:记录字符串的实际长度(字节数),获取长度时直接返回 len,时间复杂度 O (1)(解决 C 字符串的低效问题)。
  • alloc:记录总分配的空间(不包含头部和结尾 \0),alloc - len 即为未使用的预留空间,用于减少修改时的内存重分配(见下文 “预分配策略”)。
  • flags:低 3 位表示 SDS 类型(SDS_TYPE_5SDS_TYPE_64),用于区分不同结构体(根据字符串长度自动选择)。
  • buf:存储字符串的字符数组,结尾包含 \0(兼容 C 语言字符串函数,如 printf),但内容由 len 控制(支持二进制安全,即使包含 \0 也能正确处理)。

2. 不同 SDS 类型的区别

Redis 定义了 5 种 SDS 结构体,根据字符串长度选择对应的类型,最小化内存占用:

结构体 len 类型 最大长度(字节) 适用场景
sdshdr5 5 位(嵌在 flags 中) 31 极少使用(Redis 源码注释提到暂不启用)
sdshdr8 uint8_t 255 短字符串(≤255 字节)
sdshdr16 uint16_t 65535 中长字符串(≤65535 字节)
sdshdr32 uint32_t 4294967295 长字符串(≤4GB)
sdshdr64 uint64_t 极大(理论上无上限) 超长字符串

自动类型转换:当字符串长度超过当前结构体的 len 上限时,SDS 会自动升级为更大的类型(如 sdshdr8 升级为 sdshdr16),确保能容纳新长度。

SDS 的核心特性与优势

1. 高效获取长度

通过 len 字段直接获取长度,无需遍历,时间复杂度 O (1)。例如:

1
2
3
4
5
6
7
8
9
// 获取SDS长度(Redis源码函数)
static inline size_t sdslen(const sds s) {
unsigned char flags = s[-1]; // flags在buf前一个字节
switch(flags & SDS_TYPE_MASK) {
case SDS_TYPE_8: return SDS_HDR(8,s)->len;
case SDS_TYPE_16: return SDS_HDR(16,s)->len;
// ... 其他类型
}
}

2. 杜绝缓冲区溢出

修改字符串前,SDS 会先检查 alloc - len 是否足够容纳新内容:

  • 若足够,直接修改 buf 并更新 len
  • 若不足,自动扩展内存(调用 sdsMakeRoomFor 函数),再执行修改。
    彻底避免了 C 字符串中因空间不足导致的缓冲区溢出。

3. 二进制安全

SDS 以 len 而非 \0 标记字符串结束,因此可以存储任意二进制数据(如包含 \0 的图片数据)。例如:

  • 存储字符串 “a\0b”(含 \0)时,len=3buf 实际为 {'a', '\0', 'b', '\0'}sdslen 返回 3,正确识别完整长度。

4. 减少内存重分配

SDS 通过预分配空间惰性释放优化内存操作:

  • 预分配空间:扩展字符串时,若新长度 new_len ≤ 1MB,则分配 new_len * 2 的空间(预留一倍);若 > 1MB,则额外分配 1MB 空间。减少后续修改的重分配次数。
  • 惰性释放:缩短字符串时,不立即回收多余空间,而是更新 len,预留空间供后续扩展使用(可通过 sdsRemoveFreeSpace 手动释放)。

Redis 字符串对象的编码机制

Redis 中的字符串是 “对象(robj)”,不仅包含 SDS 数据,还包含编码信息(encoding)。根据字符串内容,自动选择三种编码方式,平衡性能与内存:

编码方式 适用场景 存储结构
OBJ_ENCODING_INT 字符串可表示为 64 位整数(如 “123”) 直接存储整数(robj->ptr 指向整数),无 SDS 结构
OBJ_ENCODING_EMBSTR 短字符串(长度 ≤ 44 字节,非整数) robjsdshdr8 连续存储(一次内存分配)
OBJ_ENCODING_RAW 长字符串(长度 > 44 字节,非整数) robjsdshdr 分开存储(两次内存分配)

1. OBJ_ENCODING_INT(整数编码)

  • 触发条件:字符串是纯整数(如 “123456”),且可被 64 位整数表示。
  • 优势:直接以整数形式存储(robj->ptr 指向整数),无需 SDS 结构,节省内存(robj 本身仅占 16 字节)。
  • 转换:当整数被修改为非整数(如 “123abc”),会转为 EMBSTRRAW 编码。

2. OBJ_ENCODING_EMBSTR(嵌入式编码)

  • 触发条件:非整数字符串,长度 ≤ 44 字节(Redis 6.0 中,EMBSTR_MAX_SIZE 为 44)。

  • 存储特点:通过一次内存分配,分配连续空间,依次存储robj和sdshdr8结构(包含buf)。例如:

    1
    [robj结构][sdshdr8.len][sdshdr8.alloc][sdshdr8.flags][buf...]
  • 优势:内存连续,缓存命中率高(CPU 缓存可一次性加载 robj 和 SDS 数据),创建和释放效率高(一次分配 / 释放)。

  • 限制EMBSTR 编码的字符串是 “只读” 的,修改时会先转为 RAW 编码(因为修改可能需要扩展空间,破坏连续性)。

3. OBJ_ENCODING_RAW(原始编码)

  • 触发条件:字符串长度 > 44 字节,或 EMBSTR 编码的字符串被修改(如拼接操作)。
  • 存储特点robjsdshdr 分开分配内存(两次分配),两者在内存中不连续。
  • 优势:支持任意长度的字符串,修改时无需担心连续内存的限制。

编码转换示例:

1
2
3
4
5
6
7
8
9
10
11
# 1. 整数字符串 → INT 编码
SET num "123" → 编码为 INT(存储整数 123)

# 2. 短字符串 → EMBSTR 编码
SET str "hello" → 编码为 EMBSTR(长度 5 ≤ 44)

# 3. 长字符串 → RAW 编码
SET longstr "a..."(45 个 'a')→ 编码为 RAW

# 4. EMBSTR 修改后 → 转为 RAW
APPEND str " world!..."(总长度 > 44)→ 编码转为 RAW

EMBSTRRAW 的核心区别

特性 EMBSTR 编码 RAW 编码
内存分配次数 1 次(连续空间包含 robjsdshdr 2 次(robjsdshdr 分开分配)
内存连续性 连续(缓存友好) 不连续(可能降低缓存命中率)
可修改性 只读(修改即转为 RAW 可直接修改
适用场景 短字符串、只读或极少修改的字符串 长字符串、需频繁修改的字符串

欢迎关注我的其它发布渠道