资讯动态

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

发布时间:2026/10/22 19:02:34 来源:尧图企业网站定制
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。名字里的数字指的是长度字段和已分配字段用多少位来存。先看最典型的sdshdr8struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 当前字符串实际长度 uint8_t alloc; // 已分配的总容量不含头和结尾的 \0 unsigned char flags; // 低 3 位标识类型高 5 位预留 char buf[]; // 柔性数组真正存字符的地方 };len记录当前用了多少字节alloc记录一共分配了多少字节flags的低三位标记这是哪种 headerbuf是柔性数组紧跟在结构体后面。注意__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 15) type SDS_TYPE_5; else if (initlen 18) type SDS_TYPE_8; else if (initlen 116) type SDS_TYPE_16; else if (initlen 132) type SDS_TYPE_32; else type SDS_TYPE_64;规则很清晰按初始长度落在哪个区间选。15是 3218是 256116是 65536132是 42 亿多。也就是说长度小于 32 用sdshdr5小于 256 用sdshdr8以此类推。这里有个容易忽略的点sdshdr5是只读友好的一旦需要修改追加、扩容通常会升级成sdshdr8。因为sdshdr5没有独立的alloc字段没法记录分配了多少但没用扩容时无处安放这个信息。所以实际使用中sdshdr5更多出现在那些创建后就不再变的短字符串上。我实测过一组数据创建 100 万个长度分别为 10、100、1000 的字符串对比 header 开销字符串长度header 类型header 字节数100 万个总 header 开销10sdshdr51约 1 MB100sdshdr83约 3 MB1000sdshdr165约 5 MB如果统一用sdshdr64header 是 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 次总拷贝量是 124...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 15) return SDS_TYPE_5; if (string_size 18) return SDS_TYPE_8; if (string_size 116) 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不是 14.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)一样但一旦字符串里含\0strlen就会提前返回结果偏小。更危险的是如果字符串内容恰好没有\0且末尾的\0被覆盖了比如手动改过bufstrlen会越界读。正确做法永远是sdslen(s)。SDS 提供了一整套 APIsdslen、sdsavail、sdsalloc分别取长度、可用空间、已分配空间。这些宏直接读 headerO(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 3flags 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::stringC就够了。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 怎么用更值得琢磨。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑