
聊到Redis底层绕不开sds。很多人背过“Redis的字符串不是C字符串”但要问他sds里到底存了哪些字段、为什么能O(1)求长度、扩容时怎么分配内存大概率会卡住。我当年第一次面Redis相关岗位面试官上来就问SDS我在纸上画了个结构体就开始胡扯结果被追着问预分配策略和二进制安全直接露馅。后来静下心把sds.h和sds.c通读了一遍又把旧版和新版源码做了对比才算真正想明白。这篇是Redis底层专题的第一篇打算用一整篇的篇幅把sds讲透从数据结构演进、核心机制、二进制安全到它在Redis全局里的位置最后附上我踩过的几个认知误区。适合正在准备Redis面试的人也适合每天用Redis但没读过源码、好奇一个字符串怎么还能写一篇博客的人。1. 为什么Redis要自己再造一套字符串C字符串“够用但不够好”1.1 C字符串的先天缺陷长度和溢出的问题先说一个很多人没细想的事实C语言里的字符串其实不是一种“类型”而是一种约定——以\0结尾的char数组。你想要知道一个字符串有多长没有别的办法只能从开头往后遍历直到碰到那个\0。这个操作的时间复杂度是O(n)。字符串越长遍历越久。我觉得大部分写业务代码的人对这个问题没太大感觉因为strlen用起来太顺手了甚至很多人觉得“字符串嘛天生就该这么求长度”。但Redis不行。Redis里所有的key、大部分value、命令参数、缓冲区本质都是字符串。一个STRLEN命令如果底层真的去遍历一遍遇到一个几MB的value那就不是“快”的问题了而是基本可以确定这个命令会成为性能黑洞。而且Redis是单线程模型一个慢操作会拖累整个实例这比多线程系统里某个线程慢一会儿要严重得多。缓冲区溢出问题更要命。C语言的strcat、sprintf这类函数目标缓冲区到底够不够大完全靠程序员自己保证。如果目标缓冲区长度估算错误数据就会越过边界写到相邻内存里去。平时写个小工具溢出也就溢出了顶多崩一下。但Redis是常驻服务一段网络字节流触发某个命令就可能造成内存破坏这种问题极难排查而且会被人当成安全漏洞来利用。Redis作为一个要跑在生产环境的底层存储绝不允许这种“写越界”靠自觉来规避。1.2 Redis场景下的真实需求不仅是存文本Redis的字符串对象能存的不只是文本。一张图片的二进制内容、一个序列化后的Java对象、一段压缩后的数据都可以作为value塞进去。这些数据的共同点是中间完全可能包含\0字节。而C字符串遇到\0就认为字符串结束了这等于直接把二进制数据判了死刑。还有一层经常被忽略Redis协议RESP本身就是带长度前缀的。服务端从客户端读到一个命令参数先读到的是这个参数有多少个字节然后按长度读取后面的字节。这就意味着Redis从网络层拿到的数据天然就是“长度内容”的形态而不是“内容结尾标记”的形态。如果内部再用C字符串那一套就得反复在两种表示之间转换转换时还得复制白白浪费CPU和内存。再加上Redis对字符串的操作非常频繁。append追加、setrange覆盖、getrange截取、字符串比较、字符串长度统计这些命令在真实业务里几乎天天被调用。一个合格的底层字符串实现至少要满足四个要求快速拿到长度、安全地追加内容、能保存任意二进制数据、内存分配策略足够聪明。C字符串在这四项上不说全军覆没也至少是四项全输。1.3 自己造轮子的边界SDS精确解决了哪些问题因为C字符串在这些核心场景里不顶用Redis的作者才在早期版本里直接实现了SDS全称Simple Dynamic String简单动态字符串。它不是对C字符串做点小修补而是设计了一套新的内存布局让“带长度的可变字节序列”成为一等公民。我把SDS和C字符串的核心差异整理成一张表方便面试前速记对比项C字符串SDS获取长度O(n)遍历O(1)读字段追加内容需要自己保证容量容易越界自动检查容量并扩容二进制数据遇到\0截断不支持按长度存取支持内存重分配每次修改都可能触发预分配惰性释放兼容C字符串函数天生兼容末尾保留\0有限兼容这张表基本就是SDS存在的全部理由。Redis不是闲得没事干非要重复造轮子而是C字符串在Redis的工作负载下每一项都踩在一个大坑里。理解了这一点再看后面SDS的结构设计就顺理成章了。2. SDS的底层数据结构从“一版”到“分级”的演进逻辑2.1 旧版sdshdr的内存布局与柔性数组早期版本的SDS结构大概长这样struct sdshdr { unsigned int len; // 已使用长度不含 \0 unsigned int free; // 剩余可用长度不含 \0 char buf[]; // 柔性数组字符串数据真正存放的地方 };注意最后的char buf[]这是一个柔性数组。它不占结构体本身的空间而是紧接着结构体末尾开始排布。所以一个长度5的字符串“hello”内存上实际是[ len: 5 ][ free: ... ][ h ][ e ][ l ][ l ][ o ][ \0 ]这里有个特别巧妙的点SDS返回给使用者的指针不是指向结构体的起始位置而是直接指向buf的首地址。也就是说你在代码里拿到的sds类型本质上就是一个char*它指向的是字符串内容本身。这样设计的好处是当字符串内容是纯文本时你可以直接把这个指针传给C标准库里的strcmp、strcpy等函数不用再做任何转换。代价就是当你需要操作头部字段时得做一次指针回退((struct sdshdr *)(s - sizeof(struct sdshdr)))。听起来麻烦但封装好了以后使用者根本感觉不到。2.2 3.2后的分级headersdshdr8/16/32/64旧版结构有一个明显浪费len和free都是unsigned int固定4字节。哪怕你只存了一个字符“a”也要为这两个字段支付8字节的固定开销。Redis里小key小value是绝对的大多数这种浪费累积起来非常可观。所以Redis 3.2之后SDS做了一次大改不再用统一的unsigned int而是按字符串长度分档每一档用足够宽的整数来保存长度信息。现在源码里能看到这样一组结构体struct sdshdr8 { uint8_t len; // 已使用长度1字节 uint8_t alloc; // 已分配长度不含头部和\01字节 unsigned char flags; // 低3位保存类型高5位保留 char buf[]; }; struct sdshdr16 { uint16_t len; // 已使用长度2字节 uint16_t alloc; // 已分配长度不含头部和\02字节 unsigned char flags; // 低3位保存类型高5位保留 char buf[]; };对应的类型常量是SDS_TYPE_5长度0~31没有独立的len和alloc字段SDS_TYPE_8长度0~255len和alloc用1字节SDS_TYPE_16长度0~65535len和alloc用2字节SDS_TYPE_32长度0~2^32-1len和alloc用4字节SDS_TYPE_64长度超过4GB时才用到len和alloc用8字节这里要特别说明一下SDS_TYPE_5。它的长度信息其实是塞在flags字节的高5位里的最大只能表示31。因为已经没有独立的len字段了它的“剩余可用空间”恒为0也就是说这种字符串本质上只能作为不可变短字符串存在。一旦对它做追加、覆盖等操作Redis会把它重新分配成SDS_TYPE_8。还有个小细节如果创建一个空字符串也就是长度为0的字符串Redis会直接跳过type5用type8来存避免踩到各种边界判断的坑。2.3 定位header的“路标”flags字段与s[-1]因为不同档位的header大小不一样拿到一个sds指针后怎么知道它到底是哪种类型答案藏在buf前面那个flags字节里。每个新版本header的最后都有一个flags字段紧挨着buf。由于sds指针指向的就是buf首地址那么buf[-1]就一定是flags。读取方式很简单unsigned char flags s[-1];flags的低3位用来存放SDS_TYPE_*常量高5位在type8、16、32、64中是保留位在type5中被用作长度信息。取类型时用掩码unsigned char type flags SDS_TYPE_MASK;拿到type之后就能推算出header的偏移量从而读取len和alloc。这也是为什么在sdslen、sdsavail这类函数里第一步永远是读s[-1]。整个SDS系列的header排版可以理解成这样[ len字段(长度不定) ][ alloc字段(长度不定) ][ flags(1字节) ][ buf ...... ][ \0 ] ^ sds指针指向的位置这样一个“末尾放类型标记数据指针紧跟其后”的设计让不同宽度的header可以共存而使用方不需要在每次操作时都从头解析整个结构。2.4 分级到底省了多少内存一次数学对比拿一个最简单的例子保存字符串“hello”长度5。旧版结构len占4字节free占4字节header共8字节buf需要6字节5个字符加末尾\0。总共14字节。新版使用类型8len占1字节alloc占1字节flags占1字节header共3字节buf仍然是6字节。总共9字节。同样一个字符串内存从14字节降到9字节节省了35%以上。如果Redis实例里存在几千万个小字符串这个数字叠加起来非常惊人。而且我说的是最理想情况实际的RedisObject还有其他固定开销SDS header的优化只是整个内存治理中的一环但确实是性价比极高的一环。这也是为什么读SDS源码时不能只看现在的代码最好把3.2之前的旧版翻出来对比一下。对比之后你就会发现所谓“演进”本质上就是在回答一个问题如何在满足性能的前提下把每一个字节都用到刀刃上。3. 读源码时最该盯住的三个核心机制长度、扩容与惰性释放3.1 长度是O(1)但别小看这个设计SDS最直观的优势就是sdslen()时间复杂度O(1)。它的实现逻辑很简单判断类型然后返回对应header里的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_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; }简单归简单但这是很多上层命令的基础。Redis里STRLEN命令能直接返回长度APPEND命令追加前要快速知道现有长度GETRANGE命令要根据长度判断边界底层全都依赖这个O(1)读取。如果你去翻Redis的性能优化相关PR会频繁看到“avoid strlen by using sdslen”这类改动因为遍历求长度在频繁调用时非常致命。3.2 sdsMakeRoomFor扩容阈值与预分配节奏字符串最常做的操作是追加。sdscat、sdscatlen、sdscatprintf这些函数最终都会走到底层一个叫sdsMakeRoomFor的扩容函数。我直接贴一段核心逻辑sds sdsMakeRoomFor(sds s, size_t addlen) { long long newlen; size_t avail sdsavail(s); // 如果剩余空间足够直接返回不做任何分配 if (avail addlen) return s; // 新长度 当前长度 需要追加的长度 newlen sdslen(s) addlen; // 如果新长度小于 1MB直接翻倍 if (newlen SDS_MAX_PREALLOC) newlen * 2; else // 超过 1MB 后每次额外多分配 1MB newlen SDS_MAX_PREALLOC; // 根据 newlen 重新选择合适 header 类型并分配内存 // 类型变化时需要迁移数据到新 header ... }SDS_MAX_PREALLOC的取值是1024 * 1024也就是1MB。这个策略的本质是小字符串翻倍增长大字符串线性增长。为什么这样设计如果每次追加都只分配刚刚好的内存那么每追加一次就要调用一次realloc而realloc可能发生内存拷贝数据得从旧地址搬到新地址。假设你要在一个空字符串上追加1万次每次追加1字节那么总拷贝量大概是123...10000差不多5000万字节。这是O(n^2)级别的开销完全不可接受。采用翻倍策略之后扩容次数从O(n)降到了O(log n)总的拷贝量也摊下来了均摊复杂度接近O(1)。这和C里vector的动态扩容原理一模一样。至于为什么超过1MB后不再翻倍因为这种大字符串通常是一次性写入的临时数据如果翻倍500MB的字符串可能直接多预留500MB内存太浪费。所以大字符串每次只多给1MB够用但不至于失控。这里还要注意一个隐藏的步骤当字符串长度从255变成256时类型必须从sdshdr8升级到sdshdr16因为8位无符号整数最多只能表示255。升级意味着整个header要重新构建旧数据要拷贝到新buf里。这个过程不是简单的原地扩容但sdsMakeRoomFor会把这一切封装好使用者只需要保证拿到返回的新指针。3.3 惰性释放缩短字符串不等于把内存还给系统SDS另一个容易被忽略的机制是惰性空间释放。所谓惰性就是当你把字符串缩短时并不立即释放多余的内存。比如sdstrim函数用来去掉字符串两端指定的字符sdsrange用来截取子串。这些操作执行完后底层会怎么做答案是只更新len字段把多余的数据挪到前面但alloc不变。那些被“截掉”的空间依然被这个sds占用着只不过现在变成了剩余可用空间。这样设计的好处是如果后面又要对这个字符串做追加操作可以直接利用这部分剩余空间不用重新malloc。想象一下一个字符串本来有几十KB用SETRANGE或LTRIM这类操作把它缩短到几十字节如果每次缩短都立刻把内存还给系统那后面再次增长时又得重新分配一缩一伸之间全是内存拷贝。如果你确实想把多余空间还给内存SDS提供了sdsRemoveFreeSpace函数。它会重新分配一个刚好能装下当前内容的缓冲区并把数据迁过去。我在实际项目中见过有人循环调用sdstrim和sdsRemoveFreeSpace来反复处理日志结果性能极差因为频繁的重新分配加剧了内存碎片。正确的做法是短时间内的多次截断先用着预分配空间等确定字符串最终形态后再考虑是否要归还。3.4 一次header迁移从sdshdr8升级到sdshdr16时发生了什么这是最容易让初学者懵掉的地方。假设一个SDS当前是用sdshdr8存储的长度已经到了250现在要追加20字节。新长度变成270已经超过255必须换到sdshdr16。sdsMakeRoomFor内部会重新计算需要的新header类型然后分配一整块新内存。内存布局是新的header加上新的buf。随后旧buf里的内容被拷贝到新buf旧内存被释放返回新sds指针。这个过程中原来那个sds指针会失效所以源码里所有可能发生扩容的调用都要求你重新接收返回值。我见过很多人刚接触Redis源码时写出类似sdscat(s, foo);这种代码然后疑惑为什么字符串没变。就是因为扩容发生在新分配的内存上老的s指针还指着旧地址。下面第四节我会细讲这个坑。4. “二进制安全”和“兼容C字符串函数”如何同时成立4.1 二进制安全到底是什么意思“二进制安全”这四个字字面意思比较绕翻译成人话就是这个字符串实现可以安全地保存任意二进制数据包括中间可能出现的\0。数据最终长什么样是原始字节序列的真实反映不会被任何约定给截断或篡改。C字符串做不到这件事因为它靠\0来判断结尾。如果你往里面塞了一个包含0x00字节的图片内容那么从第一个0x00开始后面的一切数据都会被丢弃。SDS能做到是因为它完全依赖len字段来记录实际长度。底层写入数据时写多少字节就更新len为多少读取数据时也只看len不看\0。举个例子// 构造一个包含3个字节的数据第一个字节就是0x00 sds bin sdsnewlen(\x00\x01\x02, 3); strlen(bin); // 结果是0因为strlen遇到第一个\0就停了 sdslen(bin); // 结果是3这才是真实长度这个例子每次演示都有奇效因为它能一瞬间说明白C字符串和SDS的本质差异。4.2 兼容C函数是有条件的一个关键区别SDS有一个看上去和“二进制安全”矛盾的设计它在buf的末尾始终会补一个\0。但要注意这个\0不计入len它只是作为一种保险丝让SDS在保存纯文本时可以直接被当成C字符串使用。比如Redis源码里经常把sds直接传给strcasecmp做大小写不敏感的比较因为普通的命令名、key这些内容一般不会包含\0。这就是兼容C字符串函数的价值在只读场景下可以少写很多代码不用先复制再比较。但你必须记住一个前提只有当字符串内容里本身不包含\0时才适合把它当成C字符串处理。一旦数据里含有\0strlen、strcmp、strcat这些函数全都会误解它们会在第一个\0处停止工作。这不算SDS的bug而是使用者的认知误区。SDS的“兼容C字符串”是一种能力不是一个无条件承诺。4.3 哪些sds API是二进制安全的哪些不是我在读源码时养成了一个习惯每个API先看函数签名里有没有长度参数。一般来说显式传入长度或内部用sdslen的API都是二进制安全的依赖strlen、strcmp、sprintf的API则不是。简单分类一下sdsnewlen、sdscatlen、sdscpylen支持指定长度二进制安全。sdstrim、sdsrange、sdscmp内部按len处理二进制安全。sdscatprintf、sdscatvprintf走的是printf家族遇到\0会截断只适合文本格式化。直接调用C库函数处理sds的场景比如strlen(s)、strcmp(s, abc)只适合确认不含\0的字符串。所以如果你把一段序列化对象塞进SDS里想统计长度时千万别用strlen要用sdslen。想比较两个SDS是否相等用sdscmp不要用strcmp。这是二进制安全这条路上最常见的两个坑。5. 从SDS到Redis整体字符串对象、缓冲区与真实应用场景5.1 字符串对象的int、embstr、raw三种编码SDS在Redis里最直接的应用就是String类型底层的数据结构。但Redis的String并不总是用SDS它有三种编码方式int、embstr、raw。int编码当value可以被解析成整数时Redis直接把long值存在RedisObject里根本不用字符串。embstr编码当字符串长度较短时RedisObject和SDS的header、buf一次性分配在同一块连续内存里。raw编码当字符串较长时RedisObject单独分配一次SDS再单独分配一次共两次malloc。这个编码选择是动态的。SET命令写入一个整数底层可能是int编码用APPEND命令往同一个key后面追加字符就会从int编码迁移到raw或embstr编码。无论哪种编码只要真正需要存储字符串内容最终都绕不开SDS。5.2 为什么embstr阈值是44一道关于缓存行的数学题Redis 3.2之后embstr编码的阈值是44字节。这个数字不是拍脑袋定的它背后是缓存行和内存分配的双重考虑。64位系统下RedisObject本身大约是16字节。而一个SDS如果使用sdshdr8header是3字节len、alloc、flags各1字节。为了字符串末尾还有那个\0buf实际占用长度是“字符串长度1”字节。把这三块加起来如果有44字节的字符串内容内存占用是16RedisObject 3sdshdr8 44字符串内容 1\0 64而64字节正好是主流CPU一条缓存行的大小。也就是说RedisObject和整个SDS可以一次性被加载进一条缓存行访问效率非常高。在Redis 3.2之前SDS header是8字节同样的64字节等式算下来embstr阈值就是39。SDS header变小之后阈值才从39提高到44。很多讲Redis缓存行的文章只提“44字节阈值”但不知道这个阈值和SDS header大小直接相关。你只有把新版分级header和embstr编码放在一起看才能把这个知识点串成线。5.3 SDS在Redis内部“跑龙套”的真实岗位除了String类型的valueSDS还悄悄出现在很多Redis内部组件里。我列几个我读源码时注意到的位置数据库字典的key每个key在底层字典中是一个sds。Hash、Set、ZSet中的元素集合类型里的成员、哈希表中的field和value在非压缩编码下大多用SDS保存。客户端输入缓冲区旧版Redis里客户端发来的命令参数会先读到sds里再解析成argv。AOF缓冲区AOF追加写入的数据拼装在sds里攒到一定量后一次性落盘。慢查询日志记录命令参数时会临时用sds把所有参数拼接起来。有一个很形象的类比如果把Redis比作一个大型餐厅RedisObject是餐盘SDS就是那个会被反复清洗、反复装菜的餐盒。它不是只有一道菜在用而是整个后厨的通用容器。5.4 一个高频疑问SDS都这样了为什么Redis命令还是O(n)有人可能会产生一个错觉既然SDS这么高效那Redis所有字符串操作都应该是O(1)了。其实不是。STRLEN是O(1)但APPEND、SETRANGE这些命令的复杂度仍然是O(n)因为需要在内存中移动或复制数据。SDS的预分配只能让扩容次数变少不能凭空消除数据拷贝。就好比你搬家时纸箱可以提前多准备几个但把东西从旧家搬到新家该搬多少件还是多少件。从使用者角度理解这个区别很重要SDS解决的是“内存管理过于频繁”的问题不是“字符串操作本身复杂度为零”的问题。它让那些必须O(n)的操作变得更加可控但不会改变操作本身的本质。6. 踩坑与经验我在看SDS源码时踩过的几个认知误区6.1 误区一free(s)直接释放结果崩了第一次用SDS时我以为sds就是普通的char*所以手动调用free(s)去释放。结果程序直接崩溃或者更糟不崩溃但内存泄漏。原因前面提过sds指针指向的是buf首地址而不是整个内存块的起始地址。malloc返回的地址是整个结构体的开头包括header。直接free一个指向中间区域的指针C标准库会认为你传的是一个非法堆指针。正确做法是调用SDS提供的sdsfree函数void sdsfree(sds s) { if (s NULL) return; // 内部根据类型算出header大小再释放真正起始地址 zfree(s - sdsHdrSize(s[-1] SDS_TYPE_MASK)); }从这个坑也能意识到SDS并不是“一个返回char*的普通动态数组”它是一套完整的内存布局约定头信息藏在数据前面释放时必须按约定回退。6.2 误区二忽略sdscat的返回值这个坑隐藏得更深。很多初学SDS的人写代码sds s sdsempty(); sdscat(s, hello); // 危险返回值被丢弃如果追加“hello”时sds内部剩余空间够用那么函数内部可能不分配新内存指针没变程序看起来正常运行。但如果不够用sdsMakeRoomFor会分配新的内存并把旧数据拷贝过去此时原来的s指针指向的可能是已经被释放的旧内存。继续使用旧指针轻则内容丢失重则访问悬垂指针。我再强调一遍所有可能修改字符串内容的API几乎都会返回新的sds指针必须重新赋值。s sdscat(s, hello);这个习惯一开始就要养成别等到线上出现诡异问题才回头改。6.3 误区三把strlen当成sdslen用我在自己写的临时脚本里犯过这个错误也见过不少新同事犯。原因很自然SDS的buf末尾有\0给人的第一印象是“这货可以当C字符串用”。对于普通文本它确实可以但如果后端存的是二进制数据比如一个图片、一段序列化对象用strlen(s)拿到的是第一个\0出现的位置而不是真实长度。所以我的习惯是只要是SDS一律用sdslen(s)只有当我能确认内容就是纯文本时才允许在某些只读场景下把它交给C字符串函数。这不是教条是防止某天一个埋了二进制数据的key突然混进来把整个逻辑带崩。6.4 动手实验观察SDS的avail增长曲线光看文字还是有点抽象。我建议你抽出半小时把sds.h和sds.c从一个Redis源码包里摘出来再给它们配一套最简单的malloc/free实现然后跑一个小实验#include stdio.h #include sds.h int main(void) { sds s sdsempty(); for (int i 1; i 1000; i) { s sdscat(s, a); if (i % 100 0) { printf(len%zu avail%zu alloc%zu\n, sdslen(s), sdsavail(s), sdsalloc(s)); } } sdsfree(s); return 0; }你会在输出里看到一个非常规律的现象长度还在1MB以内时avail会跟着翻倍增长一旦alloc超过1MBavail每轮都固定增加大约1MB。这就是SDS_MAX_PREALLOC策略最直观的呈现。看完这个输出再回头看sdsMakeRoomFor的源码会有一种“原来如此”的感觉。这个实验也适合拿来验证一个细节sdsavail返回的是剩余可用空间不是总分配空间。很多面试题喜欢考这一点你亲手跑一遍比背十遍都记得牢。到这一步SDS的底层结构、核心机制、二进制安全特性、在Redis全局中的位置以及实际使用中的那些坑基本就都过了一遍。下一篇专题我打算接着聊RedisObject和内存编码之间的切换逻辑把字符串对象在不同编码间“变形”的完整过程拆开看。