第四篇:Redis String 为什么不是 String?SDS 到底解决了什么问题?

Redis String 为什么不是 String?SDS 到底解决了什么问题?

上一篇我们讲了《Redis 为什么只有五种数据类型,却能支撑几乎所有业务?》,知道了 Redis 对外只有五种数据类型,而内部真正工作的却是各种不同的数据结构。

其中,使用频率最高的,就是 String

很多人看到这里都会觉得:

String 不就是字符串吗?直接用 C 语言的 char* 不就行了?

毕竟 Redis 本身就是 C 写的。

为什么还要自己重新设计一套字符串?

甚至专门发明了 SDS(Simple Dynamic String)

Redis 官方为什么要多此一举?今天,我们就来看看到底发生了什么。


C 语言的字符串,到底有什么问题?

先来看最普通的 C 字符串。

c 复制代码
char name[] = "hello";

它在内存中的样子,大概是这样:

text 复制代码
+---+---+---+---+---+----+
| h | e | l | l | o | \0 |
+---+---+---+---+---+----+

最后必须以 \0 作为结束标志。

所以,每次获取字符串长度时,C 都只能这样做:

c 复制代码
strlen(name);

strlen() 会从第一个字符开始,一直扫描,直到遇到 \0

例如:

text 复制代码
h↓e↓l↓l↓o↓\0↓结束

如果字符串长度只有几十个字符,这几乎感觉不到性能问题。

但是,如果字符串有几 MB 呢?

每次调用 strlen(),都要重新遍历一遍。

时间复杂度就是:

text 复制代码
O(N)

对于 Redis 来说,这是不能接受的。


Redis 怎么解决这个问题?

Redis 没有继续使用 char*

而是重新设计了一种字符串。最经典的 SDS,大概可以理解成下面这样:

c 复制代码
struct sdshdr {
    int len;
    int free;
    char buf[];
};

看起来比普通字符串多了两个字段。但是,就是这两个字段,让整个性能发生了巨大变化。

例如:

text 复制代码
len  = 5
free = 10

buf = hello

现在再获取字符串长度,已经不需要遍历了。

直接返回:

c 复制代码
len

时间复杂度立即变成:

text 复制代码
O(1)

这也是 Redis String 为什么查询速度这么快的重要原因之一。


free 字段又有什么用?

很多人第一次看源码时都会疑惑。既然已经有了长度,为什么还要保存一个 free

举个例子。假设现在有:

text 复制代码
hello

然后执行:

redis 复制代码
APPEND name world

普通字符串会发生什么?

很可能需要:

text 复制代码
申请更大的内存
↓
复制 hello
↓
追加 world
↓
释放旧内存

如果不断追加。

例如:

text 复制代码
hello
↓
helloworld
↓
helloworldredis
↓
......

每次都申请新内存,效率会越来越低。于是 Redis 想了一个办法。

一次扩容的时候,顺便多申请一点空间。

例如:

text 复制代码
len  = 5

free = 15

那么下一次追加时,直接写进去即可,不用再次申请内存。

这就是 SDS 的空间预分配机制。它可以极大减少内存分配次数。


Redis 为什么追加字符串越来越快?

来看一个简单例子。

第一次:

redis 复制代码
APPEND msg hello

Redis:

text 复制代码
申请 32 字节

真正使用:

text 复制代码
5 字节

剩余:

text 复制代码
27 字节

下一次:

redis 复制代码
APPEND msg world

直接利用剩余空间,不用重新申请内存。很多追加操作,因此都只是简单的内存复制。这也是 Redis String 写入性能很高的重要原因。


删除字符串,为什么也不急着释放内存?

再来看一个问题。

假设:

text 复制代码
helloworldredis

执行:

redis 复制代码
SETRANGE

或者:

redis 复制代码
GETRANGE

甚至删除一部分内容。

很多人觉得:应该立即释放不用的空间,Redis 并没有这样做。

它通常只是:

text 复制代码
len --

free ++

真正的数据空间仍然保留。

例如:

text 复制代码
len  = 5

free = 20

这样,下次再次追加,又可以继续利用这块空间。避免频繁申请、释放内存。这就是惰性空间释放。


SDS 为什么支持二进制安全?

这是很多面试都会问的问题。

先来看普通字符串。

例如:

text 复制代码
ABC\0DEF

对于 C 来说。

遇到 \0 字符串已经结束了。

真正读取到的是:

text 复制代码
ABC

后面的内容全部丢失,但是 Redis 不一样。

因为 Redis 根本不依赖 \0 判断长度。

而是:

text 复制代码
len = 7

所以,即使中间存在:

text 复制代码
\0

Redis 仍然可以完整保存。

例如:

  • 图片
  • 音频
  • 视频
  • 压缩文件
  • Protocol Buffer
  • 序列化对象

都可以直接存入 Redis,这就是所谓的:

二进制安全(Binary Safe)


Redis String 真的是字符串吗?

现在,我们再回到最开始的问题,Redis String 真的是 String 吗?

其实并不是。上一篇我们已经介绍过:

text 复制代码
RedisObject
↓
Type
↓
Encoding
↓
真正的数据结构

对于 String 来说,底层甚至有多种 Encoding。

例如:

text 复制代码
INT

EMBSTR

RAW

其中:真正保存字符串数据时,最终使用的就是:

text 复制代码
SDS

也就是说我们每天执行:

redis 复制代码
SET

GET

APPEND

背后操作的,并不是 C 字符串,而是 Redis 自己设计的一套高性能字符串。


为什么 Redis 不直接使用 C 字符串?

现在答案已经很清楚了,因为普通字符串存在很多问题:

  • 获取长度需要遍历。
  • 每次追加都可能重新申请内存。
  • 无法保存二进制数据。
  • 容易发生缓冲区溢出。

而 SDS 全部解决了这些问题:

  • len 实现 O(1) 获取长度。
  • free 减少内存重新分配。
  • 支持空间预分配和惰性释放。
  • 支持二进制安全。
  • 所有操作都带长度控制,更安全。

所以,Redis 并不是简单使用 C 语言开发。它把很多基础组件,都重新设计了一遍。


总结

很多人觉得 Redis String 就是普通字符串。实际上,它和 C 字符串几乎已经不是同一种东西。Redis 自己设计 SDS,并不是为了炫技,而是为了真正解决性能问题。可以把 SDS 的几个核心特点总结成一句话:

用空间换时间,用额外的元数据换来更高的性能、更好的安全性以及更灵活的扩展能力。

也正因为如此,SDS 才成为 Redis 最基础、也是最重要的数据结构之一。


上一篇:《Redis 为什么只有五种数据类型,却能支撑几乎所有业务?》

下一篇:《Redis 为什么不用链表保存 List?QuickList 到底是什么?》


如果这篇文章让你真正理解了 SDS 为什么存在,欢迎点赞、收藏。

你以前是不是也认为 Redis String 就是 C 的 char*?欢迎在评论区聊聊你的理解。