资讯动态

Redis SDS源码解析:简单动态字符串如何做到O(1)与二进制安全

发布时间:2026/10/2 8:48:56 来源:尧图企业网站定制
在Redis源码里翻过字符串实现的人应该都见过sds.h和sds.c这两个文件。不管你是用Docker拉镜像、源码编译、还是Windows下折腾出来的Redis底层这套字符串结构都是一样的——它叫SDS全称Simple Dynamic String简单动态字符串。Redis所有的键、大部分的值、AOF缓冲区、客户端输入缓冲区底层全是这东西在扛。我入行那会儿第一次读Redis源码第一个卡住的地方就是SDS。倒不是说它多难懂而是它绕过了C语言标准字符串那一套约定专门给自己设计了一套“不按套路出牌”的表示方法。等你真正搞懂它为什么这么做你会发自内心觉得这玩意儿设计得确实漂亮。这篇就把SDS从头到尾拆一遍为什么Redis不用C自带的char*、SDS的结构长什么样、长度获取和扩容是怎么做到O(1)的、二进制安全到底安全在哪、一个SDS实际占多少内存。内容偏源码级别但我会把每个设计的“为什么”都交代清楚新手看下来不会懵有经验的也能在内存预分配和类型选择那块收获点新东西。1. 为什么要为Redis单独设计一套字符串C字符串的四个“硬伤”1.1 C语言字符串的天然局限C语言里字符串就是一串以\0结尾的char数组标准库提供的strlen、strcpy、strcat等一系列函数全部依赖这个\0来定位字符串的边界。这个设计在普通场景下够用但放到Redis这种高性能、高并发的内存数据库里就成了四个绕不过去的坑。第一个坑获取长度必须遍历。strlen要从头开始数遇到\0才停复杂度是O(n)。Redis里获取字符串长度是一个非常高频的操作比如STRLEN key命令、各种内部逻辑判断、协议解析如果每个长度都要遍历一遍整个字符串性能损耗完全不可接受。第二个坑二进制不安全。一个字符串里只要中间出现\0C就默认字符串在这儿结束了。可Redis要存的东西远远不止文本序列化后的对象、图片数据、压缩后的内容这些二进制数据里出现0x00是非常正常的事。用C字符串存这些要么被截断要么得做一层额外的转义编码既麻烦又浪费内存。第三个坑缓冲区溢出风险。strcpy、strcat这类函数不会检查目标缓冲区够不够大全靠程序员自觉。Redis作为一个长期运行的服务器程序线上环境任何一次内存越界都是灾难级别的Bug。要么数据被覆盖要么直接段错误宕机这种事故在C程序里排查起来极其痛苦。第四个坑内存重分配频繁。对一个C字符串做append操作每次都要手动realloc扩展空间。Redis的写操作非常密集键的值经常被反复修改如果每次修改都触发一次系统调用级别的内存分配性能会直线下降还容易造成大量内存碎片。1.2 Redis为什么必须“自己造轮子”有人会问Redis是C写的为什么不用现成的字符串库答案很简单Redis对字符串有自己的一套特殊要求而这些要求是任何传统C字符串方案都无法同时满足的。Redis在操作字符串时需要的是O(1)复杂度获取长度、二进制安全、append操作尽量减少内存重分配、并且能和C字符串函数无缝混用。这四个要求如果都要满足就不能再用裸露的char*必须给字符串加一个“头”把这个字符串的元信息——长度、容量——记录下来。这就是SDS的由来。我在面试候选人的时候经常问一个问题SDS和C字符串最本质的区别是什么有人回答是“多了一个len字段”这只能说答对了一半。最本质的区别是SDS把字符串从“以分隔符定界”变成了“以长度定界”。这个思维转变是整个设计的基石后面所有的高级特性比如二进制安全、O(1)长度、预分配都是从这个转变里长出来的。2. SDS的结构设计从v1到v2的演进2.1 老版本SDS一个简单的结构体Redis 3.2之前的SDS结构很简单就是一个结构体加一个柔性数组struct sdshdr { unsigned int len; // 已使用长度 unsigned int free; // 未使用长度 char buf[]; // 字节数组 };当时这个设计已经比C字符串强太多了len让获取长度变成O(1)free记录了剩余空间append前先检查free够不够不够才扩容这样就减少了内存重分配次数。但这里面有个很明显的内存浪费问题不管你的字符串是5个字节还是5万个字节len和free都固定占用8字节两个unsigned int加上buf末尾的\0每创建一个短字符串都要额外付出9字节的代价。Redis里大量的小字符串比如两位数、英文单词这个固定头部的开销就显得很不划算。2.2 新版本SDS五种类型按需分配Redis 3.2之后SDS被拆成了5种类型sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64。不同类型的区别在于len和alloc字段占用的字节数不同核心思想就是用小头装小字符串用大头装大字符串不再搞“一刀切”。typedef char *sds; struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 低3位存储类型高5位存储长度 */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 已使用长度 uint8_t alloc; // 分配的总容量 unsigned char flags; // 低3位存类型标识 char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr32 { uint32_t len; uint32_t alloc; unsigned char flags; char buf[]; }; struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; uint64_t alloc; unsigned char flags; char buf[]; };注意这三种头部的字节数sdshdr8占3字节sdshdr16占5字节sdshdr32占9字节sdshdr64占17字节。对于大量短字符串来说3字节的头部比老版本的8字节省了一大截。这里有个非常关键的设计细节__attribute__((packed))也就是取消内存对齐。正常情况下编译器会在结构体成员之间插入填充字节以保证对齐比如上面那个sdshdr16如果不加packeduint16_t后面紧跟unsigned char再后面是char buf[]由于对齐规则结构体大小可能就不是5字节而是6字节甚至更多。加上packed后成员之间零填充头部大小严格按照直觉计算。这个细节我在后面的章节还会再提因为它既是SDS巧妙设计的根基也会在某些场景下带来性能损失。2.3 类型选择一个字符串怎么决定自己用哪种头部SDS不是创建时随便挑个类型的它有一套严格的逻辑根据字符串长度来决定static inline char sdsReqType(size_t string_size) { if (string_size 15) // 小于32字节 return SDS_TYPE_5; if (string_size 18) // 小于256字节 return SDS_TYPE_8; if (string_size 116) // 小于65536字节 return SDS_TYPE_16; if (string_size 1ll32) // 小于4GB return SDS_TYPE_32; return SDS_TYPE_64; }这个逻辑简单到不需要解释但背后的指导思想值得玩味Redis费这么大劲搞出5种头部就是为了在不同的字符串长度区间内把头部开销压到极致。一个长度为5的字符串用sdshdr5的话头部只有1字节就算用sdshdr8也就3字节。这种“内存斤斤计较”的风格是Redis能保持超高内存利用率的重要原因。还有一个容易忽略的点SDS_TYPE_5并没有单独的len和alloc字段它的长度直接藏在flags的高5位里。这意味着sdshdr5最多只能表示31字节的字符串一旦超过31字节就必须升级到sdshdr8。设计者这样做的目的非常明确极短的字符串在Redis里占比极高为了它们专门压缩掉2字节的头部开销积少成多省出来的内存相当可观。3. 核心机制源码级拆解长度获取、扩容与缩容3.1 O(1)获取长度以及那个神奇的s[-1]SDS获取长度的入口是宏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; case SDS_TYPE_16: return SDS_HDR(16, s)-len; case SDS_TYPE_32: return SDS_HDR(32, s)-len; case SDS_TYPE_64: return SDS_HDR(64, s)-len; } return 0; }这里最聪明的点是s[-1]。sds本质上就是char*它指向的是buf数组的首地址不是结构体的首地址。所以在buf前面紧挨着的那个字节就是flags字段。通过s[-1]取到这个字节读它的低3位拿到类型再根据类型去取对应结构体的len。3.2 扩容策略小于1MB翻倍大于等于1MB加1MB字符串append是Redis最频繁的操作之一。假设每次append都重新分配恰好能装下的内存那平均每两次append就要发生一次系统调用级别的内存分配。SDS的解决方法是空间预分配核心实现在sdsMakeRoomForsds sdsMakeRoomFor(sds s, size_t addlen) { ... /* 计算新长度 */ newlen (len addlen); if (newlen SDS_MAX_PREALLOC) newcap newlen * 2; // 新长度小于1MB直接翻倍 else newcap newlen SDS_MAX_PREALLOC; // 新长度超过1MB追加1MB ... newsh s_realloc_usable(sh, hdrlennewcap1, usable); ... }注意这里SDS_MAX_PREALLOC的值是1024*1024也就是1MB。规则很简单如果扩容后的新长度还不到1MB就按新长度的2倍分配如果新长度超过1MB就在新长度基础上再追加1MB。为什么这么定我个人的理解是这是针对Redis工作负载的一个经验性平衡。短字符串场景下append往往还会继续翻倍分配可以摊薄后续append的内存重分配成本而大字符串场景下动辄几MB甚至几十MB的字符串如果还翻倍一次append就可能分配出上百MB的容量内存浪费太严重。加1MB是“够用且不至于太浪费”的折中。这个策略背后是典型的“空间换时间”思路但妙就妙在它不是无脑换而是分档处理兼顾了小字符串的频繁操作和大概率内存浪费的问题。我实际测过一个从空字符串开始反复append的SDS如果每次都只追加少量数据触发预分配后前几十次append几乎都不会触发真正的内存重分配。3.3 惰性空间释放缩短字符串不立即还内存与预分配对应的是SDS的惰性空间释放。当执行sdsclear清空字符串时它不会直接调用free归还内存而是只把len设为0并保留已经分配的空间void sdsclear(sds s) { sdssetlen(s, 0); s[0] \0; }这个设计非常聪明一个字符串如果短期内被反复修改比如日志聚合、计数累加、缓冲区复用频繁释放再重新分配内存的代价极高。惰性释放保证了下一次append可以复用现有空间只有真正需要内存时才通过sdsRemoveFreeSpace缩容把空闲空间归还给内存分配器。我在生产环境排查过一个问题一个列表里某个元素被反复修改SDS头部从sdshdr8升到sdshdr16后数据删掉了但头部没有降回来。这不是Bug而是惰性释放的预期行为——空间换时间。如果业务上确定字符串不会再变大了可以主动调SDS_NOINIT或者sdsRemoveFreeSpace把多余空间收回来这在小内存实例上确实能省出不少内存。4. 二进制安全与C字符串兼容SDS的两个隐藏能力4.1 二进制安全不靠\0判断边界SDS的buf数组里可以包含任意字节包括\0。因为判断字符串在哪结束用的是len字段而不是扫描\0。这是SDS和C字符串最根本的区别。举个例子你要往Redis里存一个JPG图片的二进制内容。这个图片数据里很可能在中间某处就出现了字节0x00。如果用C字符串存储strlen得到的长度是第一个\0之前的部分后面的数据全部丢失。而SDS存同样的数据len记录的是完整长度读数据时按len取中间有多少个\0都无所谓。Redis的SET命令能接受任意二进制内容AOF持久化也能正确记录各种二进制数据靠的都是SDS的二进制安全特性。这也是为什么Redis能作为消息队列、缓存二进制对象、存序列化后的数据而不仅仅是存文本。4.2 兼容C字符串函数末尾永远有个\0SDS虽然不依赖\0定界但它在设计上留了一个后门buf数组末尾总会存放一个\0。这个字符不计入len纯粹是为了兼容C语言的标准字符串函数。这意味着你可以安全地把SDS的buf直接传给strcmp、strcasecmp、printf等函数。比如Redis里比较两个SDS是否相等会先比长度长度相同再调memcmp而如果需要不区分大小写比较就直接调strcasecmp因为它知道SDS末尾有\0strcasecmp不会越界。这个设计的精妙之处在于SDS在一套数据结构上同时实现了两种能力对外部代码提供C字符串的“接口”对内部逻辑提供二进制安全的“实现”。你不需要为了兼容C函数而单独复制一份字符串也不需要担心传出去会截断二进制数据。老版本的Redis某些地方会直接返回SDS的buf给客户端就是因为末尾\0保证了它作为C字符串也是合法的。4.3 为什么 sds 用char*而不是struct*有很多第一次读源码的人会困惑sds明明是个结构体的概念为什么typedef出来的是char*而不是struct sdshdr*这个设计有很强的实用目的。如果sds是指向结构体的指针那么所有操作都要通过sh-buf取得字符数组API会非常别扭而且结构体指针和char*之间频繁转换也容易出错。直接把sds定义为char*让它指向bufSDS的API从外部看起来就和操作普通C字符串一样自然sdsnew(hello)返回一个能直接printf的地址sdscat的返回值可以继续传给其他字符串函数。代价就是当需要访问结构体头部时必须通过SDS_HDR宏把sds指针往前偏移到结构体头部再做一次类型转换。这种“结构体头和字符指针分离”的布局在实际使用中换来了极大的便捷性也让SDS能无缝嵌入到Redis各种需要char*的代码路径里。5. 实操中的内存账本一个SDS到底占多少字节5.1 各类型头部开销对照SDS的内存占用是很多人在分析Redis内存问题时容易算错的地方。先看头部开销SDS类型len字段alloc字段flags字段头部总字节数可表示的最大字符串长度sdshdr5不单独存储不单独存储1字节高5位存len131字节sdshdr81字节1字节1字节3255字节sdshdr162字节2字节1字节565535字节sdshdr324字节4字节1字节94GB-1sdshdr648字节8字节1字节17极大注意这个表格里没有计算buf末尾的\0。任何一个SDS实际分配的内存都是头部大小 len alloc空闲空间 1。比如一个存储了5字节数据的sdshdr8字符串如果分配时没做预分配实际分配是3 5 1 9字节。作为对比同样内容的C字符串只需要6字节。头部多出来的3字节买回来的是O(1)长度、二进制安全和预分配能力。5.2 联合实际场景算一次内存账我在分析线上Redis内存时有个常用的估算思路先统计key的个数和平均长度再按SDS头部规则计算“净数据”之外的开销。举个例子假设有100万个key每个key平均30字节value平均50字节。用sdshdr32固定头部的方式来存每个字符串头部9字节100万个key和100万个value加起来就是(9 9) * 100万 1800万字节的头部开销。但换成3.2之后的版本key长度30字节走sdshdr8头3字节value长度50字节也走sdshdr8头3字节头部开销直接降到(3 3) * 100万 600万字节。这一项就省了1200万字节。这个实例也解释了为什么Redis的embstr编码上限是44字节。我刚接触Redis源码时算过这个东西64字节是人们常用的jemalloc内存分配档位robj结构体本身占16字节sdshdr8头部占3字节末尾还需要1字节存\0所以能留给字符串内容的就只有64 - 16 - 3 - 1 44字节。这就是为什么字符串值超过44字节时Redis会从embstr编码转为raw编码——超过了44字节robj和SDS就不能塞进同一个64字节内存块了得分开分配。这类计算在分析Redis内存碎片、预估容量时非常有用。你在网上看到的很多Redis内存优化文章什么小字符串合并、批量写入、控制key长度背后的本质算法都是这套SDS内存模型。5.3 头部升级带来的数据迁移成本SDS头部类型不是固定的。一个字符串从短变长超过了当前头部类型能表示的范围就需要升级头部类型。比如sdshdr8的字符串长度从255涨到256就会升级成sdshdr16。这个升级不是简单改一个字段而是整个realloc把数据从头到尾搬迁到新的内存位置头部大小从3字节变成5字节。我在一些线上大key案例里见过因频繁在这个边界拉扯导致的性能抖动。比如某个value反复在255和300字节之间横跳每次跨过256这个门槛都会触发一次整体迁移。遇到这种业务如果你知道value会长大可以提前用SETRANGE或者APPEND把长度撑上去让SDS头部一次性升级到位避免反复迁移。这种优化虽然细节但在大key场景下收益是实打实的。6. 常见问题与排查技巧实录6.1s[-1]为什么不会越界很多新手看sdslen第一反应是s[-1]访问的是buf前面一个字节凭什么不会踩到别人的内存答案在于SDS创建的整个过程分配内存时申请的大小是头部 len 1buf前面紧挨着的就是flags字段。所以s[-1]访问的其实是SDS自己头部的一部分完全在自己的合法内存范围内。这要求SDS的头部必须是packed的不能有对齐填充。这也是我前面强调__attribute__((packed))的原因。如果编译器在结构体成员之间插入了填充字节s[-1]取到的就不是flags而是填充字节整个类型判断就全乱了。6.2 用strlen量SDS长度为什么结果经常不对我在刚接触Redis时犯过这个错拿到一个SDS的char*图省事直接拿strlen去量长度。结果往Redis里存了一段二进制数据量出来的长度和实际写入长度对不上。这个问题的原因就是上一节说的SDS是二进制安全的buf中间可能有\0strlen遇到\0就停了。正确做法是用sdslen宏。而且在写自己的C扩展模块时尤其要注意这一点。凡是接受SDS作为入参的API内部都会用sdslen获取长度你在自己的代码里也要保持一样的习惯。6.3 扩容后面sds指针可能变了使用sdscat、sdscatprintf等API时如果底层触发扩容原来的sds指针可能指向已经释放的内存因为realloc有可能迁移数据。源码里sdsMakeRoomFor返回的是新地址sdscat系列函数的返回值就是这个新地址。所以正确用法一定是s sdscat(s, hello);而不是sdscat(s, hello); // s可能悬空这是一类很隐蔽的Bug平时不触发一旦触发就是内存崩溃。排查时如果发现Redis扩展模块偶尔段错误先检查有没有忽略SDS扩容函数的返回值。6.4 惰性释放导致的内存“虚高”前面提到过SDS缩短后不会立即释放空闲空间。这在长期运行的Redis进程里会造成一种假象明明数据量不大但内存占用居高不下。排查手段是看INFO内存里的used_memory和used_memory_overhead的变化趋势如果发现大量字符串被缩短但内存没降下来就要考虑是不是SDS的alloc大于len造成的。Redis 4.0之后的MEMORY DOCTOR命令会检查这类问题报告里有一项就是“分配了大量但未使用的内存”。遇到这种情况可以让相关的key过期重建或者主动执行sdsRemoveFreeSpace在源码级别来缩容。业务侧如果无法控制最直接的策略是定期把大字符串重写一遍把SDS的容量压缩到和内容匹配。6.5 头部类型变化对内存碎片的影响SDS头部从3字节跳到5字节再跳到9字节如果操作系统内存分配器的最小分配粒度是8字节或16字节那么头部大小变化不一定立刻反映在实际物理内存占用上。jemalloc通常会把内存按size class管理比如16、32、48、64字节这种档位。一个SDS如果内容是20字节头3字节实际分配可能是32字节或48字节多出来的空间会被视为碎片。所以做Redis容量规划时不能简单地用“SDS头部 数据长度”来算要考虑内存分配器的size class。线上真有这种案例一个Redis实例存了大量27字节的短value按理说配sdshdr8头3字节总共30字节但jemalloc的64字节档位直接多占了34字节内存利用效率勉强一半。遇到这种场景可以适当把value内容补齐到接近size class边界反而能减少碎片。写在最后的实操心得Redis的SDS我前前后后读了不下五遍每次都有新收获。最初以为它就是一个“带长度的字符串数组”真正吃透之后才明白它的每一个字段、每一种类型、每一个宏都是根据真实业务场景掐着算出来的。读SDS的源码某种程度上是在读Redis作者对“如何用最优成本解决实际问题”的思考过程。如果你正在准备面试SDS几乎是必考点但大多数候选人只背得出“O(1)获取长度”“预分配”“惰性释放”这几个结论一问到sdshdr5的长度上限是多少、embstr为什么是44字节、s[-1]为什么不会越界就卡壳了。这篇把这些底层细节都摊开讲了。如果你是在做Redis二次开发或者排查线上问题建议把sds.c里sdsnewlen、sdsMakeRoomFor、sdsRemoveFreeSpace这三个函数一行一行读懂基本就能掌握SDS所有的行为规律了。后面我还会继续写Redis底层专题的其它内容比如dict哈希表、ziplist/listpack、quicklist、intset。每一个拿出来单独拆都比网上那些“面试八股文”更有嚼劲。

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

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

免费获取报价 →
↑