Redis 数据结构-简单动态字符串
Redis 是基于 C 语言的内存数据库,但是 Redis 中并没有使用 C 语言的字符串(实质是 以空格结尾的字符数组)作为默认的字符串,而是自己构建了一种名为 简单动态字符串(Simple Dynamic String) 的抽象数据结构,将其用作默认的字符串表示。
通常而言,SDS 在 Redis 中被用于 1. 默认的字符串结构;2. 用作缓冲区(buffer)。
我们首先看看 Redis 是如何定义 SDS 的,再看看为什么要通过实现 SDS 作为默认的字符串实现。
SDS 的定义
在 Redis 在 sds.h/sdshrd
定义了 SDS 的结构体:
struct {
// 实际的字符数组,用于保存字符串
char buf[];
// buf数组中**已使用**字节的长度
int len;
// buf数组中的剩余空间长度
int free;
}
下图是一个 SDS 的示例:
buf
中存储了 Redis 这个字符串- 可以看到,SDS 和 C 语言一样,通过 字符数组 存储字符串,且 末尾有一个空字符
\0
作为结尾 - SDS 底层采用和 C 语言一样的存储,这样 Redis 可以直接复用 C 标准库中的部分字符串处理函数
- 可以看到,SDS 和 C 语言一样,通过 字符数组 存储字符串,且 末尾有一个空字符
len
保存了buf
中存储字符串的 实际长度- 注意,这里
len
不包含buf
末尾的空字符\0
- 注意,这里
free
是buf
中 剩余空闲空间
为什么需要 SDS
常数复杂度获取字符串长度(len 字段)
在 C 语言中,获取字符串(也就是字符数组)的长度,是一个 O(N) 复杂度的操作。
- C 需要遍历字符数组,直到遇到空字符
\0
SDS 的 len
字段令 Redis 获取字符串长度的复杂度降为 O(1)(直接读取 len
字段即可),这也确保了「获取字符串长度」这一行为,不会成为 Redis 的性能瓶颈。
杜绝缓冲区溢出(len 字段)
什么是「缓冲区溢出」?
首先,我们需要知道的是,C 语言通过空字符 \0
判断字符串是否结束。
如下例,在内存中有下面两个连续的 C 字符串 S1
和 S2
,各自保存了 Redis
和 Str
两个字符串:
C 在执行字符串拼接函数 strcat
时,会假定调用者已经分配好了足够的内存、直接在字符串末尾添加对应字符。
即,如果我们在上图情况下,执行 strcat(s1, "01")
,便会得到下图的结果:
可以看到,S1
虽然正常修改,但其数据溢出到 S2
所在的内存空间中,导致 S2
被意外修改了:S2
变成了字符串 "1"
。
这便是我们所说的 缓冲区溢出。
SDS 如何杜绝?
SDS 通过它的 空间分配策略 杜绝「缓冲区溢出」这一现象。
当通过 SDS 的 API 对一个 SDS 进行修改时,API 会按照下列步骤执行:
- 检查 SDS 的空间是否满足要求
- 如果不满足要求,API 会先对 SDS 自动进行扩容
- 执行实际的修改操作
SDS 的空间分配策略(free & len 字段)
前面提到,如果通过 API 对 SDS 进行修改,API 会通过自动扩容来确保 SDS 空间满足要求;这是 SDS 空间分配策略的一种,这里我们仔细说一下 SDS 具体的空间分配策略。
C 字符串的空间分配
SDS 的空间分配策略和 C 不同,对于 C 字符串,其空间分配策略如下:
- 如果需要进行的是「增长字符串」,例如拼接操作 append
- 先通过内存重分配扩展底层数组的空间大小(如果没有这么做,就会发生上面提到的「缓冲区溢出」)
- 如果需要进行的是「缩短字符串」,例如截断操作 trim
- 需要先通过内存重分配释放字符串不再使用的空间(如果没有这一步,那么就会发生「内存泄漏」)
可以看到,每次对 C 字符串进行操作时,都需要进行 内存重分配。
- 内存重分配涉及复杂的算法,甚至可能需要执行系统调用,是一个 较为耗时 的操作
- Redis 作为数据库,对很可能被用于对速度有要求,同时也会对数据进行频繁修改的场合
- 如果使用 C 字符串原始的空间分配策略,那么每次修改字符串的内存重分配会对性能造成较为严重的损耗
基于这个原因,SDS 实现了自己的「空间分配策略」。
SDS 的空间分配策略
对于每个 SDS 结构,buf
数组长度和实际存储的字符串长度并不是相同的,准确地说,应该是 len(buf)==len+free+1
(buf
数组的空字符不在 len
和 free
计算中)。SDS 基于 free
字段实现了「空间预分配」和「惰性空间释放」两种空间分配策略。
空间预分配
空间预分配用于优化字符串的增长操作。
当 SDS API 对 SDS 字符串进行增长时,不仅会为 SDS 分配修改所需空间,还会基于 SDS 字符串的增长后的实际长度(也就是 len
)为 SDS 分配额外的空间。
- 如果增长后,SDS 保存的字符串小于 1MB(通过
len
字段判断)- 那么会为 SDS 预先分配 和
len
属性相同大小 的未使用空间free
- 那么会为 SDS 预先分配 和
- 如果增长后,SDS 保存的字符串大于等于 1MB
- 会为 SDS 分配 1MB 的未使用空间
free
- 会为 SDS 分配 1MB 的未使用空间
如下图,有 SDS "Redis"
,我们对其顺序执行两次修改:
- 第一次追加字符串
"01"
- 当前
free
为 1,小于字符串"01"
的长度,因此需要扩容,也就是进行内存重分配 - 因为追加后的
len
为 7,小于 1024,因此我们直接扩充未使用空间free
到len
的大小,也就是 7
- 当前
- 第二次追加字符串
"02"
- 当前
free
为 7,足够追加字符串"02"
,因此无需扩容,直接追加即可
- 当前
可以看到,通过这种「空间预分配」的策略,SDS 将连续增长 N 次字符串所需的内存分配次数从「必定 N 次」优化为「最多 N 次」。
惰性空间释放
惰性空间释放用于优化字符串的缩短操作。
当 SDS API 需要缩短 SDS 保存的字符串时,程序不会像 C 一样立即释放被缩短的空间,而是仅增长 free
字段、并将空间保留至将来使用。
如下图,我们缩短之前的 SDS "Redis0102"
至 "Redis"
:
- 可以看到,
buf
数组中的无用空间并没有被释放,而是将其保留,并令free
增长而已。
这样做的好处是,如果后续有对这个字符串进行增长操作时,可以尽可能的减少扩容动作。
与此同时,SDS 也提供了「主动释放空闲内存」的 api,让我们在有需要时让这些空闲空间真正被释放,防止内存浪费。
二进制安全(len & buf 字段)
在 C 语言中,由于是用字符数组实现的字符串,安全性方面天然就带有一些限制:
- 字符串中的字符必须符合某种编码(例如 ASCII)
- 除了字符串末尾,字符串中不能包含空字符
\0
(也就是前面提到的「缓冲区溢出」)
这也使得 C 语言的字符串只能保存文本数据,不能保存图片、音频等二进制数据,也就是「二进制不安全」。
而为什么说 SDS 是二进制安全的呢?
- SDS 的 API 会以处理二进制的方式来处理 buf 数组里的数据,不对数据作任何限制和处理,也就是保证了数据写入和读取时都是一样的
- SDS 通过
len
字段判断字符串是否结束,也就是允许中间出现空字符\0
因此,SDS 是一个「二进制安全」的字符串,它可以存储任何形式的二进制数据。
兼容部分 C 字符串函数(buf 字段)
如前文「SDS 的定义」一节中所说,SDS 和 C 语言一样,通过 字符数组 存储字符串,且 末尾有一个空字符 \0
作为结尾。这样保存文本数据的 SDS 就可以复用部分 <string.h>
中的函数了。
总结
最后再对 SDS 做一个总结。
SDS 是 Redis 在 C 字符串基础上实现的数据结构,用于 Redis 中默认的字符串表示,其结构体定义如下:
struct {
// 实际的字符数组,用于保存字符串
char buf[];
// buf数组中**已使用**字节的长度
int len;
// buf数组中的剩余空间长度
int free;
}
相比于原生的 C 字符串,SDS 有如下的优点:
- 以 O(1) 的时间复杂度获取字符串长度
- 不会出现「缓冲区溢出」
- 减少了字符串扩缩容时的内存重分配次数
- 二进制安全(可以存储视频等二进制数据)
- 同时兼容部分 C 字符串库函数