一、为什么面试必考 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) | 适用字符串最大长度 |
|---|---|---|---|
| sdshdr5 | 1 字节 | 无(长度直接存在 flags 的高 5 位里) | (即 31 字节) |
| sdshdr8 | 3 字节 | uint8_t(8 位,1 字节) | (即 255 字节) |
| sdshdr16 | 5 字节 | uint16_t(16 位,2 字节) | (即 65535 字节) |
| sdshdr32 | 9 字节 | uint32_t(32 位,4 字节) | (约 4GB) |
| sdshdr64 | 17 字节 | uint64_t(64 位,8 字节) | (极大值) |
优化效果显著:
现在,如果你再存一个 "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__))。
它的作用有两个:
- 极致省空间:告诉编译器,绝对不要进行任何内存对齐填充,结构体里的字段必须严丝合缝地挨在一起。
- 指针偏移的妙用:在 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 语言层面抠这几个字节的内存,可谓是将底层优化做到了极致。这种对比思维在面试中非常加分。
分享文章
生成精美分享图或复制链接,与更多人分享本文。
继续阅读
最后更新于 ,距今已过 89 天
部分内容可能已过时