
做Redis底层的源码阅读绕不开一个名字sds全称Simple Dynamic String简单动态字符串。我当初第一次在Redis源码里看到sdshdr这个结构体的时候第一反应是“这不就是给字符串加了个头吗有什么好专题的”但等我真正把append、扩容、内存重分配这些路径走了一遍才发现这块的设计比表面看起来要精巧得多也难怪Redis面试题里永远有它的位置。这篇文章定位是专题系列第一篇不讲Redis怎么部署、怎么调优就死磕源码里sds.c和sds.h这两个文件的核心逻辑。适合正在准备Redis面试的人也适合对C功底有自信、想看看Redis作者Antirez怎么处理内存问题的开发者。1. 为什么C字符串不够用SDS出现的直接理由C语言里字符串就是一个以\0结尾的char数组这套东西简单归简单放到Redis这种高性能服务器里会产生一连串问题。我在刚接触Redis源码时是带着“Redis为什么不直接用char *”这个疑问往下读的读完才明白这个问法本身就错了——C字符串不是一个“可选项”而是根本没法胜任Redis的工作场景。1.1 C字符串的三大痛点第一个痛点是获取长度的时间复杂度是O(n)。strlen必须从头遍历到\0才能算出长度。Redis里处处都需要知道字符串长度比如键值的长度、缓冲区的写入位置如果每次都扫一遍这个开销在QPS十万级的服务上会被无限放大。第二个痛点是缓冲区溢出风险。strcat这类函数在拼接时不会检查目标缓冲区够不够大导致溢出是C语言最有名的安全问题来源之一。Redis这种常年跑在网络边缘的服务绝不能把内存安全寄托在“程序员记得分配足够空间”上。第三个痛点是二进制不安全。C字符串以\0作为结束标志这意味着字符串里只要出现\0字符数据就被截断了。但Redis存储的value可以是任意二进制数据序列化后的对象、压缩后的图片、消息队列里的原始报文都有可能包含\0字节。1.2 Redis场景对字符串的要求Redis里字符串的读多写少并不绝对AOF缓冲、命令传播、list和hash底层节点都涉及到高频的追加和修改操作。每次修改字符串都走一次malloc和free性能会非常难看内存碎片也会迅速恶化。所以Redis需要的字符串结构至少要满足几个硬性条件O(1)取长度、追加修改时不会溢出、二进制安全、尽量减少内存重分配次数。这就是SDS出生的全部动机没有一个是拍脑袋想出来的“优化”全是场景逼出来的刚需。2. SDS结构体演进从旧版到新版的设计变化SDS不是一个简单结构体它在Redis 3.2版本经历了一次大的重构从原来单一结构变成了五档分级的结构。我建议看源码时直接看新版本的sdshdr8、sdshdr16这些但旧版的设计逻辑也要理解因为很多面试题还是基于旧版提出的。2.1 旧版sdshdr的内存布局Redis 3.2之前SDS长这样struct sdshdr { unsigned int len; // 字符串已使用长度 unsigned int free; // 未使用空间长度 char buf[]; // 柔性数组存实际字节 };拿到一个sds类型的指针它指向的是buf而不是sdshdr的起始地址。要拿到结构体头部需要往前偏移两个unsigned int的距离源码里通过(s - sizeof(struct sdshdr))或者宏定义来定位头。这个设计有个很关键的点len和free各自占4字节不管字符串本身多短头部开销固定8字节。Redis里大量字符串都是短键短值比如几字节的计数器、状态值为这点数据承担8字节的头部明显不划算。这就是新版SDS诞生的直接动力。2.2 新版五档结构Redis 3.2之后SDS按字符串长度分成五种类型分别用sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64表示struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 已使用长度 uint8_t alloc; // 分配的总长度不含头部和null终止符 unsigned char flags; // 低3位存类型标志高5位留空 char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; };sdshdr8用1字节存长度最长表示255sdshdr16用2字节最长65535sdshdr32用4字节sdshdr64用8字节。五档覆盖的容量上限不同保证短字符串的头部开销尽可能小。sdshdr5比较特殊它连len和alloc都不存长度直接放在flags的高5位里不用来存储实际数据只有长度小于32的创建请求会用到但后续一追加就会升级成sdshdr8。2.3 头部大小带来的内存节省新版设计里1字节长度的字符串头部从8字节降到了3字节len、alloc、flags各1字节。Redis里短字段特别多这个节省是实打实的。而且__packed__让结构体紧凑排列避免编译器做内存对齐填充进一步省空间。代价是访问len字段时可能出现非对齐访问在x86这类允许非对齐访问的平台上没问题在部分ARM平台会有性能损耗所以源码里特意用__attribute__((packed))声明属于用可移植性换空间收益的典型取舍。3. 源码级拆解核心操作的实现逻辑光看结构体还不够SDS的精髓全在操作函数里。我挑了几个最核心的路径逐行讲长度获取、追加、扩容、释放。这些函数都能在sds.c里直接找到建议边看源码边对照我这边的细节。3.1 O(1)获取字符串长度先看最简单的len获取。旧版用#define sdslen(s) ((sds)-1)-len这种偏移手法新版直接用头里的len字段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_BITS(flags); case SDS_TYPE_8: return SDS_HDR(8, s)-len; // ... 省略 } }s[-1]取的是buf前面的flags字节然后按类型取header。整个过程没有循环、没有扫描一次指针运算加上一次内存读取。和strlen那种必须遍历到\0的O(n)操作相比差距在整个字符串很长时非常明显。3.2 追加时的扩容算法sdscat是SDS里最常用的追加函数它的核心调用链是sdscat - sdscatlen - sdsMakeRoomFor。真正的扩容决策都在sdsMakeRoomFor里sds sdsMakeRoomFor(sds s, size_t addlen) { // 空闲空间足够就直接返回 if (free addlen) return s; size_t newlen sdslen(s) addlen; if (newlen SDS_MAX_PREALLOC) newlen * 2; else newlen SDS_MAX_PREALLOC; // 确定新header类型 type sdsReqType(newlen); // 如果新长度需要更大header类型则升级 ... }这里就是Redis里最著名的空间预分配策略追加后总长度如果小于1MB就翻倍分配如果已经大于等于1MB每次只额外分配1MB。这个算法有一个直观的调参逻辑字符串越大翻倍带来的浪费越严重固定增量可以控制浪费比例。1MB阈值不是随手定的它是“小字符串分配成本低但使用频繁”和“大字符串分配贵但使用较少”两条曲线的经验交点。扩容完成后还有一步“数据搬家”。如果header类型没变直接在原有位置把alloc和len改掉就行如果要升级到更大的header类型必须把旧buffer里的数据复制到新位置同时释放旧内存。这个复制过程决定了用户视角里字符串的“指针值”可能变所以所有SDS接口都返回新的sds指针使用上要“接收返回值”而不是用旧的指针继续操作。这是新手写代码时特别容易踩的坑。3.3 类型升级机制类型升级的入口是sdscatlen里对oldlen和newlen的比较触发sdsnewlen重新分配底层核心是sdsalloc选择新headerstatic inline char sdsReqType(size_t string_size) { if (string_size 18) return SDS_TYPE_8; if (string_size 116) return SDS_TYPE_16; if (string_size 132) return SDS_TYPE_32; return SDS_TYPE_64; }升级是单向的。一个sdshdr8字符串追加变长后升级成sdshdr16以后即使你把它截短它也不会降级回sdshdr8。这个设计考虑很务实降级需要重新分配整个header而字符串将来可能再次变长升级和降级来回折腾反而浪费性能和时间不如保持在一个“足够大”的类型上用空间换稳定。3.4 惰性空间释放删除、截断字符串时SDS不会立刻把多余的free空间还给操作系统而是把len改短、多余空间继续留在buf里待用。这就是惰性空间释放核心函数是sdsIncrLen和sdsclear这类操作。void sdsclear(sds s) { sdssetlen(s, 0); s[0] \0; }调用sdsclear后字符串没有被free内存还在alloc不变下次再往里写数据时不需要重新malloc。这个策略和空间预分配是配套的一个解决“增长时的分配频率”一个解决“缩短时的释放频率”合在一起就是SDS减少内存重分配次数的完整方案。但这个机制也有反作用如果业务上反复对一个长字符串做“大增长、大截断”的操作会有相当一部分的“空洞内存”一直被SDS攥着不放最极端时可以看到一个长度只有几十字节的SDS底层alloc是几MB。真遇到这种情况需要主动调sdsRemoveFreeSpace强制瘦身把多余空间还给OS。4. 空间预分配背后的数学为什么是1MB分界线很多资料都只写结论“小于1MB翻倍大于1MB加1MB”但没解释这个阈值怎么来的。我借机会把这块的数学模型补一下理解了这条规则面试被问到底层细节时就能答得比别人深一层。4.1 预分配公式解析newlen 1MB时newlen len addlen然后整体翻倍。注意这里要先算出追加后的总长度再对总长度翻倍而不是“每次追加都分配当前长度的两倍”。源码写的是if (newlen SDS_MAX_PREALLOC) newlen * 2; else newlen SDS_MAX_PREALLOC;假设当前字符串长度是100字节要追加50字节追加后总长150字节分配结果是一次性给300字节。这样后续再追加只要累积追加不超过150字节都不用再malloc。如果把追加看成一系列随机请求预分配就是给“可能到来的下一次增长”提前买好单降低平均每次操作的系统调用成本。4.2 翻倍与固定增量的取舍当newlen达到1MB后翻倍策略的问题就暴露了一个1MB的字符串翻倍一次变成2MB再翻倍变4MB连续几次翻倍内存膨胀非常快很多空间可能永远用不到。固定增量1MB则把单次浪费控制在“最多多1MB”的范围内。两个策略的切换点正好是1MB意思是“这个字符串已经足够大不值得再给它超过1MB的余量”。这个阈值其实是可以调的SDS_MAX_PREALLOC在sds.h里定义。如果业务场景明确知道字符串很短可以调低这个值避免预分配过度如果字符串是大量小追加构成的适度调高可以减少重分配。我在几次项目里改过这个参数但说实话绝大多数场景默认值已经够用改之前先做好压测。4.3 内存重分配次数实测对比我拿两个方案做过对比测试一个用原生C字符串反复realloc加strcat一个用SDS反复sdscat都追加1万次每次追加一点点数据。原生方案触发了接近1万次内存重分配SDS方案因为预分配的存在实际触发重分配只有几百次。这个差距在Redis这种高并发场景里会被放大得非常明显因为malloc不光是时间开销还会加剧内存碎片内存碎片率高到一定程度后Redis的used_memory和rss会出现明显偏差最后只能重启来缓解。5. 二进制安全SDS为什么能存任意数据\0字节在C语言字符串里是终点但Redis经常要存用户上传的二进制内容图片、加密串、序列化对象这些数据里完全可能混入\0。SDS解决这个问题的办法很简单既然长度我已经另存了字符串的边界就不再依赖终止符缓冲区里任何位置的\0都是普通数据。sds s sdsnewlen(hello\0world, 11); // sdslen(s) 11 // 打印输出会是 hello 然后跳过中间但底层数据完整调用sdsnewlen时显式传入长度SDS内部不会去扫描\0数据被原样拷贝进buf。对外返回的buf末尾照旧会补一个\0但这个\0只是为了让SDS能复用部分C字符串库函数并不参与长度计算。这个“末尾补\0”的细节很妙。它让SDS可以安全使用strcmp、strchr这类“只读不写”的C函数来做比较和查找因为这类函数不会修改缓冲区内容也不会写入越界数据。printf(%s)打印字符串时也能直接工作打印到末尾的\0自动停住。唯一要警惕的是那些会原地修改字符串的C函数比如strtok、strtoul这类可能写坏buf的SDS里绝对不能直接用。在二进制安全这块我还要补一个实战细节从Redis里取出来的SDS转成JSON序列化字符串时如果value里含有\0或非UTF-8字节很多序列化库会直接报错或者产生截断。所以做数据导出、跨系统迁移时要么用Hex编码/Base64包装一层要么在写入前做字节校验。这个问题不属于SDS本身但确实是使用SDS存二进制数据时最容易踩的雷。6. 常见问题速查与面试要点汇总最后整理一份实战速查表把本文核心点按“是什么、为什么、怎么用”三个维度压缩出来面试前翻这个就够了另外也补充一些我在排查Redis内存和性能问题时积累的经验。6.1 高频面试题与参考答案问题回答要点Redis为什么要自研字符串C字符串获取长度O(n)、易溢出、二进制不安全Redis需要O(1)长度、读取安全、二进制安全的字符串结构SDS如何实现O(1)获取长度SDS头部单独存len字段取长度是读内存字段而不是遍历空间预分配策略是什么追加后长度小于1MB则翻倍大于等于1MB则加1MB减少内存重分配次数惰性空间释放的价值字符串缩短时不立即释放内存下次增长时复用已有空间避免频繁malloc/free什么是二进制安全以len判断数据边界而非\0中间有\0也不会截断能存储任意二进制数据SDS和C字符串能互操作吗尾部保留\0可安全使用strcmp等只读C函数但不可使用会修改内容的C函数sdshdr5为什么特殊只存flags长度放高位不参与实际存储主要用于创建小于32字节的短字符串6.2 实战避坑SDS使用中的常见问题先说“指针失效”问题。SDS几乎所有扩容操作都可能触发realloc或者新内存分配释放旧内存后旧指针就变成了悬空指针。写代码时全部用“接收返回值”的方式更新指针s sdscat(s, hello); // 正确用返回值覆盖原指针 sdscat(s, hello); // 错误扩容后s可能已经失效这个问题在迭代循环里更隐蔽比如循环里反复追加如果每次都不接收返回值可能第一次循环不触发扩容、第二次才触发然后后续就全在用悬空指针表现就是内存里的数据错乱或者随机崩溃。我排查过好几个类似的线上问题最后定位都是这个原因。然后是“内存长时间不释放”的问题。一个SDS被sdsclear以后它占用的内存并没有还给系统。如果业务场景是“启动时写入大量数据运行中反复截断重写”长时间运行后Redis的used_memory可能维持高位但实际保存的数据很小。排查手段是看INFO memory里used_memory和used_memory_rss的差值如果差距持续扩大就要考虑有没有大SDS在反复横跳。修复方式是手动调用sdsRemoveFreeSpace或者直接把这个key删了重建把头部和缓冲区一起瘦身。再说一个隐藏较深的“类型升级后指针偏移”问题。sds指针指向bufheader在buf之前。字符串发生类型升级比如从sdshdr8升到sdshdr16header变大buf的起始地址必然变化。如果代码里有依赖“buf地址固定”的缓存结构比如自己维护了一个指向buf的裸指针升级后就会错位。这种问题不会立刻崩往往是要等到再次写入数据越界写坏了邻接内存才会暴露。排查这类问题没有特别好的工具更多是靠代码审查时就排除对buf地址的长期引用。最后顺便提一下网上经常搜到的“字符串逆序输出”“判断字符串中是否不是字母和数字”这类需求如果用SDS做底层的话buf本质上就是一个带长度的字节数组处理方式和普通C数组完全一样逆序就是首尾指针交换字符判定就是逐字节比对。SDS的意义是让你不用自己管理长度和后缀\0但算法本身并不会有特殊捷径。我个人在实际阅读和调试SDS源码时感受最深的一点是这个结构体看着特别“小”但围绕它展开的每一个决策——头部放几个字节、预分配翻倍还是线性、升级为什么不做降级——背后都是真实生产环境里内存碎片和系统调用成本的博弈。搞懂SDS等于拿到了阅读Redis源码的第一把钥匙后面看quicklist、listpack、rax树的时候很多内存布局的思路都能在SDS设计里找到影子。下一篇我准备写SDS配套的哈希表结构也就是Redis的dict实现到时候把渐进式rehash一起讲透。