
先抛一个场景。你面试Redis前面的题都答得顺面试官忽然问“Redis为什么不直接用C字符串非要另起炉灶搞一个SDS”如果你只能背出“简单动态字符串获取长度O(1)、二进制安全”这几句话大概率会被追问到支支吾吾。这篇笔记把我自己读源码、查线上问题时对SDS的理解完整整理一遍从C字符串的麻烦说起到SDS头结构的演进再到空间预分配、惰性释放这些内存分配策略最后把embstr、raw编码和44字节这个经典数字一次讲透。希望读完之后你不只是记住SDS这个名字而是真的能讲清楚它为什么长这样、为什么这么设计。1. C字符串的三宗罪逼着Redis必须另起炉灶先明确一个前提Redis的核心是一个内存字典所有的key都是字符串大量value也是字符串。如果直接用C语言标准库那套char数组字符串会有三个致命问题。这三个问题不是理论推导出来的麻烦而是任何想做高性能内存中间件的人在设计第一阶段就会撞上的墙。1.1 获取长度strlen的时间复杂度是O(n)C字符串长得什么样一个以\0字符作为结束标志的char数组。你想知道它有多长strlen从头开始遍历直到遇见\0所以时间复杂度是O(n)。存几万个字符取一次长度就要扫描一遍。Redis是那种需要撑住单机十万级QPS的中间件每次SET、GET、INCR、SETEX都要反复确认字符串对象的长度。如果每个字符串取长度都O(n)Redis的吞吐量会被这个操作直接拖垮。更现实的问题是字符串越长这个拖累越明显。业务日志、序列化对象、缓存大JSON动不动几十上百KB一个SET操作里光是取长度就要白白遍历一遍这是绝对不能接受的。所以SDS第一个设计目标就是把获取长度的操作做成O(1)。怎么做到的在数据前面带一个len字段。老版本SDS的头结构就这么简洁struct sdshdr { unsigned int len; // 已使用字节数 unsigned int free; // 未使用字节数 char buf[]; // 真正的字节数组 };这里有个C99柔性数组flexible array member的用法buf[]不占结构体空间它只是偏移量的标记。实际的SDS对象在堆上是“header buf”一整块内存返回给调用方的sds指针指向buf而不是header开头。这样设计的好处是需要长度时通过指针往回偏移就能读到len字段而且因为指针指向bufSDS在很多场景下可以直接被当作普通C字符串使用这点后面讲二进制安全时还会展开。1.2 缓冲区溢出一场随时会爆的内存事故第二个问题更严重。C标准库的字符串拼接、拷贝函数比如strcat、strcpy根本不会检查目标空间够不够。你调strcat(dst, src)的时候如果src长度超过dst剩余空间就会一路写过去把旁边内存的数据直接踩掉。这类问题的经典场景我见过太多了。比如日志模块里拼字符串一个size估算失误线上进程直接core dump查半天查不出原因因为被覆盖的可能是堆上另一个无关对象的数据错误不会立刻暴露而是在某个遥远的操作里突然崩溃。C程序员都知道这类问题最恶心的不是崩溃本身而是崩溃的位置和根因完全对不上。Redis作为对外提供内存服务的中间件最不能接受的就是这种随机内存损坏。所以SDS在设计API时所有修改类操作sdscat、sdscpylen等都内置空间检查不够用就先自动扩容目标空间够不够是API内部的事情调用者根本不需要也没资格去关心。它把C字符串时代“谁来保证空间充足”的问题从调用方包办到了实现方。所有修改都在可控范围里不会出现越界写。1.3 二进制不安全字符串中间不能出现\0第三宗罪更贴近业务。C字符串靠\0判断结束所以字符串内部天然不允许出现\0。也就是说你没法用C字符串存储一张图片、一个序列化好的对象、一段压缩后的数据——这些东西的二进制内容里大概率有\0字节。一旦中间遇到\0C函数就认为字符串到头了后面的数据全部丢失。但Redis是中间件是缓存层是用户什么数据都往里扔的储物柜。用户可能存JSON、protobuf、Java序列化对象甚至直接扔一个文件内容进去。如果Redis的字符串底层是C字符串那Redis只能处理纯文本这个产品基本就废了。SDS解决这个问题的方式非常直接这个结构从头到尾根本不认\0是结束标志它只认len字段。len写的是多少有效数据就是多少字节。\0在SDS里就是一个普通字符和其他字节没有区别爱存多少个存多少个。这也是为什么我把SDS叫“字节数组元信息”而不是“字符串”——它的本质是二进制安全的字节容器。C字符串的这三大问题对应的正是SDS当年出现的理由O(1)取长度、杜绝溢出、二进制安全。但SDS的好东西远不止这三点头结构的演进和内存分配策略才是它在实战中最值钱的部分。2. SDS头结构演进从老sdshdr到五种变体SDS并不是生下来就长现在这样。它经历了从简单到精细的演化过程这个演化本身就是一部“内存抠门”的进化史。看懂了这段演进你就明白了Redis为什么在行业内被称为“内存友好型数据库的天花板”。2.1 为什么老版设计成lenfree老版本的sdshdr使用两个unsigned int字段len和free。len是已经使用的字节数free是后面预留但还没用的字节数。二者加起来就是总共分配的内存字节数。这个设计信号很明确SDS从一开始就打算在“空间管理”上做文章而不是像C字符串那样只在需要时临时分配。每次修改SDS的时候先检查free够不够。不够就按预分配逻辑补够了就直接写写完之后同步更新len和free。这里有一个很多人忽略的点free不是“剩余空间”这么简单它是后续append操作能不能免malloc的关键。维护一个free字段本身就是在为高频追加场景做铺垫。老版本获取len和free也有个技巧因为sds指针指向buf所以len就存在buf之前的8个字节处len占4字节free在buf之前的12字节处。源码里用sdslen宏本质就是做一次指针偏移再读内存static inline size_t sdslen(const sds s) { struct sdshdr *sh (void*)(s - (sizeof(struct sdshdr))); return sh-len; }读长度就是一次减法加一次解引用这也是O(1)的实底。2.2 Redis 3.2之后一种头拆成五种老版本头虽然简洁但它有个坏毛病不管字符串是3字节还是3GBheader一律是8字节len 4字节free 4字节。Redis是内存数据库磁盘上那些能忍的空间浪费在内存里每一字节都心疼。几百万个短字符串的key每个头上多背5个字节加起来就是几十MB这在内存昂贵时代是不可接受的。于是从Redis 3.2开始SDS的头结构变成了一套变体家族按字符串最大长度分成五个档位类型头部字段头占用字节len/alloc可表示范围sdshdr5flags1长度存于flags高5位最多31字节sdshdr8len(1B)alloc(1B)flags(1B)3最多255字节sdshdr16len(2B)alloc(2B)flags(1B)5最多65535字节sdshdr32len(4B)alloc(4B)flags(1B)9最多4GB左右sdshdr64len(8B)alloc(8B)flags(1B)17超大字符串新头的代码长这样以sdshdr8为例struct __attribute__((__packed__)) sdshdr8 { uint8_t len; // 已用长度 uint8_t alloc; // 总分配长度不含头和结束符 unsigned char flags; // 低3位存类型高5位备用 char buf[]; };和老版对比最直观的变化有三个free没了换成了alloc。free alloc - len需要时算一下就行省一个字段。len和alloc都改成了尽可能小的无符号整数类型短字符串用1字节就够了。多了flags字段用来标记当前头属于哪种类型这样运行时才知道怎么解析头部。老版的free被去掉不是功能减配是因为free可以由alloc-len推导出来。省字段、省内存才是这次重构的核心目的。sdshdr5更极端它连len和alloc都不存直接把长度塞在flags的高5位里所以它只能服务不超过31字节的字符串几乎就是一个“贴头即用”的极限优化。2.3 packed把字节对齐的浪费也抠回来为什么结构体前面要加__attribute__((packed))这里涉及C语言的结构体内存对齐。正常情况下编译器会在结构体成员之间插入填充字节让每个成员对齐到它自身类型的自然边界。比如一个sdshdr16len(2B)alloc(2B)flags(1B)之后编译器可能补1字节填充让整个结构体变成6字节某些平台上甚至对齐到更大的倍数。Redis的做法是直接打包告诉编译器不要给我插任何填充字节结构体占用必须正好等于所有字段之和。sdshdr8就是3字节sdshdr16就是5字节sdshdr64就是17字节。这样做的代价是字段可能出现在非对齐地址上严谨地说访问这类成员属于C标准里的未定义行为。但Redis在工程上赌了一把它假设运行平台对这些非对齐访问是支持的x86和主流ARM都支持换来的则是遍布整个内存堆的结构体头全面瘦身。很多追求极致性能的C项目都干过类似的事但Redis把这个技巧用到了极大规模的内存对象上收益非常可观。理解到这一层你就明白为什么Redis能在性能上做到那么猛——它的跳表、quicklist、listpack、rax树每一个都在内存布局这件事上做足了功夫。SDS只是这个设计哲学最典型的一张名片。3. 空间预分配与惰性释放SDS的内存分配哲学SDS在运行期最值得讲的部分就是它的内存分配策略。C字符串时代内存分配是“用多少分多少”而SDS反其道而行之靠“多分”和“晚还”两个手段把动态字符串的性能拉到了一个新高度。3.1 如果没有预分配append会变成灾难模拟一个业务场景日志收集器不断向Redis追加数据同一个key每次append几KB一天下来可能追加几千次。如果SDS每次追加都realloc扩容会发生什么每次realloc可能有两种情况原地扩展成功运气好或者搬到一个更大的新地址大多数时候。一旦搬家就要把旧数据整体拷贝过去。也就是说每追加一批数据最坏情况下要把已有的全部内容拷一遍。追加M次总拷贝量接近O(M²)级别。M是几千次的时候这个开销足以让CPU和内存带宽报警。这个问题的本质和动态数组比如Java的ArrayList一样只是C没有容器帮你兜底。Redis作为中间件绝不能允许这种复杂度所以SDS在修改前一定会调用sdsMakeRoomFor预先判断剩余空间够不够不够就一次性多分配一些为后续的追加留出余量。3.2 预分配的具体规则一条1MB的临界线sdsMakeRoomFor的扩容逻辑其实就是两条规则如果新的字符串总长度小于SDS_MAX_PREALLOC1MB那么新分配的总长度直接翻倍。如果新长度大于等于1MB那么只额外增加1MB。翻译成人话就是字符串小的时候按几何级数增长字符串已经很大的时候按算术级数增长。为什么要把临界线设在1MB因为小字符串翻倍的成本很低比如从100字节翻到200字节只多占100字节但能为下一次append省一次malloc。而如果一个大字符串从10MB翻到20MB一次多了10MB空闲内存对内存数据库来说太奢侈了。1MB的固定增量是一个工程上的折中大字符串同样能享受预分配的便利又不会让内存过度膨胀。对比一下Java的ArrayList它的扩容是1.5倍。SDS的翻倍策略和Java这种“教科书式”方案相比多了1MB这个上限保护说明Redis在设计时把大对象的极端场景也考虑进去了。这种细节才是源码阅读最有收获的地方。3.3 惰性释放截短了不回收留着下次用SDS的另一半内存策略是惰性释放。比如sdstrim会把字符串头尾的空格去掉很多人以为API内部会realloc缩容。实际上不是字符串确实变短了len和alloc也同步更新但底层那块大分配空间并不会被释放而是转成了free空间记在账上。这个设计很妙。因为业务场景里“截短之后马上又变长”的情况太常见了。比如队列消费端的pending数据消息长度来回波动比如Redis做限流时周期窗口内的计数和令牌数据频繁变化。如果截短一个字符就释放一次内存下一次变长又malloc一次分配器来回折腾性能和内存碎片都不好看。惰性释放把“删除空间”从修改路径上挪走了只有真正需要归还内存时才调用sdsRemoveFreeSpace或者sdsAllocSize之类的接口去缩容。相应的还有一个sdsclear操作它把字符串清成空串但同样不释放底层空间。看到这几个API的设计你就知道Redis对“内存分配次数”有多敏感了。3.4 注意惰性释放的阴暗面惰性释放也不是没有代价。如果一个key长期被反复缩短free空间会越攒越多最终出现怪异的现象字符串本身只有几十字节底层却占了几MB内存。我在线上排查bigkey时真的遇到过类似案例——某个业务反复截断字符串却不新增导致内存占用异常高。排查方法很简单用DEBUG SDSLEN key看一眼底层实际分配的大小如果和字符串实际长度差距明显就说明free空间积压了。处理手段也直接调用sdsRemoveFreeSpace强制缩容或者干脆等这个key过期后由Redis自己回收。所以说惰性释放是绝大多数场景下的最优策略但它不是银弹你心里得有这根弦。4. 二进制安全与常用API用len说了算的世界SDS最容易被低估的一点是“二进制安全”这四个字背后到底意味着什么。很多人嘴上说着二进制安全实际问他“为什么能存图片”他说不上来。这一节我把这层窗户纸捅破。4.1 二进制安全的本质告别\0霸权SDS的buf里len个字节是有效数据后面的\0只是一个“兼容垫片”。换句话说\0的位置根本不代表字符串的结束它只是为了让整个buf仍然是一个合法的C字符串这样当我们需要把SDS传给一些C标准库函数时比如打印日志、和普通字符串做比较调用不会越界访问。用户存储“ABC\0DEF”这样的字节序列SDS的len会正确地记成7而不会在读到\0时就停下来。反过来写进字符串里的\0在读取时也会原样返回。你有多少字节len就记录多少不多不少。二进制安全带来的直接收益Redis能存图片、音视频、序列化对象、压缩数据……这也是它作为通用中间件而不仅仅是文本缓存的底气。理解这一点你才能理解为什么Redis官方文档里喜欢用“byte array”来描述SDS而不是“string”。4.2 从常用API看SDS的操作复杂度SDS的API分三类查询、修改、创建销毁。把常用操作的时间和空间表现列出来就能一眼看出SDS的设计重心操作API复杂度说明获取长度sdslenO(1)直接读len字段设置长度sdssetlenO(1)直接写len追加sdscat / sdscatlen分摊O(1)大多情况free够用不够就触发预分配裁剪sdstrimO(n)需要检查边界并更新len截断sdsrangeO(n)复制保留区间的数据比较sdscmpO(n)逐字节比较复制sdsdupO(n)新分配一份并拷贝重点说一下sdscat。它内部并不是直接realloc之后memcpy而是先判断free是否够。够的话直接memcpy过去这就是为什么预分配能产生质变一旦预分配到位后续的追加退化成一次内存拷贝。不够的时候才进入sdsMakeRoomFor走扩容逻辑。这种“先检查再行动”的模式就是SDS所有修改类API的安全底线。4.3 和C字符串的那根“藕断丝连”SDS的buf始终以\0结尾这不是浪费一个字节而是刻意为之。有两点考虑第一兼容部分C标准库函数。比如用%s格式打印或者和普通字符串做strcmpSDS可以在不拷贝的情况下直接胜任。但要注意strcmp遇到嵌入\0的数据会出错所以这种兼容只适合纯文本场景二进制的场景还是得走SDS自己的API。第二方便排查问题。线上gdb调试Redis的时候SDS的buf在调试器里看起来就是个普通的C字符串前面内容一目了然。如果没有这个约定每次都要手动根据len算边界调试体验会很糟。Redis源码里还有个细节sdsnewlen分配buf时实际分配长度是len1多出来的那个字节永远写\0。也就是说\0是SDS的标配但它的命运只是“伴生”而不是“主宰”。这个细节很能体现C语言老手的风格既拥抱标准库的便利又不让它限制自己的数据结构。5. SDS不止是字符串键它撑起了半个Redis很多人以为SDS只是string类型value的底层实现其实格局小了。SDS在Redis内部简直无处不在。搞清楚了SDS的应用范围你才能明白为什么它值得花一整篇文章去讲。5.1 键空间、AOF缓冲、网络输入缓冲处处都是SDS列几个我印象最深的使用位置键空间表dict里的key本身就是一个SDS。string类型value在raw/embstr编码下的载体是SDS。AOF持久化需要缓冲待写入的命令server.aof_buf是SDS。从客户端网络层读到的命令尚未解析时存在client.querybuf里它也是SDS。输出缓冲、订阅发布模式下给客户端攒的消息同样能用SDS组织。也就是说Redis每处理一条客户端命令背后至少要创建或修改一两个SDS。这也是为什么SDS的性能优化对整个Redis的影响被放得那么大——它不是一个偏门角落的数据结构而是主路径上人人都要踩的地基。5.2 string类型的三种编码int、embstr、rawRedis的string类型value底层会根据内容动态选择编码。SDS只在其中两种编码里出现但理解这三种编码的切换逻辑是理解字符串键性能的关键int编码如果字符串可以被解析成整数比如1000Redis直接把这个数字存进redisObject的ptr字段通过指针位数的技巧根本不创建SDS对象省掉一个对象头。embstr编码字符串长度小于等于44字节时使用embstr。此时redisObject和SDS头、buf分配在同一个连续内存块里一次malloc全部搞定对CPU缓存极度友好。raw编码长度超过44字节redisObject和SDS分成两块独立内存需要两次malloc。这里“44字节”是一个非常经典的面试数字。它怎么来的Redis在分配小对象时依赖jemalloc64字节是一个最常用的档位。64字节减掉redisObject本身的16字节再减掉sdshdr8头部的3字节再减掉末尾\0的1字节剩下正好44字节给真正的数据。如果你用的是Redis 3.2之前的版本这个阈值是39字节因为当时sdshdr头占8字节64-16-8-139。很多人在面试时能说出“44”但能把这笔账算清楚的人不多。这恰好是从“背概念”到“理解原理”的分水岭。5.3 面试高频追问用SDS原理统一回答整理三个我经常被问到、也是面试官很喜欢追问的问题第一SDS相比C字符串到底强在哪四条O(1)取长度、无缓冲区溢出风险、二进制安全、内存分配策略高效。别只背词条每条背后都能展开讲一两分钟这就叫把原理吃透了。第二空间预分配既然这么省有没有浪费内存的反面案例有就是前面说的惰性空间累积。所以面试官如果追问“预分配会不会导致内存浪费”你可以回答“会但Redis提供了缩容接口而且大多数key生命周期内数据长度相对稳定预分配带来的收益远大于浪费”。第三为什么Redis 3.2要把SDS头拆成5种核心是一个词内存。Redis的字典可能存放上亿个key头结构每少1字节整体省下的内存就是几十上百MB。从工程视角看这是一次典型的“用复杂度换内存”的策略同时也是一次代码重构的艺术课。再串一个业务场景Redis做分布式锁。SETNX加锁时业务方通常会传一个UUID作为锁的value加锁、解锁整个生命周期里这个字符串对象被创建、读取、删除。SDS的预分配机制在这里的价值是加锁时的value如果恰好落在预分配区间里后续的GET、续期操作都无需触发malloc锁操作的抖动时间被压到最低。基础数据结构对上层业务的影响就是这么直接。最后分享一个我的习惯。看Redis源码时别急着往quicklist、listpack那些复杂结构里钻先把sds.c和sds.h读透。我第一次自己跟读sdsMakeRoomFor的源码时印象最深的是那段预分配逻辑的注释malloc再分配的次数越少缓存命中和内存碎片就越好。后来我在公司内部做Redis调优分享第一页PPT也经常放SDS的头部结构图因为很多同事对底层一无所知也能把Redis用得风生水起但一旦遇到内存增长异常、bigkey、性能抖动能救场的永远是这些底层原理。SDS就是那把钥匙。