ARTICLE DETAIL

资讯详情

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

Redis为什么快?一文拆透SDS数据结构与扩容机制

Redis为什么快?一文拆透SDS数据结构与扩容机制 背过 Redis 面试题的人八成遇过这个问题Redis 为什么快答案里一定会有一条“采用了 SDS 而不是 C 字符串”。但 SDS 到底是个什么东西为什么 Redis 放着现成的 char* 不用非要自己搞一套字符串加上几个字段就能快这么多能讲清楚的人其实并不多。这篇就走源码层面把 SDS 彻底拆开从设计动机、数据结构、扩容缩容机制到实战命令验证一次讲透。适合正在啃 Redis 源码、准备面试、或者天天用 Redis 但没搞明白底层的人。1. 为什么 Redis 非要自己造一个字符串SDS 的设计动机很多初学者第一次看到 SDS 都会有个疑问C 语言里字符串不就是 char* 吗Redis 直接用不行吗不行。C 字符串那套“以 \0 结尾”的约定放在数据库这个场景下会出大问题。数据库里的字符串是要被频繁读取、修改、拼接、持久化的每一条慢操作都会直接放大成线上事故。1.1 C 字符串的三个致命伤第一个是获取长度的时间复杂度。C 字符串不记录长度想知道一个字符串多长只能从头遍历到 \0这是一个 O(n) 操作。Redis 作为内存数据库动不动就是几十万 QPS每次取长度都要扫一遍字符串CPU 肯定吃不消。而且很多命令STRLEN、APPEND、SETRANGE都依赖快速拿到长度如果这一步是 O(n)整个命令的复杂度都会被拖上去。第二个问题更致命缓冲区溢出。C 语言里拼接字符串用 strcat如果目标字符串的空间不够它会直接越界写把数据写到相邻内存里去。Redis 如果直接用 char*我们要自己写一堆溢出检查逻辑每次拼接前手动算一遍空间够不够这不仅啰嗦还容易漏。真实环境里这种问题一旦发生排查难度极高内存已经被写坏了你还不知道是哪一行代码干的。第三个是二进制不安全。C 字符串判断结尾的唯一标准是 \0所以字符串中间一旦出现 \0后面的内容全部会被截断或者被误判。这在我们只存文本的时候没事但 Redis 是要存二进制数据的。图片、序列化对象、压缩包、Redis 主从复制的 RDB 文件这些都是二进制流中间随时可能出现 \0 字节。用 C 字符串存这些东西等于天生少了一个类型。1.2 SDS 给出的答案加字段而不是加逻辑SDSSimple Dynamic String简单动态字符串的解决思路非常朴素既然 C 字符串的短板在于“信息不全”那就把信息补全。在字符数组前面加上一个结构体头里面记录已用长度、分配长度和类型标记让字符串“自己知道自己多长、还有多少空间”。这相当于把很多原本需要调用者手动操心的事情全部收进了数据结构内部。调用者只需要拿到一个 SDS 对象追加、截断、取长度都是 O(1) 级别而且永远不会发生缓冲区溢出因为扩容逻辑已经在内部写死了。更关键的是SDS 的字符数组照样以 \0 结尾所以它能够无缝兼容所有 C 语言的标准字符串函数——这是 Redis 源码里大量复用 libc 函数的基础。一句话总结SDS 不是把 C 字符串推翻重来而是在它上面加了一个“元信息层”用内存换安全和效率。数据库场景下这个交换非常划算。1.3 为什么这和 Redis 的速度口碑强相关Redis 常被夸“快”快在事件模型、快在纯内存、快在用 epoll 管理网络但数据结构本身也不能拖后腿。SDS 让字符串操作的复杂度从 O(n) 降到了 O(1)每次操作省下的时间虽然只有几十纳秒但在单线程模型下每一次 CPU 指令都在关键路径上累积起来就是巨大的吞吐差距。还有一点容易被忽略Redis 是单线程的一旦某个操作慢所有客户端都会排着队等。所以 Redis 对数据结构的每一个实现都有极强的“性能洁癖”SDS 正是这种洁癖的体现。你去看 Redis 源码几乎所有按键、值、集合元素最终都要落到 SDS 上它其实就是 Redis 内部的“通用货币”。2. SDS 的数据结构从源码看它到底长什么样知道了设计动机接下来要上真东西了。SDS 并不是一成不变的Redis 3.2 之前是一套结构3.2 之后做了一次大重构分成了五种类型。很多老博客写的还是旧结构看源码的时候会懵这里把两个版本都讲清楚。2.1 Redis 3.2 之前一个结构走天下老版本的 SDS 结构长这样struct sdshdr { int len; // 已使用长度 int free; // 未使用空间长度 char buf[]; // 柔性数组真正存字符数据的地方 };这个结构的问题在于不管你的字符串是 5 个字节还是 5 万个字节len 和 free 都固定占 4 字节。存储小型字符串时头部的内存开销比例太高了。一个只存“hello”的字符串光头部就占了 8 字节加上数据 5 字节和结尾的 \0总共 14 字节其中超过一半是元信息。Redis 里动辄几十万个小键值对这个浪费会被放大得非常明显。2.2 Redis 3.2 之后按长度分层内存拿来即用新版本把 SDS 拆成了五种类型根据字符串的实际长度选择最小的头部来存储。这事跟 Go 里小对象用不需要 GC 扫描的类型、Rust 里 String 用胖指针是同一个思路减少元信息的内存占用。struct __attribute__ ((__packed__)) sdshdr5 { unsigned char flags; /* 低 3 位存类型高 5 位存长度 */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; /* 已使用长度 */ uint8_t alloc; /* 分配的总容量不包含头部和 \0 */ unsigned char flags; /* 低 3 位存类型 */ char buf[]; }; struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; }; // sdshdr32 和 sdshdr64 同理只是换成了 uint32_t / uint64_t这里有几个细节值得展开。第一__attribute__ ((__packed__))是让结构体按 1 字节对齐不填充 padding保证 SDS 头部之后紧跟字符数据内存布局紧凑而且能通过指针偏移直接定位到 buf。第二sdshdr5 是个特殊存在它没有 len 和 alloc直接把长度塞进了 flags 的高 5 位也就是说它最多只能存 31 字节的字符串而且这个类型是只读的一发生修改就会升级成 sdshdr8。第三buf 是柔性数组不占结构体空间数据就存在结构体的紧后面。2.3 三个核心字段分别管什么事len 字段记录当前字符串实际使用的字节数它让 STRLEN 命令能直接 O(1) 返回结果也是决定 SDS 是二进制安全的那个关键。alloc 字段记录的是分配的总容量注意它不包含头部和末尾的 \0它解决的是“还剩多少空间可以用”这个问题。flags 字段的低 3 位表示类型高 5 位在 sdshdr5 中另有用途。每次容量不足需要扩容时Redis 会同时更新 len 和 alloc保证这两个值的差就是真正的可用空闲空间。理解了这三个字段后SDS 的很多操作都可以用“改字段 拷贝数据”来理解根本不用动整个字符串。2.4 五种类型的边界和选择逻辑类型len/alloc 占位最大长度适用场景sdshdr50 字节31 字节短字符串定值只读sdshdr8各 1 字节255 字节大多数短字符串sdshdr16各 2 字节65535 字节中等长度sdshdr32各 4 字节4GB 左右长字符串sdshdr64各 8 字节极大超大字符串上面的选择逻辑核心一句话用能满足需求的最小头部。字符串从 100 字节长到 300 字节时SDS 会从 sdshdr8 整体迁移到 sdshdr16把老的 buf 拷贝过去并释放旧内存。迁移带来的拷贝开销是存在的但因为长度是单调增长的迁移次数其实很有限。Redis 用这种“拷贝一次、后续受益”的策略换取了日常操作中的低内存开销。3. 核心机制拆解扩容、缩容、二进制安全SDS 最精髓的部分不是结构体本身而是围绕结构体设计的三个机制创建时的初始化、追加时的空间预分配、删除时的惰性释放。这是看完源码回来自己上手实现一个简化版 SDS 时最值得模仿的三个点。3.1 创建从裸数据到 SDS 的第一次变身Redis 创建 SDS 的入口是sdsnewlen它的逻辑可以概括为先算长度长度走几号头部再分配一块“头部 数据 \0”的连续内存然后拷贝数据并设置字段。sds sdsnewlen(const void *init, size_t initlen) { // 根据 initlen 选择类型 char type sdsReqType(initlen); if (type SDS_TYPE_5 initlen 0) type SDS_TYPE_8; // 计算头部大小 int hdrlen sdsHdrSize(type); // 一次性分配连续内存 char *sh s_malloc(hdrlen initlen 1); // 写入类型标记 unsigned char *fp ((unsigned char*)sh) hdrlen - 1; *fp type; // 写入 len 和 alloc // 拷贝数据 // 末尾补 \0 }这里有个细节SDS_TYPE_5 initlen 0时会强制升级为 SDS_TYPE_8。原因很简单空字符串如果不升级查询 len 的时候还要从 flags 里拆位操作不方便。所以空字符串直接落到 sdshdr8也解释了为什么空字符串在 Redis 里的编码是 embstr 而不是什么特殊 short 类型。还有一个值得注意的点内存是连同头部、数据、\0 一次分配的没有两次 malloc。这样好处有两个一是减少内存碎片二是缓存局部性好读取数据的时候头部和字符数据大概率在同一条缓存行里性能更好。3.2 扩容为什么一次追加只重分配了一半次数SDS 扩容的核心是sdsMakeRoomFor但真正精彩的是它背后的空间预分配策略。先看代码sds sdsMakeRoomFor(sds s, size_t addlen) { // 检查剩余空间是否足够 size_t avail sdsavail(s); if (avail addlen) return s; // 计算新长度 size_t newlen sdslen(s) addlen; if (newlen SDS_MAX_PREALLOC) newlen * 2; else newlen SDS_MAX_PREALLOC; }这里的SDS_MAX_PREALLOC是 1MB。扩容的规则是如果追加后的总长度小于 1MB就翻倍分配如果已经大于等于 1MB就只追加 1MB 的额外空间。为什么是“小于 1MB 翻倍、大于 1MB 只加 1MB”这是内存和时间的一个平衡点。对于小字符串翻倍的成本很低但能换来很多次后续追加的免扩容对于大字符串翻倍意味着额外分配几百 MB不仅费内存而且大数据量下的 memcpy 也很耗 CPU所以改为线性增长控制单次扩容的峰值成本。老版实现里还专门分析过通过空间预分配连续追加 N 次字符串内存重分配次数从 N 次降低为最多 log2(N) 次。这就解释了为什么用 Redis 的 APPEND 命令连续写长日志效率反而很高。3.3 缩容惰性空间释放到底行不行SDS 的缩容策略叫惰性空间释放意思是删除字符串内容时不立刻释放内存而是只修改 len 字段把多余的空间留着备用。void sdsclear(sds s) { sdssetlen(s, 0); s[0] \0; }就这么简单直接不 free不 realloc只是把 len 归零、buf[0] 置成 \0。比如一个分配了 1MB 空间的 SDS通过 STRLEN 命令截短成 10 字节底层内存还是 1MB下次往这个键追加内容时直接用剩余空间就行省了一次 realloc。但这里有一个容易被误解的地方惰性空间释放不意味着内存永远不会还回去。当 SDS 数据量明显小于已分配空间或者 Redis 内存紧张需要回收时sdsRemoveFreeSpace会被调用把多余的空间真正归还给系统。底层会尝试 realloc 缩容如果当前分配器不支持原地缩容就会重新分配并拷贝数据。所以在极端场景下大量对同一个 key 做“写大、清空、再写大”的操作仍然会产生内存拷贝和碎片。实际使用中如果你发现 Redis 的内存只增不减不要第一个怀疑惰性释放而要看是不是过期键没清、内存碎片率太高或者大量 bigkey 频繁修改导致的碎片化。3.4 二进制安全这个特性比很多人以为的都重要二进制安全从原理上讲就一句话SDS 以 len 字段判定字符串是否结束而不是以 \0 判定。因此字符串中间可以包含任何字节包括 \0。C 字符串是“遇 \0 即止”SDS 是“len 说多长就多长”。所以你可以把一个长度为 100 的二进制数据块存进 Redis其中 50 个字节都不满 \0再完整地读出来。这在 Redis 复制、持久化、模块开发里都特别关键因为 RDB 文件、AOF 缓冲、主从心跳这些内部流程本质上都是在搬运二进制数据。有一个细节值得留意SDS 的 buf 末尾依然会补一个 \0。这是纯粹为了兼容 C 字符串函数。比如要在 SDS 上调用 printf、strchr、strstr 这些 libc 函数时\0 能保证它们不会越界读到其他数据。所以 SDS 不是把 C 字符串踢开而是把它变成“更安全的超集”。4. 实战观察在 redis-cli 里看到 SDS 的行为讲完源码层面的机制得回到实际运用。Redis 不像其他数据库那样直接暴露内部结构但通过几个命令组合我们完全能在黑盒层面看到 SDS 的工作痕迹。这些操作我建议你亲手敲一遍对理解会非常有帮助。4.1 object encoding一眼看穿字符串编码Redis 的 string 类型底层有三种编码方式int、embstr、raw。其中 embstr 和 raw 都用 SDS 存储数据区别在于 embstr 是把 redisObject 和 SDS 一次性分配在一块连续内存里而 raw 是分开分配的。127.0.0.1:6379 set mykey hello OK 127.0.0.1:6379 object encoding mykey embstr字符串长度不超过 44 字节时默认走 embstr。超过 44 字节就会变 raw127.0.0.1:6379 set mykey a very long string that exceeds the forty four bytes limit! OK 127.0.0.1:6379 object encoding mykey raw这个 44 是怎么来的redisObject 结构体占 16 字节最小的 sdshdr8 头部加终止符占 4 字节加起来 20 字节Redis 里一次性分配的内存上限是 64 字节64 减去 20 等于 44多出来的空间全部留给字符数据。所以长度小于等于 44 的字符串能塞进一个 64 字节的内存块里。4.2 append 命令触发 embstr 到 raw 的升级embstr 有个重要特性它是只读的。一旦对 embstr 编码的字符串执行修改操作Redis 会先把编码转换成 raw再走 SDS 的扩容逻辑。127.0.0.1:6379 set counter hello OK 127.0.0.1:6379 object encoding counter embstr 127.0.0.1:6379 append counter world (integer) 11 127.0.0.1:6379 object encoding counter raw这个升级过程本身就是 SDS 机制在起作用append 操作发现编码是 embstr立刻转成 rawraw 状态下调用 sdsMakeRoomFor发现新长度 11 小于 1MB走翻倍策略直接分配 22 字节的空间后续再 append 几次都不用重新分配内存。你可以多 append 几次然后观察一个现象每次 append 返回的字符串长度持续增长但内存的 realloc 并不会发生在每次 append 上这正是空间预分配在起作用。4.3 bigkey 和 SDS 扩容之间的微妙关系Redis 里的大 key 问题本质上就是 SDS 扩容的极端场景。一个 10MB 的字符串如果业务上反复对它做“截断到很小再追加到很大”的操作每一次大范围长度变化都可能触发 sdshdr 的迁移和内存拷贝。# 写入一个 2MB 的大字符串 127.0.0.1:6379 set bigkey some large string... OK虽然从命令行难以直接看到扩容细节但有一个思路可以验证利用DEBUG SDSLOG这类内部命令或者在 Redis 源码里配合 jemalloc 看一下stats memory中的碎片率。当 bigkey 反复变大变小时内部碎片率通常会升高这就是给 bigkey 频繁扩容付出的内存碎片成本。所以很多 Redis 运维规范会建议大对象不要做频繁的读写更新而是用新值覆盖或者拆分。底层原因就在这里SDS 的头部长途迁移、数据大面积拷贝、内存碎片的形成都是 bigkey 频繁变动的附加产物。5. 面试高频考点SDS 这个“超级字符串”怎么答不翻车如果你在准备 Redis 面试或者身边有朋友在背八股文SDS 几乎是躲不掉的一个点。我的建议是把下面几个问题和答案吃透别只背结论要把过程讲出来。5.1 直接对比SDS 比 C 字符串强在哪这是最基础的考点拿一张表记住就行。对比项C 字符串SDS获取长度O(n) 遍历O(1) 读 len 字段追加字符串需要手动检查溢出自动扩容不会溢出修改重分配每次都可能触发预分配机制大幅减少二进制安全不支持遇 \0 截断支持按 len 判定兼容 C 函数天然兼容通过末尾 \0 兼容面试官如果追问“能举例吗”你就拿 APPEND 命令举例连续追加 N 次C 字符串需要 N 次 reallocSDS 只需要 log2(N) 次左右。这个数字变化本身就很有说服力。5.2 空间预分配的计算细节必须说全高频考点里最容易丢分的恰恰是计算规则。完整回答应该是当修改后的新长度小于 1MB 时分配新长度两倍的空间也就是newlen oldlen addlen; newlen newlen * 2。当修改后的新长度大于等于 1MB 时只额外分配 1MB即newlen oldlen addlen SDS_MAX_PREALLOC。注意这里指的是“修改后的字符串总长度”不是“追加的内容长度”。很多人会用错追加 10 个字节就以为翻倍后是 20 字节其实翻倍后是“已有长度 10”的两倍。同时再补充说明这个策略让小字符串少做哈希表扩容式的重复分配让大字符串避免不必要的大块内存浪费。5.3 五种类型为什么这么设计新版 SDS 五种类型的高频考点一般有三个为什么要有 sdshdr5为什么空字符串不直接用 sdshdr5以及边界怎么算。回答思路是sdshdr5 进一步优化长串在 31 字节以内的内存占用连 len/alloc 都不存。空字符串用 sdshdr5 时读长度要从 flags 拆位而且后续几乎一定会升级所以统一落到 sdshdr8。边界值注意sdshdr8 最大 255sdshdr16 最大 65535以此类推。加一个“长度达到边界时会整体迁移头部”的过程描述。还可以补充一句这些类型选择不是拍脑袋定的而是配合 Redis 的键空间结构让大多数短字符串场景都能享受最紧凑的内存布局整体节约的内存可能在几十 MB 甚至几百 MB 级别。5.4 常见误区把 SDS 和 Redis Object 搞混还有一些人把 SDS 和 redisObject 混为一谈其实是两个层面的东西。redisObject 是 Redis 中所有对象值的通用封装里面包含 type、encoding、ptr 等字段而 SDS 只是用来存储字符串的一种具体数据结构。一个 redisObject 可以指向一个 SDS也可以指向一个 long 整数还可以指向其它复杂结构。理解了这层关系再去看object encoding的输出会更加清楚int 编码是 redisObject 直接存数值embstr 和 raw 都是 redisObject 指向 SDS只是内存分配方式不同。这个视角在排查线上问题时特别有用能帮你快速判断一个键值对的最底层载体到底是什么。6. 实战过程中的个人复盘几个容易忽略的细节文档和面试题能覆盖的大多是“标准答案”但真去看源码、自己改造、或者排查线上局内存问题时还是会遇到一些书上不会专门写的坑。挑几个我印象比较深的说说。第一个是内存对齐和 packed 的问题。SDS 头部的__attribute__ ((__packed__))在实际使用中确实给人省了内存但也带来了一个副作用在某些平台上对未对齐的 uint16_t / uint32_t 直接做解引用可能触发性能惩罚甚至在部分交叉编译环境下有兼容性风险。如果你自己在做嵌入式移植或者二次封装别轻易去掉 packed 也不要随意改字段顺序否则各种隐晦的崩溃会找上门。第二个是扩容策略在 Redis 7 及后续版本里的变化。很多老文章写的是“翻倍和 1MB”那是 6.x 时代的行为。Redis 7 在空间预分配方面把代码抽得更加模块化部分场景下的分配策略会稍有不同。所以如果你用 Redis 7 的源码去对照老版分析别觉得是自己理解错了很有可能是版本演进导致的差异。第三个是内存碎片率这个问题。SDS 的预分配机制用空间换时间后果之一就是内存碎片率可能偏高。当字符串频繁在多个长度级别之间横跳时比如一会儿 300 字节、一会儿 200 字节、一会儿又 280 字节SDS 头部在 sdshdr8 和 sdshdr16 之间反复切换每次切换都是一次 malloc 和 free。遇到线上碎片率高的情况先别急着开activedefrag先确认是不是有大量 key 在频繁做这种跨长度区间的修改。第四个也是我觉得最实用的一个心得SDS 不只是一个 Redis 源码里的冷知识它对业务设计有直接的启发。当你设计一个高并发系统时能预分配的资源不要推迟到使用时才分配能惰性释放的成本不要在生产路径上主动承担——SDS 把这两种策略应用在内存管理上换来了极致的吞吐表现。这个思路完全可以平移到日常的缓冲池、连接池、对象池设计里。7. 看完这篇之后自己动手做一遍原理讲再多不如动手。这里给你一条最快的验证路径找一个 Redis 6.x 的源码把 sds.h 和 sds.c 两个文件单独拿出来编译一个小演示程序直接调用sdsnewlen、sdscat、sdslen打印每次扩容前后sdsalloc的变化。你会非常直观地看到第一次追加后分配翻倍、后续追加几乎不触发 realloc、sdsclear之后 alloc 保持不变。整个过程半小时之内能跑完但对 SDS 的理解会比读十篇文章都深。然后把 Redis 跑起来用 redis-cli 配合object encoding、append、strlen这些命令做黑盒验证再看一遍源码理解为什么会有这种表现。从这个“源码机制 黑盒验证”的组合里你会感受到 SDS 设计的精妙之处它用很小的元信息开销换来了 O(1) 的长度获取、二进制安全、缓冲区安全、以及大幅减少的内存重分配。说它是 Redis 高性能的基石之一一点不过分。最后说一句个人体会我当初第一次看 SDS 源码时最大的冲击不是那几个宏定义和结构体而是 Redis 作者为了解决一个看似很小的问题愿意把一个字符串结构重写几遍、引入五种类型、精心设计每个字段。这种极致的取舍正是我们日常写业务代码时最缺的东西。希望这篇原理篇能给你同样的启发也让你后面再看其他 Redis 的底层结构时多一分知己知彼的底气。
返回列表