ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

Redis SDS 源码解析:动态字符串设计与内存优化实践

Redis SDS 源码解析:动态字符串设计与内存优化实践

1. 从一次内存告警说起:为什么要啃SDS这块硬骨头

几年前我在一个日志采集服务里踩过一个坑:服务跑着跑着内存缓慢上涨,重启后恢复,过几天又涨。排查了半天,最后定位到的是大量短字符串的频繁拼接和截断操作。当时我第一反应是"字符串操作能有什么问题",直到把 Redis 的 sds.c 翻出来读了一遍,才意识到 C 语言原生字符串在这类场景下有多脆弱。那次之后,我把 SDS 的源码完整过了一遍,也顺手把它的设计思路用到了自己的项目里。

这篇内容就是那次学习的完整沉淀。SDS,全称 Simple Dynamic Strings,是 Redis 自己实现的一套字符串结构,用来替代 C 语言原生的char *。它解决的问题很具体:原生 C 字符串拿不到长度、拼接容易溢出、二进制不安全、频繁修改性能差。Redis 作为一个内存数据库,键、值、AOF 缓冲区、客户端输入输出缓冲区,几乎到处都是字符串,所以它必须有一套自己的字符串实现。

这篇适合谁看?如果你写过 C,用过strlen、strcat,被\0截断坑过,那这篇能帮你把"为什么需要动态字符串"这件事彻底想明白。如果你正在读 Redis 源码,卡在sds.h那一堆typedef和宏定义上,这篇会带你从结构体布局一路走到扩容策略。如果你只是好奇一个字符串能玩出什么花样,那更有意思——SDS 里藏着不少"用空间换时间""用位运算省内存"的巧思。

我会按"结构长什么样 → 为什么这么设计 → 怎么扩容 → 怎么省内存 → 实际怎么用"的顺序讲,中间穿插我自己读源码时踩过的困惑点和实测数据。不堆术语,尽量把每个设计决策背后的"为什么"说清楚。

2. SDS 的结构体布局:一个字符串到底存了哪些东西

2.1 从 sdshdr5 到 sdshdr64 的五种头结构

第一次打开sds.h,很多人会被五个结构体搞懵:sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64。名字里的数字指的是长度字段和已分配字段用多少位来存。先看最典型的sdshdr8:

struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 当前字符串实际长度 uint8_t alloc; // 已分配的总容量(不含头和结尾的 \0) unsigned char flags; // 低 3 位标识类型,高 5 位预留 char buf[]; // 柔性数组,真正存字符的地方 };

len记录当前用了多少字节,alloc记录一共分配了多少字节,flags的低三位标记这是哪种 header,buf是柔性数组,紧跟在结构体后面。注意__attribute__ ((__packed__)),它告诉编译器不要做内存对齐填充,这样 header 就是紧凑的,不会因为对齐白白浪费几个字节。

sdshdr16、sdshdr32、sdshdr64结构完全一样,只是len和alloc的类型分别是uint16_t、uint32_t、uint64_t。这样设计的目的很直接:短字符串用小的长度类型,长字符串用大的。一个 5 字节的字符串如果统一用uint64_t存长度,光 header 就 16 字节,太浪费。

sdshdr5是个特例,它没有len和alloc两个独立字段:

struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; // 低 3 位类型,高 5 位直接存长度 char buf[]; };

因为长度小于 32 的字符串,5 个 bit 就能表示(0~31),所以它把长度塞进了flags的高 5 位,header 只有 1 字节。这是 SDS 里最省内存的一种 header,专门给超短字符串用。

2.2 为什么 buf 前面要放 flags,而不是放最后

这里有个细节值得说:flags放在buf前面,而不是结构体最后。原因是 SDS 对外暴露的永远是指向buf的指针,也就是sds类型本身。当你想知道这个字符串是哪种 header 时,只需要buf[-1]就能拿到flags,然后根据低三位判断类型,再往前偏移对应字节数就能拿到len和alloc。

#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T))); #define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))

这组宏就是干这个的。s是buf的地址,减去 header 大小就得到结构体起始地址。这种"指针往前偏移"的取头方式,是 SDS 能对外只暴露一个char *的关键。你拿到的sds可以直接当 C 字符串用(因为buf末尾永远有\0),同时又能在 O(1) 时间内反查到长度。

提示:buf末尾的\0不计入len,也不计入alloc。它的存在只是为了让 SDS 能兼容那些只认 C 字符串的函数,比如printf("%s", s)。这是 SDS "二进制安全"和"C 兼容"之间的一个折中。

2.3 五种 header 的选型规则

创建 SDS 时,到底选哪种 header?看sdsnewlen里的逻辑:

if (initlen < 1<<5) type = SDS_TYPE_5; else if (initlen < 1<<8) type = SDS_TYPE_8; else if (initlen < 1<<16) type = SDS_TYPE_16; else if (initlen < 1<<32) type = SDS_TYPE_32; else type = SDS_TYPE_64;

规则很清晰:按初始长度落在哪个区间选。1<<5是 32,1<<8是 256,1<<16是 65536,1<<32是 42 亿多。也就是说,长度小于 32 用sdshdr5,小于 256 用sdshdr8,以此类推。

这里有个容易忽略的点:sdshdr5是只读友好的,一旦需要修改(追加、扩容),通常会升级成sdshdr8。因为sdshdr5没有独立的alloc字段,没法记录"分配了多少但没用",扩容时无处安放这个信息。所以实际使用中,sdshdr5更多出现在那些创建后就不再变的短字符串上。

我实测过一组数据,创建 100 万个长度分别为 10、100、1000 的字符串,对比 header 开销:

字符串长度header 类型header 字节数100 万个总 header 开销
10sdshdr51约 1 MB
100sdshdr83约 3 MB
1000sdshdr165约 5 MB

如果统一用sdshdr64,header 是 17 字节,100 万个就是 17 MB。对于内存数据库来说,这十几 MB 的差距在百万级键的场景下就是实打实的成本。所以这套分级 header 不是炫技,是真的在省内存。

3. 扩容这件事:SDS 怎么做到追加字符串不炸

3.1 原生 C 字符串拼接的两个致命问题

先看原生 C 字符串怎么拼接:

char *s = "hello"; strcat(s, " world"); // 灾难

这段代码有两个问题。第一,s指向的是字符串字面量,存在只读段,写它会直接段错误。第二,就算s是malloc出来的,strcat也不知道目标缓冲区还剩多少空间,它会一路往后写,直到遇到\0,越界了也不管。这就是经典的缓冲区溢出。

要安全拼接,你得自己算:目标缓冲区总大小、当前长度、要追加的长度,判断够不够,不够就realloc,然后再strcat。每次拼接都要strlen一遍算当前长度,strlen是 O(n) 的,拼接 n 次就是 O(n²)。Redis 里键值操作极其频繁,这种复杂度完全不能接受。

SDS 的解法是:把长度和容量都记在 header 里。取长度 O(1),判断空间够不够 O(1),不够才扩容。

3.2 空间预分配:为什么扩容要"多给一点"

看sdsMakeRoomFor的核心逻辑:

if (avail >= addlen) return s; // 空间够,直接返回 newlen = (len + addlen); if (newlen < SDS_MAX_PREALLOC) // SDS_MAX_PREALLOC = 1MB newlen *= 2; // 小于 1MB,翻倍 else newlen += SDS_MAX_PREALLOC; // 大于 1MB,多给 1MB

这就是空间预分配策略。假设当前字符串长度 10,要追加 5 字节,newlen是 15,小于 1MB,所以实际分配 30 字节。多出来的 15 字节就是预留空间,下次再追加只要不超过 15 字节,就不用重新分配。

为什么是翻倍而不是刚好够?因为翻倍能让追加操作的均摊复杂度降到 O(1)。你可以这样理解:每次扩容后容量至少翻倍,那么从长度 1 涨到 n,最多扩容 log n 次,总拷贝量是 1+2+4+...+n ≈ 2n,均摊到每次追加就是 O(1)。如果每次只多分配刚好够的量,那每次追加都要重新分配和拷贝,复杂度退化成 O(n²)。

为什么超过 1MB 就改成"多给 1MB"而不是继续翻倍?因为大字符串翻倍的代价太高。一个 100MB 的字符串翻倍就是 200MB,内存瞬间翻倍,可能直接触发 OOM。改成线性增长 1MB,既保证了后续追加有缓冲,又不会让内存占用爆炸。这个 1MB 的阈值是经验值,在"减少分配次数"和"控制内存峰值"之间取的平衡。

3.3 惰性释放:缩短字符串时为什么不立刻还内存

有预分配就有对应的"惰性释放"。当你调用sdstrim或sdsrange缩短字符串时,SDS 不会立刻realloc缩小内存,只是把len改小,alloc保持不变。腾出来的空间留着,下次追加可以直接用。

void sdsrange(sds s, ssize_t start, ssize_t end) { // ... 计算新长度 if (newlen != 0) { // 移动数据 s[newlen] = '\0'; sdshdr->len = newlen; // 注意:没有 realloc 缩小 alloc } }

这个策略的取舍很明确:用一点内存冗余换后续操作的性能。如果你的场景是频繁地"截断再追加",惰性释放能省掉大量realloc调用。但如果你的场景是"创建一个大字符串,截断后长期不用",那这部分内存就一直占着。Redis 提供了sdsRemoveFreeSpace来手动触发真正的缩容,在需要的时候可以主动调用。

注意:惰性释放是 SDS 的一个"隐藏行为"。如果你在业务代码里用了类似 SDS 的结构,缩短后内存没降下来,别急着怀疑内存泄漏,先看看是不是预留空间没释放。

3.4 扩容时的 header 类型升级

扩容还有一个容易忽略的细节:长度增长可能跨越 header 类型的边界。比如一个sdshdr8的字符串,长度从 200 涨到 300,超过了uint8_t能表示的范围(255),这时候必须升级成sdshdr16。

static inline char sdsReqType(size_t string_size) { if (string_size < 1<<5) return SDS_TYPE_5; if (string_size < 1<<8) return SDS_TYPE_8; if (string_size < 1<<16) return SDS_TYPE_16; // ... }

升级过程不是原地改类型那么简单,因为 header 大小变了,buf的位置也要跟着移动。sdsMakeRoomFor里会重新计算type,如果类型变了,就malloc一块新内存,把旧数据拷过去,再free旧的。如果类型没变,才用realloc原地扩展。

这个设计保证了内存布局始终紧凑,但代价是跨类型扩容时有一次完整的内存拷贝。实际使用中,字符串长度跨越 256、65536 这些边界的频率不高,所以这个开销可以接受。

4. 二进制安全与 C 兼容:SDS 怎么两头都占

4.1 二进制安全到底是什么意思

"二进制安全"这个词听起来玄乎,其实就一句话:字符串里可以出现\0,长度以len为准,不以\0为准。

原生 C 字符串用\0标记结尾,所以"a\0b"在 C 眼里就是"a",后面的b被截断了。但 Redis 里存的东西可能是图片、序列化后的对象、压缩数据,这些内容里出现\0太正常了。如果还用\0判断结尾,数据就废了。

SDS 的len字段解决了这个问题。sdslen(s)直接返回 header 里的len,跟内容里有没有\0完全无关。所以 SDS 可以安全地存任意二进制数据。

sds s = sdsnewlen("a\0b", 3); printf("%zu\n", sdslen(s)); // 输出 3,不是 1

4.2 为什么 buf 末尾还要留一个 \0

既然二进制安全了,为什么buf末尾还要多分配一个字节放\0?这是为了兼容 C 标准库函数。

Redis 里有些地方需要把 SDS 传给只认 C 字符串的函数,比如日志打印、strcasecmp比较。如果buf末尾没有\0,这些函数会一路读越界。所以 SDS 在每次创建和修改后,都会保证buf[len] = '\0'。

这个\0不占len,也不占alloc,是额外的一个字节。sdsnewlen里分配的是hdrlen + initlen + 1,那个+1就是给\0留的。

sh = s_malloc(hdrlen + initlen + 1); // ... if (initlen && init) memcpy(s, init, initlen); s[initlen] = '\0';

这个设计是"二进制安全"和"C 兼容"的折中:内部逻辑全用len,对外暴露时末尾有\0,两边都不耽误。

4.3 一个容易踩的坑:sdslen 和 strlen 混用

读源码时我见过有人这么写:

sds s = sdsnew("hello"); size_t n = strlen(s); // 错误用法

strlen(s)在字符串不含\0时结果和sdslen(s)一样,但一旦字符串里含\0,strlen就会提前返回,结果偏小。更危险的是,如果字符串内容恰好没有\0且末尾的\0被覆盖了(比如手动改过buf),strlen会越界读。

正确做法永远是sdslen(s)。SDS 提供了一整套 API:sdslen、sdsavail、sdsalloc,分别取长度、可用空间、已分配空间。这些宏直接读 header,O(1) 且安全。

#define sdslen(s) (SDS_HDR_VAR(...)->len) #define sdsavail(s) (SDS_HDR_VAR(...)->alloc - SDS_HDR_VAR(...)->len)

提示:sdsavail返回的是"已分配但未使用"的字节数。判断追加会不会触发扩容,看sdsavail(s) >= addlen就够了,不用自己算。

5. 那些藏在宏里的性能细节

5.1 用位运算代替除法取类型

SDS 里判断 header 类型用的是位移,不是除法:

#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_MASK 7 #define SDS_TYPE_BITS 3

flags & SDS_TYPE_MASK取出低三位就是类型。为什么用& 7而不是% 8?因为位运算比取模快,而且7是2^3 - 1,& 7等价于% 8但省去了除法指令。这种细节在热路径上累积起来就是性能差距。

sdshdr5里长度存在flags >> 3,也是同样的思路:高 5 位存长度,右移 3 位取出来。

#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS)

5.2 柔性数组 vs 指针:为什么不用 char *buf

有人会问,为什么buf用柔性数组char buf[]而不是指针char *buf?区别在于内存布局。

如果用指针,结构体里存的是指针(8 字节),buf指向另一块堆内存。这样 header 和内容分离,需要两次内存分配,而且指针本身占 8 字节。用柔性数组,header 和内容在同一块连续内存里,一次分配搞定,指针那 8 字节也省了。

对于 SDS 这种"每个字符串都要带 header"的结构,省 8 字节乘以百万级数量就是 8MB。而且连续内存对 CPU 缓存更友好,访问buf时 header 大概率已经在缓存里了。

5.3 内联函数与宏的取舍

SDS 里大量用了static inline函数,比如sdslen在旧版本是宏,新版本改成了内联函数:

static inline size_t sdslen(const sds s) { unsigned char flags = s[-1]; 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; // ... } return 0; }

宏和内联函数功能上差不多,但内联函数有类型检查,调试时能打断点,而且不会像宏那样有参数多次求值的副作用。代价是内联函数在-O0下可能不内联,有函数调用开销。Redis 默认用-O2,内联函数会被展开,性能和宏一样。

这个改动反映了一个趋势:现代 C 项目更倾向于用内联函数替代宏,可读性和安全性更好,性能损失在优化编译下可以忽略。

6. 把 SDS 思路用到自己的项目里

6.1 什么时候该自己实现一套动态字符串

不是所有项目都需要 SDS。如果你的场景是"字符串创建后基本不改",那直接用 C 字符串或者std::string(C++)就够了。SDS 的价值在频繁修改 + 内存敏感 + 需要二进制安全这三个条件同时成立时。

我自己的判断标准是:如果一个字符串在生命周期内会被追加、截断超过 3 次,或者需要存二进制数据,或者数量级在十万以上且对内存敏感,那就值得考虑类似 SDS 的结构。否则引入一套自定义字符串反而增加维护成本。

6.2 移植 SDS 时最容易忽略的三件事

第一,header 的对齐问题。SDS 用了__attribute__ ((__packed__)),这是 GCC/Clang 的扩展。如果你在 MSVC 上编译,得用#pragma pack(1)。忘了这个,header 会因为对齐填充变大,省内存的初衷就打折扣了。

第二,realloc失败的处理。SDS 里s_realloc失败会返回 NULL,调用方必须检查。我见过有人移植时直接忽略了返回值,结果内存不足时程序崩溃。正确做法是:

newsh = s_realloc(sh, newlen); if (newsh == NULL) return NULL;

第三,sdshdr5的只读特性。前面说过sdshdr5没有alloc字段,扩容时会升级成sdshdr8。移植时如果没处理这个升级逻辑,追加操作会写坏内存。判断逻辑是:如果当前是sdshdr5且需要扩容,强制用sdshdr8重新分配。

6.3 一个实测的性能对比

我在本地做过一组对比测试,模拟日志采集场景:不断追加短字符串,然后定期截断。对比原生 C 字符串(每次手动算长度 + realloc)和 SDS 风格实现:

操作规模原生 C 字符串耗时SDS 风格耗时内存峰值对比
100 万次追加约 1.8 秒约 0.4 秒原生略低
100 万次追加+截断约 3.2 秒约 0.6 秒SDS 高约 15%
含 \0 的二进制数据数据损坏正常-

结论很清晰:SDS 用略高的内存占用换来了数倍的性能提升,而且二进制安全是原生字符串给不了的。那 15% 的内存冗余就是空间预分配和惰性释放的代价,在大多数场景下完全值得。

6.4 读源码的方法建议

最后分享一点读 SDS 源码的经验。别一上来就从头读到尾,那样很容易在宏定义里迷路。我的顺序是:先看sds.h里的结构体定义,把五种 header 的字段搞清楚;然后看sdsnewlen,理解创建流程和类型选择;接着看sdsMakeRoomFor,这是扩容的核心;最后看sdscatlen、sdscpylen这些常用 API,看它们怎么调用前面的基础设施。

读的时候手边开个调试器,在sdsnewlen打个断点,看看flags的值、buf的地址、header 的实际内存布局。光看代码容易想当然,实际跑一遍印象深得多。我当时就是靠单步调试才彻底搞明白buf[-1]取flags这个操作的。

SDS 这套设计思路,本质上是在 C 语言的约束下,用最小的内存开销实现了"带长度、可扩容、二进制安全"的字符串。它没有用任何高级语言特性,全是结构体、宏、位运算这些最基础的东西,但组合起来的效果非常扎实。这种"在底层约束下做精巧设计"的思路,比具体某个 API 怎么用更值得琢磨。

返回列表