先抛一个场景。你面试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字段;而且因为指针指向buf,SDS在很多场景下可以直接被当作普通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'字节。一旦中间遇到'\0',C函数就认为字符串到头了,后面的数据全部丢失。
但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 为什么老版设计成len+free
老版本的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字节还是3GB,header一律是8字节(len 4字节+free 4字节)。Redis是内存数据库,磁盘上那些能忍的空间浪费,在内存里每一字节都心疼。几百万个短字符串的key,每个头上多背5个字节,加起来就是几十MB,这在内存昂贵时代是不可接受的。
于是从Redis 3.2开始,SDS的头结构变成了一套变体家族,按字符串最大长度分成五个档位:
| 类型 | 头部字段 | 头占用字节 | len/alloc可表示范围 |
|---|---|---|---|
| sdshdr5 | flags | 1 | 长度存于flags高5位,最多31字节 |
| sdshdr8 | len(1B)+alloc(1B)+flags(1B) | 3 | 最多255字节 |
| sdshdr16 | len(2B)+alloc(2B)+flags(1B) | 5 | 最多65535字节 |
| sdshdr32 | len(4B)+alloc(4B)+flags(1B) | 9 | 最多4GB左右 |
| sdshdr64 | len(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语言的结构体内存对齐。正常情况下,编译器会在结构体成员之间插入填充字节,让每个成员对齐到它自身类型的自然边界。比如一个sdshdr16,len(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_PREALLOC(1MB),那么新分配的总长度直接翻倍。
- 如果新长度大于等于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 | 复杂度 | 说明 |
|---|---|---|---|
| 获取长度 | sdslen | O(1) | 直接读len字段 |
| 设置长度 | sdssetlen | O(1) | 直接写len |
| 追加 | sdscat / sdscatlen | 分摊O(1) | 大多情况free够用,不够就触发预分配 |
| 裁剪 | sdstrim | O(n) | 需要检查边界并更新len |
| 截断 | sdsrange | O(n) | 复制保留区间的数据 |
| 比较 | sdscmp | O(n) | 逐字节比较 |
| 复制 | sdsdup | O(n) | 新分配一份并拷贝 |
重点说一下sdscat。它内部并不是直接realloc之后memcpy,而是先判断free是否够。够的话直接memcpy过去,这就是为什么预分配能产生质变:一旦预分配到位,后续的追加退化成一次内存拷贝。不够的时候才进入sdsMakeRoomFor走扩容逻辑。这种“先检查再行动”的模式,就是SDS所有修改类API的安全底线。
4.3 和C字符串的那根“藕断丝连”
SDS的buf始终以'\0'结尾,这不是浪费一个字节,而是刻意为之。有两点考虑:
第一,兼容部分C标准库函数。比如用%s格式打印,或者和普通字符串做strcmp,SDS可以在不拷贝的情况下直接胜任。但要注意,strcmp遇到嵌入'\0'的数据会出错,所以这种兼容只适合纯文本场景,二进制的场景还是得走SDS自己的API。
第二,方便排查问题。线上gdb调试Redis的时候,SDS的buf在调试器里看起来就是个普通的C字符串,前面内容一目了然。如果没有这个约定,每次都要手动根据len算边界,调试体验会很糟。
Redis源码里还有个细节:sdsnewlen分配buf时,实际分配长度是len+1,多出来的那个字节永远写'\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、raw
Redis的string类型value,底层会根据内容动态选择编码。SDS只在其中两种编码里出现,但理解这三种编码的切换逻辑,是理解字符串键性能的关键:
- int编码:如果字符串可以被解析成整数(比如"1000"),Redis直接把这个数字存进redisObject的ptr字段(通过指针位数的技巧),根本不创建SDS对象,省掉一个对象头。
- embstr编码:字符串长度小于等于44字节时,使用embstr。此时redisObject和SDS头、buf分配在同一个连续内存块里,一次malloc全部搞定,对CPU缓存极度友好。
- raw编码:长度超过44字节,redisObject和SDS分成两块独立内存,需要两次malloc。
这里“44字节”是一个非常经典的面试数字。它怎么来的?Redis在分配小对象时依赖jemalloc,64字节是一个最常用的档位。64字节减掉redisObject本身的16字节,再减掉sdshdr8头部的3字节,再减掉末尾'\0'的1字节,剩下正好44字节给真正的数据。如果你用的是Redis 3.2之前的版本,这个阈值是39字节,因为当时sdshdr头占8字节,64-16-8-1=39。
很多人在面试时能说出“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就是那把钥匙。