面试的时候如果被问到 Redis,十个有九个会撞上同一个问题:为什么 Redis 不用 C 语言自带的字符串,非要自己搞一个 SDS?我这几年读 Redis 源码、排查线上大 key 和缓冲问题,越看越觉得这题不只是一个数据结构细节,它背后藏着一张完整的性能账本。C 字符串“能用但不够用”,SDS 才是 Redis 处理字符串的真正底座。这篇文章我会从底层数据结构拆到线上实际场景,把 SDS 的设计思路、源码细节和常见坑一次讲透,不管你是准备面试还是做服务端开发,都能有收获。
1. C 原生字符串的“硬伤”:三个绕不过去的坎
1.1 取长度居然是 O(n),连 Redis 都得数一遍
C 原生字符串有多“简陋”,很多人写 C 的时候没细想。所谓字符串,其实就是一段以\0结尾的字符数组,比如"redis"在内存里是r e d i s \0。这种结构想要知道一个字符串有多长,只能从头开始遍历,一字节一字节数到\0为止,也就是调用strlen的代价是 O(n)。
在普通应用里,字符串长度一般不会成为瓶颈。但 Redis 是内存数据库,所有 key、value、缓存数据、命令缓存全都要和字符串打交道,而且很多操作是高频执行。比如STRLEN这种命令,如果每次都要遍历一遍完整字符串,遇到几十 KB、几百 KB 的大 value,性能立刻被打回原形。
你可以把 C 字符串想象成一栋没有楼层号、只有走廊尽头挂了个“停止”牌子的楼。想知道总楼层数,只能一层一层走到底。SDS 干的第一件事,就是给这栋楼装上电梯楼层显表——用一个字段直接记录当前长度。这一下就把 O(n) 变成了 O(1)。
1.2 缓冲区溢出:一次 strcat 引发的血案
C 字符串第二个大坑是缓冲区溢出。因为 C 字符串本身不知道“自己有多少容量”,strcat、strcpy、sprintf这类函数只管往目标位置写,完全不检查目标空间够不够。
char buf[4] = "abc"; strcat(buf, "def"); // 没检查容量,直接越界写这段代码看起来只是“多写了几个字节”,实际可能把相邻内存区域的数据覆盖掉,导致程序崩溃甚至被利用做内存攻击。在 Redis 这种长期运行的服务里,一旦发生缓冲区溢出,轻则数据损坏,重则崩溃卡死,而且这类问题非常难定位。
Redis 早期也踩过类似内存管理的坑,后来在 SDS 里加入了len与alloc两个字段,让字符串自己知道“我用到了哪里、我还有多少空间”。所有写入操作都会先检查能否装下,不够就自动扩容,从根上杜绝了越界写。
1.3 二进制不安全:存一张图片就被截断
C 字符串还有一个极其隐蔽的缺陷:它用\0表示字符串结束,那么当数据本身包含0x00字节时,字符串就会被无情截断。比如你想把一张 JPEG 图片、一段 protobuf 序列化结果或者一份压缩包塞进 Redis,这些二进制数据里很可能包含\0,用 C 字符串存,数据后半部分就直接丢了。
这种“二进制不安全”在纯文本业务里很少暴露,但凡是做缓存、做对象存储、做消息队列,早晚会遇到。Redis 本身是一个通用的数据结构服务器,不只是存字符串文本,它还要撑起 bitmap、HyperLogLog、Stream 这类二进制敏感的数据结构。所以 Redis 必须有一种“以长度为准、不以结束符为准”的字符串表示,这正是 SDS 出现的直接原因。
1.4 还有内存重分配:每次改动都可能“搬家”
C 字符串另一个隐性成本是内存重分配。字符串长度随时可变,一旦内容变长,底层字符数组可能装不下,就得用realloc重新分配一块更大的内存,再把旧数据整体拷贝过去。这个过程涉及系统调用、内存拷贝,开销不小。
更难受的是,频繁 append 一个 C 字符串,时间复杂度会被放大成 O(n²)。比如从空字符串开始,逐字节追加到 1MB:
- 每次
strlen都要 O(n) 数长度; - 每次空间不足都要
realloc+ 拷贝,搬运的数据量也在增长; - 连续 n 次追加,总拷贝量大概是 1+2+3+...+n,也就是 O(n²)。
在 Redis 里,命令处理、日志追加、AOF 缓冲都涉及大量字符串拼接,如果每次拼接都来一次 O(n²),服务性能根本扛不住。
2. SDS 到底改了什么:从源码结构说起
2.1 三个字段加一个柔性数组:len、alloc、flags、buf
SDS 全称是 Simple Dynamic String,简单动态字符串。名字里的“动态”指的就是它可以根据需要自动扩容。Redis 3.2 之后,SDS 的 header 不再只有一种,而是分成了sdshdr5、sdshdr8、sdshdr16、sdshdr32、sdshdr64五种。拿最常用的sdshdr8举例,它的结构长这样:
struct __attribute__ ((__packed__)) sdshdr8 { uint8_t len; // 已使用字节数 uint8_t alloc; // 已分配字节数,不含 header 和末尾 '\0' unsigned char flags; // 低 3 位表示 header 类型 char buf[]; // 柔性数组,真正存数据的起点 };len记录当前用了多少字节,alloc记录分配了多少容量,flags标记这是哪种sdshdr类型,buf则是真正的数据区。SDS 对外暴露的指针并不是 header 的起始地址,而是buf的地址,也就是说,对上层使用者来说,SDS 看起来仍然是一个char*,可以通过buf直接读写数据。
老版本 Redis(3.2 之前)的 SDS 结构更简单:
struct sdshdr { int len; // 已用长度 int free; // 剩余空间 char buf[]; };而新版拆成 5 种 header,核心原因是内存优化:短字符串没必要用 64 位、32 位甚至 16 位的长度字段,几字节的字符串用uint8_t就够了,这样能省下大量内存开销。
2.2 len 字段如何把 O(n) 变成 O(1)
有了len字段,获取长度的逻辑就变成一次内存读取。Redis 写了一个sdslen函数,读法很精妙:
typedef char *sds; static inline size_t sdslen(const sds s) { unsigned char flags = s[-1]; switch (flags & SDS_TYPE_MASK) { 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]。因为s指向的是buf起始位置,而flags就存在buf前面一个字节,所以通过向前偏移一个字节就能拿到 header 类型,再通过宏计算出 header 起始地址,返回len。
整个过程没有任何循环,时间复杂度 O(1)。这也是STRLEN命令在 Redis 里能毫秒级返回的根本原因。换句话说,SDS 用一个小小的len字段,把 C 字符串最大的性能短板补上了。
2.3 二进制安全的真正含义
SDS 的len字段让字符串的长度判断不再依赖\0。哪怕数据中间全是0x00,SDS 也知道“这串数据一共有多少字节”,所以它天然具备二进制安全能力。
我在实际项目中就踩过类似的坑。当时用 Redis 做分布式缓存,value 是 protobuf 序列化后的二进制对象,一开始想直接用 C 字符串工具从 Redis 底层解析数据,结果怎么解析都不对,长度总是对不上。后来才意识到,二进制数据里包含了很多0x00字节,C 字符串在第一个0x00处就提前“截断”了,而 SDS 能完整保留整段二进制数据。
当然,二进制安全和“能直接当 C 字符串用”是两回事。SDS 内部仍然会在末尾补一个\0,这个\0不计入len,只是为了让数据在某些场景下能兼容 C 字符串 API。但如果数据中间本身包含\0,你仍然不能直接把buf当普通 C 字符串去strlen、strcpy,正确姿势是通过sdslen获取真实长度。这一点初学者特别容易搞混。
2.4 不同 header 类型:短字符串也抠内存
Redis 对内存的抠门程度在行业里是出了名的,SDS 的 header 设计就是典型例子。不同类型对应的长度字段宽度不一样,直接决定了一个短字符串要额外背多少字节的“元数据”。
| header 类型 | len 字段宽度 | alloc 字段宽度 | 适合场景 |
|---|---|---|---|
| sdshdr5 | 1 字节(高 5 位存长度) | 无 | 长度不超过 31 的静态字符串 |
| sdshdr8 | uint8_t | uint8_t | 长度不超过 255 |
| sdshdr16 | uint16_t | uint16_t | 长度不超过 65535 |
| sdshdr32 | uint32_t | uint32_t | 长度不超过 4GB 左右 |
| sdshdr64 | uint64_t | uint64_t | 超大字符串 |
为什么短字符串要单独抠?假设所有 SDS 都用uint64_t存 len 和 alloc,光是 header 就要 17 字节左右,Redis 里大量 key 可能只有几个字节,光元数据开销就能翻好几倍。拆分成多种 header 后,一个短字符串可能只需要 3 字节 header,省下的内存非常可观。
sdshdr5比较特殊,它的 len 和 alloc 被压缩到一个字节里,无法支持修改和扩容,所以实际动态场景基本不用,只有极短字符串初始化时会用到。凡是需要 append、修改的操作,Redis 都会把字符串升级成sdshdr8甚至更高类型,这个过程对上层完全透明。
3. SDS 的两种“空间魔法”:预分配与惰性释放
3.1 初识空间预分配:翻倍 和 加 1MB 的临界点
前面提到 C 字符串频繁 append 会导致 O(n²) 的拷贝开销,SDS 的解决方案叫“空间预分配”。核心思想是:扩容时不要每次都只多分配“刚刚够用”的空间,而是多分配一些余量,这样后续多次 append 不需要频繁触发realloc。
Redis 源码中负责扩容的函数是sdsMakeRoomFor,关键逻辑大概是这样:
sds sdsMakeRoomFor(sds s, size_t addlen) { // 检查剩余空间是否够用,够就直接返回 // ... if (newlen < SDS_MAX_PREALLOC) { // 如果新长度小于 1MB,直接翻倍 newlen *= 2; } else { // 如果新长度已经很大,只额外加 1MB newlen += SDS_MAX_PREALLOC; } // 按 newlen 重新分配内存 }这里的SDS_MAX_PREALLOC是 1MB。策略可以拆成两段理解:
- 如果目标长度小于 1MB,直接分配目标长度的两倍。比如原来 100 字节,扩容后直接给 200 字节的容量,下次 append 不超过 100 字节就不需要再扩容。
- 如果目标长度已经大于等于 1MB,每次只额外增加 1MB。这是为了避免大字符串翻倍后浪费太多内存。比如一个 100MB 的字符串,再 append 一点就分配到 200MB,显然不划算,加 1MB 余量足够用了。
这个策略的核心效果是:连续 append 时,触发realloc的次数从“每次都可能”变成“对数级别”,数据搬运总量从 O(n²) 降为 O(n)。我在本地写过一个小测试,用 C 字符串和 SDS 各 append 十万次,SDS 的耗时和内存分配次数都低了一个数量级。
3.2 惰性空间释放:先欠着,等真正需要时再还
与预分配配套的另一个机制是“惰性空间释放”。如果你用sdsclear清空一个 SDS,Redis 并不会立刻把分配的内存还给系统,而只是把len改成 0,让底层容量继续保留。
void sdsclear(sds s) { sdssetlen(s, 0); // 只改长度标记,不释放内存 s[0] = '\0'; // 保留 C 字符串兼容性 }这种设计很聪明。因为一段字符串如果被反复修改、清空、再填充,频繁释放再分配会产生大量内存碎片和系统调用开销。SDS 选择“留着空间备用”,等真正不看了再一次性释放。如果确实想立即缩容,Redis 也提供了sdsRemoveFreeSpace函数,你可以手动把多余的空间回收掉。
3.3 一次真实场景:大 append 导致的线上卡顿
今年年初我排查过一个线上接口偶发抖动的问题,现象是某个 key 的 value 会周期性重写,从几 KB 增长到几 MB。最初底层存储用了自定义的 C 字符串拼接逻辑,每次倒腾数据都触发realloc,大量内存拷贝直接拖慢了请求,接口 P99 一度飙到 2 秒以上。
后来把拼接逻辑改成类似 SDS 的预分配模式,一次扩容给足余量,后续 append 不再频繁搬数据,P99 直接降了一个数量级。这个经历让我对 Redis 设计者的选择特别有体会:SDS 不是花哨的设计,而是基于真实场景中反复被验证过的性能方案。内存预分配 + 惰性释放这两个机制,是它在高并发场景下能保持稳定的关键底牌。
4. 兼容 C 字符串 API 的巧妙设计:保留\0但不被它绑架
4.1 为什么末尾必须留一个\0
二进制安全并不等于完全抛弃\0。SDS 在buf末尾仍然会保留一个\0,只是不把它计入len。这样做的最大好处是:在数据本身不含\0的前提下,SDS 的buf可以直接传给 C 标准库的字符串函数。
比如strcmp、strcasecmp、strchr、sprintf这些函数,底层都依赖\0判断字符串结束。如果 SDS 没有在末尾补\0,Redis 内部有大量需要调用标准库字符串函数的场景都得重写一遍。保留一个\0是“花一个字节的成本,换整个标准库的兼容性”,这笔账怎么算都划算。
还有日志输出和调试场景。你在 Redis 里打印一条日志,如果用的是%s格式符,直接传sds的buf就能正常输出,不需要额外申请临时缓冲区再拷贝一次。整个设计就是让你“既用上了动态字符串,又几乎不增加迁移成本”。
4.2 sds 其实就是 char*:从指针到结构体的细节
SDS 对外暴露的数据类型定义非常有意思:
typedef char *sds;也就是说,从类型上看,SDS 就是一个char*。你调用sdsnew返回的是一个指向buf的指针,而不是指向整个结构体的指针。这个设计有两个直接好处:
第一,API 层面兼容。很多已经基于char*写的代码,可以直接替换成sds而不用大改。第二,C 字符串函数可以继续用,只要数据本身不包含\0,sds就能无缝转换成普通字符串。
那怎么从sds找到结构体 header 呢?前面提到过,flags存在s[-1]的位置。拿到flags后,通过SDS_HDR宏计算 header 的起始地址。释放内存时,Redis 的sdsfree函数要做的事情就是:根据s[-1]判断 header 类型,然后从 header 起始位置开始free。
void sdsfree(sds s) { if (s == NULL) return; // 根据 s[-1] 计算 header 起始地址并释放 zfree(s - sizeof(struct sdshdr)); }这种“指针指向数据区、元数据藏在前面”的做法,在 C 语言里是很经典的技巧。它牺牲了一点直观性,换来了极大的 API 兼容性,也是 SDS 能被 Redis 内部全量使用的重要前提。
4.3 边界处理:中间出现\0怎么办
既然 SDS 号称二进制安全,那数据中间出现\0时它到底怎么处理?答案是:透明对待,不特殊处理。len字段记录了多少字节,数据区就是多少字节,中间有几个\0都不影响 SDS 的长度计算和存储。
但这会带来一个隐藏问题:如果你在 Redis 里存了一段中间带\0的数据,然后直接把这个 SDS 传给strlen、strcpy,或者用%s输出,结果都会被截断。因为那些 C 字符串函数只看得到第一段。
我见过有人调试 Redis 模块时,明明从 value 里取出来的字节数组是对的,打印出来却只剩一小截,查了半天才发现是%s截断的问题。正确做法是:需要打印二进制数据时,按sdslen取长度,然后逐字节以十六进制方式输出,或者用fwrite这类能指定长度的函数。
5. SDS 在 Redis 里的实际应用:不只是存 key/value
5.1 键值存储:最直接的落地场景
Redis 的 key 和 value 都使用 SDS。比如执行SET user:10086 "hello":
- key 字符串
"user:10086"被包装成 SDS 存入字典; - value 字符串
"hello"同样被包装成 SDS。
为什么 Redis 连 key 都要用 SDS?因为 key 也会被频繁比较、计算长度、复制,用 SDS 可以统一提升性能。再说 key 在 Redis 内部也是二进制安全的,一个 key 可以包含特殊字符,这也是靠 SDS 支撑起来的。
字符串类型的 value 在 Redis 内部有三种编码:int、embstr、raw。其中embstr和raw底层都是 SDS,区别在于内存分配方式。embstr适合短字符串,它把redisObject和 SDS header 放在同一块连续内存里,一次分配搞定,读性能更好;raw适合长字符串,需要分别分配对象和 SDS header。很多面试题喜欢问“为什么 Redis 的字符串小于 44 字节用 embstr”,这里面的 44 字节就是由 redisObject 和 SDS 头的大小算出来的——本质上还是在围绕 SDS 做文章。
5.2 缓冲区家族:querybuf、aof_buf、输出缓冲区
SDS 在 Redis 里不只是用来存数据,还承担了大量缓冲区角色。客户端发送过来的命令会被读入输入缓冲区,这个缓冲区就是 SDS;Redis 处理完命令后要写回的响应,也会暂存在输出缓冲区里,本质仍是一堆 SDS。
AOF 持久化是另一个典型场景。Redis 会把写命令追加到aof_buf,这个缓冲区同样是 SDS。AOF 重写时也需要大量拼接命令字符串,SDS 的预分配机制在这里帮了大忙。主从复制里的积压缓冲区、复制偏移量处理,底层也离不开 SDS。
可以这样说:Redis 进程里几乎所有需要动态增删字符串数据的地方,最终都会落到 SDS 上。它不只是“一种数据结构”,而是整个 Redis 的字符串基础设施。
5.3 其他用到 SDS 的组件
除了主链路之外,Redis 内部还有很多组件直接拥抱 SDS。慢查询日志要记录执行过的命令,命令是 SDS;Lua 脚本的源码和运行参数,用 SDS 保存;Stream 的消息字段、List 的元素、Hash 的 field 和 value,凡是字符串类型,底层基本都是 SDS。
可以想象一个极端情况:如果 Redis 把 SDS 换回 C 字符串,那它至少要在 key 比较、命令解析、AOF 追加、主从复制这几个核心链路重写一遍,并且不可避免要面对缓冲区溢出和二进制安全问题。所以说,SDS 不是 Redis 的“一个选项”,而是 Redis 能稳定运行的“默认配置”。
6. 面试深挖与设计哲学:往下一层还能聊什么
6.1 为什么不用一个简单 struct { int len; char buf[]; }
有人可能会问:SDS 做的事情,我自己定义一个结构体也能做到,为什么 Redis 不直接用最简单的那种struct { int len; char buf[]; }?
这个问题其实是理解 SDS 设计的关键。简单结构体存在三个问题:
第一,不够省内存。int是 4 字节,短字符串也会强制背上 4 字节长度字段,而 SDS 用uint8_t就能覆盖 255 字节以内的字符串,中间差了好几倍。第二,没有alloc字段就无法高效预分配。没有预分配,连续 append 还是会频繁realloc,动态扩容的高性能也就无从谈起。第三,不保留末尾\0就不兼容 C 字符串 API。你每次都得自己实现字符串比较、拼接、格式化输出,代码量会迅速膨胀。
所以 SDS 是一个多维度的平衡结果:既要二进制安全,又要 O(1) 长度获取,还要预分配能力,还要兼容 C 字符串,还要尽量省内存。把这些需求全部满足之后,你会得到一个和 SDS 八九不离十的设计。
6.2 SDS 的价值归纳:安全、高效、通用
如果要用一句话向别人介绍 SDS,我会说:它既是二进制安全的动态字符串,又是能在 C 字符串 API 里无缝使用的“改进版 char*”。
| 对比项 | C 原生字符串 | Redis SDS |
|---|---|---|
| 获取长度 | O(n) 遍历 | O(1) 读字段 |
| 二进制安全 | 不支持,遇到 \0 截断 | 支持,以 len 为准 |
| 缓冲区溢出 | 不检查目标容量 | 自动扩容,写入前检查 |
| 修改字符串 | 频繁 realloc,O(n²) | 预分配+惰性释放,接近 O(n) |
| 内存占用 | 无元数据,但无法感知容量 | 少量 header 元数据,节省内存设计 |
| 兼容 C API | 本身即是 | 末尾保留 \0,基本兼容 |
这张表基本涵盖了面试官想考察的所有点。不管是问“SDS 和 C 字符串的区别”“SDS 为什么安全”“SDS 为什么用多种类型”,归根到底都是在考察你是否理解这六行背后的工程取舍。
6.3 个人踩坑与后续玩法
我自己在实际使用中比较深的两个感受。
第一,如果你也准备在 C/C++ 项目里实现类似的动态字符串,不要照搬整套代码,而是要学它的权衡思路:先想清楚你的核心使用场景是什么,再决定要不要预分配、要不要二进制安全、要不要兼容\0。不同场景的最佳答案可能完全不一样。
第二,排查 Redis 线上问题时要多留个心眼:凡是遇到字符串长度不对、内容被截断、二进制数据丢失这类怪现象,可以先怀疑是不是有人把 SDS 当普通 C 字符串用了。直接在调试器里看sdslen和buf的字节数,很多时候一眼就能找到问题。
Redis 发展到今天,底层其他模块换过很多实现,SDS 却始终稳坐核心位置。这本身就说明它经受住了大规模生产环境的检验。把一个字符串设计做到这个程度,确实是值得反复琢磨的工程范本。