Redis 为什么要自定义 SDS?

一则或许对你有用的小广告

欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 1v1 提问 / Java 学习路线 / 学习打卡 / 每月赠书 / 社群讨论

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

面试官提出这个问题,核心是想考察你是否理解 “为什么造轮子” 这一重要的系统设计思想。具体来说,考察点包括:

  1. 对底层数据结构的深刻理解:面试官不仅仅想知道 SDS 是什么,更是想知道你是否清楚 C 语言原生字符串(以空字符 '\0' 结尾的字符数组)的固有缺陷。
  2. 对性能与内存权衡的意识:能否从时间复杂度、内存安全、二进制安全等关键性能维度,分析一个基础组件的设计选择。
  3. 对 Redis 设计哲学的认识:Redis 作为内存数据库,其每一个底层实现都经过了极致优化。这个问题能看出你是否探究过这些 “常规操作” 背后的 “非常规原因”。
  4. 解决实际问题的能力:能否将 “自定义数据结构” 这一抽象概念,与“如何高效存储字符串、实现追加、获取长度等操作”这些具体场景联系起来。

核心答案

SDS(Simple Dynamic String)是 Redis 内部自定义的简单动态字符串结构。Redis 之所以不直接使用 C 语言的原生字符串,而是耗费精力自己实现 SDS,核心是为了在性能、内存安全和功能上获得 C 字符串无法提供的优势,以满足 Redis 作为高性能数据库的核心诉求。具体来说,是为了实现 O(1) 复杂度的长度获取、杜绝缓冲区溢出、支持二进制安全、以及更高效的内存重分配。

深度解析

原理/机制

C 语言字符串只是一个字符数组,以空字符 '\0' 作为结束标识。这种设计存在几个致命缺陷,而 SDS 通过一个结构体巧妙地将这些缺陷一一解决。

一个典型的 SDS 结构(以 Redis 5.0 及之后版本为例,早期版本略有不同)设计如下:

struct sdshdr {
    int len;    // 记录 buf 数组中已使用字节的数量,等于 SDS 所保存字符串的长度
    int alloc;  // 记录 buf 数组总共分配的空间大小(不包括头部和结尾的空字符)
    char flags; // 标识 SDS 的类型(sdshdr5, sdshdr8, sdshdr16, sdshdr32, sdshdr64)
    char buf[]; // 字节数组,用于保存实际的字符串数据,末尾会自动追加一个 '\0' 以保证部分 C 函数兼容
};

SDS 的关键优化机制包括:

  1. O(1) 时间复杂度获取字符串长度:C 字符串获取长度需要遍历整个数组直到 '\0',是 O(N) 操作。而 SDS 直接将长度存储在 len 字段中,直接读取即可,性能与字符串长度无关。
  2. 杜绝缓冲区溢出:C 的 strcat 函数假设目标空间足够,否则会覆盖相邻内存。SDS 的 API(如 sdscat)在执行前会检查 alloc - len 的剩余空间。如果不足,API 会先自动进行内存扩展,然后再执行拼接操作,从根本上杜绝了溢出。
  3. 二进制安全:C 字符串由于依赖 '\0' 判断结束,无法存储包含 '\0'(如 "Redis\0Cluster")的二进制数据(如图片、协议包)。SDS 完全依赖 len 来判断字符串结束,buf 里可以存放任意二进制数据。
  4. 内存重分配优化:这是 SDS 性能设计的精髓。C 字符串每次增长或缩短,都需要 realloc 系统调用,这是一个相对昂贵的操作。
    • 空间预分配:当 SDS 需要增长时,不仅分配所需空间,还会额外分配未使用空间(alloc - len。具体策略是:如果增长后 len 小于 1MB,则 alloc 会翻倍(例如从 16 字节变成 32 字节);如果大于 1MB,则每次只多分配 1MB 的未使用空间。这有效减少了连续增长操作所需的内存重分配次数。
    • 惰性空间释放:当 SDS 需要缩短时,SDS API 不会立即 realloc 回收多余内存,而是仅仅更新 len 值,将多余空间保留在 alloc 中。下次字符串增长时,就可能直接使用这些空闲空间,避免了缩短时的内存重分配开销。当然,SDS 也提供了真正的内存释放 API。

对比分析

特性C 字符串Redis SDS
长度计算O(N),遍历至 '\0'O(1),直接访问 len
安全性易造成缓冲区溢出安全,API自动检查并扩容
二进制安全否,内容不能包含 '\0',靠 len 判断结束
内存操作每次修改必重分配优化(预分配 & 惰性释放)
兼容性所有 C 库函数兼容部分兼容buf 末尾保留了 '\0'

最佳实践与常见误区

  • 最佳实践:在 Redis 中,所有键(key)和字符串类型的值(value),以及列表、集合等复杂类型的内部元素,在需要字符串表示时,都使用了 SDS。这正是 Redis 能够高效处理各种数据操作的基础。
  • 常见误区
    • 认为 SDS 只是为了 “存储长度”。长度存储只是表象,其根本目标是为了一系列高效、安全的操作 API。
    • 认为 SDS 的预分配策略会造成大量空间浪费。实际上,Redis 会根据字符串大小使用不同 sdshdr 类型(sdshdr8sdshdr32 等),用更小的字段来存储 lenalloc,在短字符串场景下极大地减少了内存开销。这是一种用 CPU 换内存、用设计换性能的经典权衡。

总结

Redis 自定义 SDS 绝非重复造轮子,而是一次针对数据库核心场景的精准优化,它通过空间换时间预分配/惰性释放等策略,将字符串操作的性能和安全提升到了 C 原生字符串无法企及的高度,是 Redis 实现高性能、高可靠性的基石之一。