1936 字
10 分钟
数据结构:String 与 SDS 底层原理

一、为什么面试必考 String 和 SDS?#

在 Redis 中,String(字符串) 是最基础且最常用的数据结构,常用于缓存数据、实现计数器和管理分布式会话等场景。

面试官考察此内容,不仅是为了了解基础用法,更是为了通过底层实现——简单动态字符串(Simple Dynamic String,即 SDS),来评估候选人对内存管理和性能优化的深刻理解。

二、SDS 的核心内部结构#

传统的 C 语言字符串存在获取长度慢、容易缓冲区溢出等局限性,因此 Redis 采用 C 语言自行构建了 SDS 抽象类型。

在 Redis 3.2 版本之后,SDS 细分了 5 种不同的类型(如 sdshdr5、sdshdr8、sdshdr16 等),以最大限度节省内存。

其核心结构包含以下四个要素:

  • len(已用长度):记录当前 SDS 实际保存的字符串长度。
  • alloc(分配长度):记录当前 SDS 实际分配的内存空间总长度(不包括结构体本身和末尾的 \0)。
  • flags(标志位):占用 1 字节,低 3 位表示当前 SDS 的具体类型。
  • buf(字节数组):真正用于保存字符串内容的字节数组。

📝 补充重点:SDS 虽然不需要依靠 \0 来判断结尾,但依然会在 buf 末尾自动添加一个 \0。这是为了能直接复用部分 C 语言标准库中的字符串处理函数(例如 printf),避免重复造轮子。

三、SDS 对比传统 C 字符串的优势#

SDS 的设计通过”空间换时间”或增加少量元数据,彻底解决了 C 字符串的痛点。这是面试的高频对比考点:

特性对比传统 C 字符串 (char*)Redis SDS
获取字符串长度需遍历整个数组直到遇到 \0,时间复杂度 O(N)。直接读取 len 属性,时间复杂度 O(1)。
缓冲区溢出风险字符串拼接时,若未提前分配足够空间会导致溢出。拼接前自动检查空间,不足则自动扩容,杜绝溢出。
内存重分配次数每次修改长度都需要重新分配内存,代价高昂。采用空间预分配和惰性空间释放策略,减少重分配次数。
二进制安全只能保存文本数据,包含 \0 会被提前截断。基于 len 属性判断结尾,可安全存储图片、音频等二进制数据。

四、核心性能优化策略:内存管理#

SDS 主要通过以下两大机制来减少频繁的内存重新分配:

1. 空间预分配(Space Preallocation)#

  • 作用:优化字符串的**增长(拼接)**操作。
  • 机制:当对 SDS 进行扩容时,Redis 不仅仅分配刚好够用的空间,还会多分配一些额外的备用空间,从而极大地提升后续拼接短字符串的性能。

2. 惰性空间释放(Lazy Freeing)#

  • 作用:优化字符串的**缩短(截断)**操作。
  • 机制:当缩短 SDS 时,Redis 不会立即回收多出来的内存,而是将其保留在 alloc 中。若将来再次追加内容,可直接复用这部分闲置内存,防止频繁回收和分配。

五、Redis 3.2 的”按需分配”优化#

如果在面试中,你能在回答完 SDS 的基础优势后,主动或在引导下讲出 Redis 3.2 版本的这次底层结构优化,面试官一定会对你刮目相看。这展现了你对”极致榨干内存”这种系统级优化思维的理解。

下面我们拆解一下这些不同的 sdshdr 类型到底是怎么设计、又是怎么省内存的。

5.1 痛点:Redis 3.2 之前的”一刀切”设计#

在 Redis 3.2 之前,SDS 只有一个通用的结构体。我们假设在 64 位系统下,它大概长这样(简化版):

struct sdshdr {
unsigned int len; // 已用长度,占用 4 字节
unsigned int free; // 剩余可用长度,占用 4 字节
char buf[]; // 字符数组
};

问题在哪里?

如果你仅仅想存一个非常短的字符串,比如 "OK"(2 个字节),Redis 依然需要为你分配 4 + 4 = 8 个字节的元数据(len 和 free)。这就造成了元数据的开销比实际数据还要大的尴尬局面,对于 Redis 这种极其依赖内存的数据库来说,这是不可接受的浪费。

5.2 破局:Redis 3.2 的”按需分配”(sdshdr5 到 sdshdr64)#

为了解决这个问题,Redis 3.2 的作者决定:根据字符串的实际长度,使用不同大小的整数类型来记录长度属性。

Redis 将 SDS 细分为了 5 种类型。核心变化在于 len(已用长度)和 alloc(分配长度)的数据类型变化:

SDS 类型元数据大小数据类型 (len 和 alloc)适用字符串最大长度
sdshdr51 字节无(长度直接存在 flags 的高 5 位里)25−12^5 - 1(即 31 字节)
sdshdr83 字节uint8_t(8 位,1 字节)28−12^8 - 1(即 255 字节)
sdshdr165 字节uint16_t(16 位,2 字节)216−12^{16} - 1(即 65535 字节)
sdshdr329 字节uint32_t(32 位,4 字节)232−12^{32} - 1(约 4GB)
sdshdr6417 字节uint64_t(64 位,8 字节)264−12^{64} - 1(极大值)

优化效果显著:

现在,如果你再存一个 "OK",Redis 会自动选择 sdshdr8(实际上 sdshdr5 因为没有预分配空间的能力,在实际更新字符串时容易引发重新分配,因此 Redis 目前对短字符串默认直接使用 sdshdr8)。使用 sdshdr8 后,元数据从原本的 8 字节压缩到了 3 字节(len 1 字节 + alloc 1 字节 + flags 1 字节),大大节省了小对象的内存占用。

5.3 绝杀考点:C 语言的内存对齐与 __attribute__ ((__packed__))#

如果你只是背出了上面的几种类型,只能算及格。想要拿高分,必须提到”取消编译器的内存对齐”。

在 C/C++ 中,编译器为了让 CPU 读取内存更高效,默认会进行内存对齐(Memory Alignment)。比如一个结构体里有 char(1 字节)和 int(4 字节),编译器可能会在 char 后面填充 3 个字节的空白,让它凑够 4 的倍数。

如果在 SDS 中发生这种填充,我们好不容易通过缩小 len 数据类型省下来的内存,又被编译器填补的空白吃回去了!

因此,Redis 在定义这几个新的 SDS 结构体时,使用了一个特殊的编译器指令:__attribute__ ((__packed__))。

它的作用有两个:

  1. 极致省空间:告诉编译器,绝对不要进行任何内存对齐填充,结构体里的字段必须严丝合缝地挨在一起。
  2. 指针偏移的妙用:在 SDS 中,每次函数传递的其实是真正指向数据的 buf 指针(为了兼容 C 标准库函数)。因为取消了内存对齐,Redis 确切地知道,buf 指针往前退刚好 1 个字节(buf[-1]),就必定是 flags 字段。通过读取 flags 的低 3 位,Redis 就能立刻知道当前是哪种 sdshdr 类型,进而推算出 len 和 alloc 的位置。

💡 结合 Go 后端的思考:

在你熟悉的 Go 语言中,字符串的底层实现 StringHeader 包含一个 Data uintptr(指针)和一个 Len int(长度)。在 64 位系统下,一个 Go 字符串本身的结构体开销固定就是 16 字节。

对比之下,你会发现 Redis 为了在 C 语言层面抠这几个字节的内存,可谓是将底层优化做到了极致。这种对比思维在面试中非常加分。

数据结构:String 与 SDS 底层原理
https://shirone.mysqil.com/posts/redis/数据结构-string-与-sds-底层原理/
作者
HikariTish
发布于
2026-07-18
许可协议
CC BY-NC-SA 4.0

分享文章

生成精美分享图或复制链接,与更多人分享本文。

继续阅读

沿着主题读

基于共同的标签与分类

换条路线

从其他文章中稳定抽取