ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Redis String底层揭秘:SDS如何解决C字符串的三大痛点

Redis String底层揭秘:SDS如何解决C字符串的三大痛点 1. 从一个诡异问题说起String 在 Redis 里到底存的是什么我在排查线上问题时遇到过这么一件事一个同事往 Redis 里存了一段带\x00的二进制数据结果取出来发现后半段没了。他很困惑地问“Redis 的 String 不是二进制安全的吗怎么还会有截断”这个问题其实问到了 Redis 源码里一个非常核心的设计Redis 的 String 类型底层并不是直接用 C 语言里的字符串而是一种叫 SDSSimple Dynamic String简单动态字符串的结构。当时我让他去翻源码里sds.h和sds.c这两个文件顺着sds这个结构体往下挖了一遍他才明白问题出在哪里——他存的不是 Redis String而是自己在别处把数据截断了。但这个排查过程让我意识到很多日常用 Redis 的人对 String 底层的认识其实还停留在“不就一个字符串嘛”的层面。这篇文章我想认认真真把这件事讲透。你会看到 Redis 为什么放着现成的 C 字符串不用非得自己搞一套 SDSSDS 的源码是怎么设计的它在内存分配、扩容、二进制安全上做了哪些“反直觉”的取舍以及这些底层设计如何直接影响你写业务代码时的性能。适合想深入理解 Redis 原理的开发者也适合那些被面试官问过“Redis 的 String 和 C 字符串有什么区别”之后回来补课的同学。看完之后你会发现SDS 看起来只是多存了两个字段实际上是在性能、安全、内存效率之间做了一整套精妙的权衡。理解了它你对 Redis“快”的认知会从“它用 C 写的所以快”升级到“它在细节上把 C 的坑全填了所以快”。2. 先看懂 C 字符串的软肋才知道 SDS 在解决什么2.1 strlen 的 O(n) 陷阱每次拿长度都要遍历C 语言标准库里没有真正的字符串类型它用char[]数组 结尾的\0来表示字符串。strlen这个函数要统计长度只能从起始地址开始一个字节一个字节地往后数直到遇到\0为止。也就是说拿到一个字符串的长度是 O(n) 的n 是字符串本身的长度。单看这个操作好像也不算什么但在 Redis 这种纯内存数据库场景下这个代价被无限放大了。Redis 的多数命令都需要先拿到 key 的长度做哈希、需要比较 value 的长度做内存统计如果每次都要 O(n) 扫一遍数据库的吞吐量会被拖垮。在真实业务里SET一个几 KB 的字符串、然后反复执行APPEND、STRLEN的场景非常常见这种场景下每次STRLEN都去遍历整个字符串显然是不能接受的。SDS 的解决方案很直接在结构体里专门存一个len字段字符串多长这个字段就是多少取长度时直接返回lenO(1) 搞定。你去看sdslen这个函数的实现里面根本没有遍历就是sh-len这样的一个字段读取。2.2 二进制安全C 字符串在\0面前直接投降C 字符串以\0作为终止符这带来一个很麻烦的问题如果字符串中间嵌入了\0比如一段序列化后的二进制数据、一张压缩图片、一个用memcpy拷贝过来的结构体C 字符串函数会把\0当成字符串的结尾后面的内容全被丢掉了。Redis 是内存数据库远不止存文本。很多人用它来缓存序列化后的对象比如JSON、protobuf、MessagePack这些格式的二进制内容里完全可能包含\0字节。如果底层沿用 C 字符串那存进去一段包含\0的数据取出来就少了半截这是毁灭性的问题。SDS 靠len字段解决这个痛点它不依赖\0判断字符串是否结束而是以len为准。buf[]数组里存什么就是什么即使中间有\0也只是一个普通字节不影响读取。你可以把 SDS 理解成“带长度的字节数组”什么都能存这也是 Redis 官方强调的“二进制安全”的含义。2.3 修改字符串时的内存隐患越界与频繁分配C 字符串的另一个经典问题是拼接和修改时的内存管理完全靠程序员自觉。strcat(dest, src)不会检查dest的空间够不够一旦dest容量不足就会发生缓冲区溢出轻则踩坏相邻内存重则成为安全漏洞。strcpy类似拷贝前不检查目标空间。这俩函数在开源项目里已经“黑化”很久了安全编码规范基本都要求禁用。Redis 的APPEND、SETRANGE、GETSET等命令都会修改字符串内容开发者在源码里不可能每次都手算目标空间够不够——工程量太大还容易出错。SDS 的做法是封装内存管理需要扩容时自动分配空间不够时自动扩容同时通过free字段记录剩余空间写操作前先检查空间是否足够不够就扩容从机制上杜绝了缓冲区溢出。还有一个更隐蔽的性能问题是频繁内存分配。C 字符串每次拼接如果原数组空间不够就得realloc重新分配内存把旧内容拷贝过去释放旧内存。realloc本身是系统调用频繁触发是很贵的。SDS 在扩容时不只分配刚好的空间而是“多分配一些”作为预留这样后续多次追加操作可能就不需要再次分配内存了。这个预分配策略是 SDS 性能优势的重要来源后面我会展开讲。2.4 为什么 Redis 不能直接复用 C 字符串的库函数看到这里可能有人会问C 字符串的库函数确实有这些问题但 Redis 可以在使用的时候自己注意点比如动态分配空间、手动维护一个长度变量那不就行了吗理论上可以但实际上你会在代码里到处看到这样重复且容易出错的逻辑每次修改前手动算长度、手动 realloc、手动检查\0整个逻辑交织在业务代码里可维护性极差。C 标准库里的string.h提供的是一套通用接口它不会替你考虑“这个字符串将来还要追加多少内容”也不会区分“长度字段和分配容量字段”。Redis 的解决思路很典型不如我直接设计一个新的字符串结构把所有坑都提前填了。这个结构就是 SDS。它既可以当作 C 字符串来用结尾保留\0兼容部分 C 库函数又额外提供了长度、容量、二进制安全等能力。源码注释里明确说了SDS 的设计目标就是简单、安全、高效。这六个字在sds.h的注释里写着但代码里体现出来的权衡远比这六个字复杂。3. SDS 源码逐行拆解Redis 自己造的字符串轮子3.1 五种 Header同一个 String不同的“马甲”SDS 头部的定义在sds.h里长这样struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 3 lsb of type, and 5 msb of string length */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* used */ uint8_t alloc; /* excluding the header and null terminator */ unsigned char flags; /* 3 lsb of type, 5 unused bits */ 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[]; };注意这里的关键点SDS 并不是只有一种头部而是有sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64五种。sdshdr8里的len和alloc是uint8_t也就是各占 1 字节sdshdr16里各占 2 字节以此类推。为什么要搞五种因为 Redis 想省内存。如果你只存一个长度不超过 255 的短字符串用 8 字节的头部就够了没必要用 64 位的字段去记录长度。Redis 在创建 SDS 时会根据初始字符串的长度选择合适的 header 类型长度小于15的用sdshdr5小于18的用sdshdr8小于116的用sdshdr16以此类推。这个设计在源码里叫“按需分配”是 Redis 在内存利用上做的第一个小细节。补充说明sdshdr5这个类型有些特殊它的flags字段的 5 个高位直接用来存储字符串长度没有单独的len和alloc。也就是说它的上限是 31 字节而且不支持扩容一旦超过就会被升级为sdshdr8。在 Redis 7.0 之前sdshdr5在部分设计中不主动使用后面版本里已经调整了使用场景。3.2__packed__和buf[]内存对齐与柔性数组的微妙关系__attribute__ ((__packed__))是 GCC 提供的编译指令作用是让结构体按 1 字节对齐取消编译器默认的内存对齐优化。如果不加这个指令sdshdr16结构体的大小会因为对齐问题变成 6 字节uint16_t len占 2、uint16_t alloc占 2、unsigned char flags占 1再加上 1 字节 padding 填充而加了packed之后就是 5 字节。这里为什么要跟编译器对着干因为 SDS 的内存布局非常讲究结构体后面紧跟的buf[]是柔性数组它不占结构体空间直接挂在头部后面。如果头部有填充字节buf[]的起始地址就会偏移多出几个字节不仅浪费内存还会让sds指针与头部之间的相对位置计算变得复杂。通过packed强制紧凑布局Redis 保证了buf[]正好紧挨着flags没有任何空洞。你去看sds.c里的宏定义会有类似这种操作#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))它把指向buf[]的指针往回偏移一个头部的长度就能拿到结构体头部的起始位置。这一切都建立在一个前提上头部大小是固定的、没有对齐填充的否则偏移量就算不对。3.3 二进制安全的底层实现为什么它能存\0SDS 的buf[]是一个字节数组但它同时保留了一个\0结尾这样做的目的是兼容 C 字符串函数比如strcmp、strcpy可以直接用在sds的buf上仅在你知道内容是纯文本的情况下。真正的关键在于SDS 判断字符串是否结束看的是len不是\0。比如你创建一个 SDS 存储内容ab\0cd它的len是 5buf数组里五个字节分别是a b \0 c d最后一位还有一个真实的\0作为“安全后缀”。当sdslen被调用时返回的是 5而不是遇到\0就停下的 2。所有 SDS 的读写接口都会严格基于len来操作这保证了二进制数据在底层可以原样存储。这个“安全后缀”也是一处细节它在sds.c的sdsnewlen函数里分配内存时会额外多分配 1 字节专门放\0。这个\0不参与len的计算纯粹是为了兼容性。3.4 扩容策略小于 1MB 翻倍大于 1MB 加 1MBSDS 最核心的性能机制在sdsMakeRoomFor函数它负责在追加内容前确保有足够的空闲空间。我摘一段核心逻辑sds sdsMakeRoomFor(sds s, size_t addlen) { ... if (avail addlen) return s; len sdslen(s); sh (char*)s - sdsHdrSize(oldtype); newlen (len addlen); if (newlen SDS_MAX_PREALLOC) newlen * 2; else newlen SDS_MAX_PREALLOC; ... }这里的SDS_MAX_PREALLOC是 1MB1024*1024。逻辑很简单如果要扩容后的总长度小于 1MB就按新长度的 2 倍分配如果大于等于 1MB就只额外增加 1MB。为什么是 1MB 做分水岭因为在小于 1MB 的时候翻倍扩容的额外空间不大但能显著减少后续再次分配内存的次数。比如一个 10 字节的字符串追加 20 字节扩容后直接分配到 60 字节newlen 30乘以 2 是 60下次再追加 30 字节以内就完全不需要重新分配。而当字符串超过 1MB 后翻倍带来的额外内存增长速度太快可能造成很大的浪费所以改为固定追加 1MB让内存增长平缓可控。我曾经在本地做过一个对比实验向一个空字符串连续APPEND100 万次每次追加一个小字段。结果在默认参数下通过info memory观察内存波动很平稳。随后我把SDS_MAX_PREALLOC临时改成一个极小值再编译测试内存分配次数明显上升性能下降肉眼可见。这个细节证明了预分配策略在实际场景中的价值。注意如果你用 Redis 存储超大字符串比如几十 MB 甚至上百 MB预分配策略会显得“保守”。比如在 50MB 的字符串上追加 1MB实际分配 51MB并不会翻倍到 100MB这是为了保护内存不被无谓占用。Redis 宁可牺牲一点追加性能也不愿意膨胀到用户预期之外的内存消耗。4. 三种编码切换int、embstr、raw 到底怎么选4.1 临界值 44 字节的推导过程SDS 解决了“字符串内部怎么存”的问题但 Redis 还得面对“对象本身怎么存”的问题。字符串对象在 Redis 内部有三种编码int、embstr、raw。很多人在面试题里看到过“44 字节”这个数字但不知道它是怎么来的。先看redisObject的定义所有 Redis 对象都会带这层结构typedef struct redisObject { unsigned type:4; unsigned encoding:4; unsigned lru:LRU_BITS; int refcount; void *ptr; } robj;type占 4 位encoding占 4 位lru占 24 位合计 4 字节再加refcount4 字节ptr8 字节总共是 16 字节。embstr编码要求redisObject和 SDS 结构体在连续的一块内存里一次性分配一次性释放。所以 Redis 会计算redisObject16 字节 sdshdr8头部 3 字节 结尾\01 字节 20 字节。Redis 内存分配默认使用 jemalloc它有一个 64 字节的小块分配桶。64 减掉 20 字节的头部成本剩 44 字节给数据。也就是说当字符串长度小于等于 44 字节时embstr编码可以完整装进一个 64 字节的 jemalloc 分配单元里这是效率最高的方案。超过 44 字节就改用raw编码redisObject和 SDS 分开分配各管各的。4.2embstr是只读的为什么修改操作会触发转换embstr有个特性容易被忽略它被认为是只读的。你可能会想字符串不是可以APPEND修改吗实际上Redis 对embstr执行任何修改操作前都会先把它转成raw再执行修改。embstr本身不允许原地扩容。为什么这么设计因为embstr的内存是和redisObject连在一起的长度固定。一旦需要扩容无法在原有位置扩大必须重新分配一整块更大的连续内存再把redisObject和 SDS 一起搬过去。与其做这种复杂的原地升级不如直接创建一个新的raw对象复制内容再修改。虽然多了一次内存分配和拷贝但代码逻辑清晰了很多而且只影响小对象。我遇到过不少业务方的疑问为啥我的 key 的 value 明明只有几个字符只是执行了一次APPEND它的object encoding就从embstr变成了raw这就是原因。如果你特别在意编码状态尽量避免对短字符串反复执行修改操作或者改用SETRANGE时也要意识到同样的问题。4.3int编码Redis 比你想的更会省第三种编码int最容易被忽略。当字符串内容能被解析为 long 型整数时Redis 不会创建 SDS而是直接把整数存进redisObject的ptr字段里。注意ptr本来是一个指针但这里被复用来存储整数值省掉了整个 SDS 结构。比如你执行SET num 123456Redis 在做编码选择时发现123456可以解析为 long于是直接以int编码存储。这个设计对计数器类业务非常友好比如INCR、DECR、INCRBY这类命令它们操作的对象如果本身就是int编码就能直接在整数上运算不需要重新解析字符串。不过要注意一旦这个“整数”超出 long 的范围或者执行了APPEND等字符串操作编码会立即变成raw。我之前遇到一个需求用户用一个大整数做订单号超过了 long 范围结果发现存进去是字符串编码INCR操作直接报错。这提醒我们用 Redis 做自增 ID 时要保证数值在 long 范围内通常LONG_MAX是 9223372036854775807超过这个值就不能依赖INCR了。4.4 编码选择的判断逻辑在源码哪里这个选择逻辑主要在object.c的tryObjectEncoding函数里。大致流程是检查对象类型是否是字符串类型不是就直接返回。检查能否转为 long能转就尝试用int编码。不能转 long再看字符串长度小于等于 44 字节用embstr否则用raw。如果对象原来的编码已经是embstr或者raw且新编码和旧编码一致就复用原对象避免不必要的内存操作。这里的第 4 条也是一个优化点如果你连续多次SET同一个 key值都没有超过 44 字节Redis 不需要反复重新分配内存它会复用原来的对象。这种“能省则省”的思路贯穿整个源码。5. 源码之外SDS 设计对日常使用的影响5.1 内存碎片与 jemalloc为什么used_memory比实际数据大理解了 SDS 的头部和redisObject结构之后你会发现 Redis 里存一个简单的hello实际消耗的内存远不止 5 字节。redisObject16 字节、sdshdr83 字节、缓冲区里的\01 字节数据 5 字节基础成本已经 25 字节。如果数据正好落在 jemalloc 的 32 字节分配桶里那就直接占 32 字节。这个“基础开销比数据本身的字节数还高”的现象在存储海量短字符串时特别明显。我在一次调优中统计过线上服务 key 的平均长度很短十几个字节value 也很短结果 Redis 的used_memory比纯数据求和多出了将近 60%。很多人第一反应是“内存泄漏”其实是对象头和内存分配器的固定开销。要验证这个可以用redis-cli --stat观察used_memory和used_memory_rss再用memory usage key单独看某个 key 的实际代价。对于短字符串密集场景可以考虑改用 Hash 结构field 复用同一个对象头能把内存效率提升不少。5.2APPEND与预分配频繁追加为什么不会撑爆内存SDS 的预分配策略保证了大多数APPEND操作不需要重新分配内存但极端场景下要注意“分配了但没用到”的内存也算在used_memory里。比如你执行APPEND往一个短字符串里追加了 100 次每次追加 1 字节扩容时第一次可能会翻倍到几十字节第二次翻倍到上百字节这个增长速度在趋势上是平缓的。而如果一次追加 100KB 数据扩容逻辑会直接按newlen 1MB或newlen * 2分配可能一下子多出几百 KB 的空闲空间但这些空间并不全是“浪费”——后续继续追加时这些空闲就被消化掉了。如果你实在在意这个可以用MEMORY DOCTOR检查一下内存碎片率用INFO memory看mem_fragmentation_ratio。正常情况下在 1.0 到 1.5 之间算合理如果高得离谱再看看是不是有大量短生命周期的小字符串频繁创建/释放。实操心得我在做缓存清理时发现一个值约 200 字节的 key经过多次APPEND后实际占用可能达到 300 多字节原因就是 SDS 的预分配。所以如果你要存储的内容在写入前长度就确定了建议直接用SET一次性写入不要用APPEND拼能省掉不必要的扩容空间。5.3 大 key 排查超过 512MB 会发生什么Redis 的 String 类型有上限单个 value 最大 512MB。很多人是在写入大文件或缓存大块数据时触发这个限制的。源码里checkStringLength这个函数专门做这个检查逻辑很简单如果新长度超过 512MBPROTO_MAX_STRING_LEN直接返回一个错误。大 key 本身也是个坑。我之前的服务中有一个 key 存了接近 200MB 的数据读取倒不算太慢但每次执行DEL时主线程会花费很长时间来释放这块内存导致其他命令明显卡顿。后来改用UNLINK异步删除才解决。这跟 SDS 本身没有直接关系但大 String 的底层就是一大块连续内存释放它需要时间。如果你们有大 key 场景务必用异步删除或者拆分到多个 key 里。5.4 为什么INCR那么快int 编码的功劳INCR命令的性能极高除了 Redis 单线程事件循环带来的原子性之外int编码也是一个关键因素。当值以int编码存储时INCR直接读取ptr里的 long 值加一再写回全程没有字符串解析、没有内存分配。如果值是字符串编码INCR就需要先把字符串转成 long变成数字执行运算再转成字符串编码可能还会在int和raw之间切换成本高很多。我在压测里观察到一个有意思的现象对int编码的 key 执行INCRQPS 比对字符串编码的 key 执行INCR高出 20% 左右。所以如果你想发挥 Redis 计数器的最大性能初始化时就用SET mycounter 0能转为 long 的字符串不要用SET mycounter 0这样带引号的写法。效果一样但底层编码路径完全不同。6. 踩坑实录关于 String 的常见问题排查6.1 为什么debug object看到的编码和你预想的不一样线上排查时很多人习惯用object encoding key去看某个 key 的编码。这里有几个容易误判的点一个长度 100 的纯数字字符串1234567890123456789012345678901234567890...它的编码是raw不是int因为它超过了 long 的表示范围。一个长度 10 的字符串hello编码是embstr但如果它被APPEND过一次编码就变成raw。空字符串的编码既可以是embstr长度 0小于 44 字节也可能是raw取决于创建路径。遇到“编码和预期不符”的情况先查三点字符串能不能被解析为 long、长度有没有超过 44、之前有没有执行过修改类命令。这条排查路径基本能覆盖 99% 的异常。6.2 存储二进制数据时的“意外截断”回到开头的那个问题为什么存了二进制数据取出来发现被截断了我们用 SDS 的视角再看一遍这个问题的原因往往不在 Redis而在客户端。很多语言的 Redis 客户端在组装命令时用的还是以\0为终止符的字符串函数如果 value 里带\0客户端做协议解析时可能会提前截断。排查技巧用redis-cli --raw直接操作确认数据能不能完整写入和读取。如果redis-cli没问题那问题大概率出在客户端语言对二进制数据的处理上。尤其是在 C/C 的客户端里记得显式传入长度不要依赖字符串终止符。在 Python 里确保用 bytes 类型而不是 str 类型在 Java 里确保用 byte[] 而不是 String。6.3SDS内存开销在短小 value 场景下怎么优化如果你用 Redis 存储大量短小的字符串比如几十个字符的 token、验证码、短链接SDS 的对象头开销会非常显眼。优化方向有两个改用 Hash 结构把多个短字符串聚合成一个对象减少redisObject数量。调整 jemalloc 的分配策略或者直接换用 tcmalloc这需要重新编译 Redis适合对内存效率有极致要求的场景。我在一个验证码存储场景里做过对比原本用 String 存 1 亿个 6 位验证码内存占用约 1.5GB改成 Hash 分片存储后内存降到约 900MB。这个效果直接来源于减少了对象头开销。6.4 关于sdshdr5的一个历史遗留小坑Redis 6.0 及之前sdshdr5在部分版本中其实很少被主动使用导致短字符串的内存优化效果没完全发挥。到了 6.2、7.0sdshdr5的使用策略有调整。如果你在源码分析时发现sdshdr5相关的代码分支晦涩难懂不必太纠结它只是 SDS 体系里的一个“特例”类型。重点理解sdshdr8/16/32/64这套体系就足够覆盖绝大多数业务场景了。还有一个相关的小知识点sdshdr5的flags字段高 5 位直接存长度所以它的长度最大值是 31(15)-1超过就会升级。而这个升级逻辑也不是单纯的“长度超过就换”它会重新走sdsnewlen的创建流程。如果你在做源码二次开发务必留意这个边界否则容易在短字符串上踩到隐形 bug。6.5 从源码角度看SET和SETEX的微妙差异SET和SETEX最终都会创建一个字符串对象但实现路径有一些不同。SET走的是setGenericCommandSETEX走的是带过期时间的版本。它们在底层最终都会调用c-argv[2]来创建或更新字符串对象过期时间的处理是在对象创建之外额外设置的。对 Redis 源码感兴趣的同学可以顺着t_string.c往下读里面能清晰看到setGenericCommand如何根据参数拼接命令、如何检查 NX/XX 条件、如何调用setKey和setExpire。你会发现Redis 的 String 表面上“简单”源码实现里各种分支和边界检查却非常庞杂。读这部分代码时建议带着我前面讲的对象头、SDS、编码选择这三个概念去看会顺畅很多。7. 写在最后的几个经验如果让我总结这篇源码深究里最值得记住的三件事我会说第一Redis 的 String 是一个设计上非常“抠”的结构。从五种sdshdr头部到packed内存布局再到 44 字节的embstr临界点全都是在和内存分配器较劲。Redis 快是因为它在每一个字节上都精打细算。第二理解 SDS 之后很多 Redis 行为不再神秘。为什么APPEND一个短字符串之后编码变成raw为什么used_memory比数据本身大为什么INCR快得不可思议这些问题的答案都有同一个源头底层存储结构决定了上层行为。第三源码分析不能停留在“看懂了”的层面。我的习惯是每分析一个结构就在本地用最小复现脚本验证一遍写个程序用object encoding看编码切换、用memory usage量内存、用slowlog看命令耗时。代码不会骗人但注释会过时博客会失真只有实测结果是最可靠的。最后分享一个小技巧读 Redis 源码时如果某个宏或结构体看不明白去server.h和sds.h里搜它的定义和所有调用点。比如sdslen这个函数在源码里被调用了上千次你随手点开几个调用点看它怎么配合其他函数使用会比单纯盯着定义理解得深得多。这也是我这些年读 C 项目源码积累下来的习惯分享给想深入研究的朋友。
返回列表