Redis 没有直接使用 C 语言传统的字符串表示,而是自己创建了一种新的数据结构叫简单动态字符串(Simple Dynamic String)SDS,并且将它作为默认字符串的表示。
SDS数据结构
c
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
}
SDS 遵循 C 字符串以空字符串结尾的惯例,保存空字符串的 1 字节空间不计算在 SDS 的 len 属性里面,并且为空字符串分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的。遵循 C 字符串以空字符串结尾的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。
SDS 和 C 字符串的区别
获取字符串长度的时间复杂度
C 字符串本身是不记录自身的长度信息的,当我们需要获取 C 字符串的长度时就需要遍历整个字符串计数,直到遇到空字符为止,这个操作的时间复杂度是 O(N)。
SDS 和 C 字符串并不同,当你需要获取长度信息的时候直接访问 SDS 的 len 属性即可,这个操作的时间复杂度是 O(1)。设置和更新 len 属性都由 SDS 的 API 在执行时自动完成。
缓冲区溢出
C 字符串不记录自身的长度信息的特性会带来一个问题,那就是缓冲区溢出。比如说我们使用 C 语言中的 strcat 函数,这个函数是将一个字符串拼接到另一个字符串末尾,假设现在我们要把 "Cluster" 拼接到 "Redis" 后,如果我们在执行函数的时候,已经为 "Redis" 分配了一定的内存,但是我们拼接 "Cluster" 的时候,分配的内存不足以存储 "Cluster",那么就会产生缓冲区溢出。
SDS 的空间分配策略就完全杜绝了发生缓冲区溢出的可能性,当需要修改 SDS 时,SDS 的 API 会检查空间是否满足所修要的修改,如果不满足,会扩充存储空间的大小,然后才执行修改的操作。
内存分配次数
C 字符串不记录自身的长度信息的特性会带来一个问题,C 字符串需要多记录一个 '\0' 来记录结束的位置,以便于计算长度的时候使用,C 的底层维护了 N+1 字符长的这样一个数组,那么如果有修改,我们的程序总是需要去对这个数组进行一次内存重分配的操作,内存重分配的涉及复杂的算法并且可能执行系统调用,通常会是一个比较耗时的操作,当 Redis 作为一个数据库时,速度的要求会比较严苛,作为一个数据库,肯定会频繁的改动,如果每次改动都进行一次内存重分配,会对性能造成影响。
SDS 使用空间预分配和惰性空间释放这两种优化策略优化了 C 字符串的这种缺陷。
空间预分配
- 如果对 SDS 修改之后, len 属性小于 1MB,那么程序会分配和 len 属性同样大小的未使用空间。比如说 SDS 进行修改了之后,len 属性变成 13 字节,那么程序也会分配 13 字节的未使用空间,也就是 free 属性为 13 字节,SDS 的 buff 数组实际的长度就变为 13 + 13 + 1 = 27。
- 如果对 SDS 修改之后,SDS 的长度大于等于 1MB,那么程序会分配 1MB 的未使用空间。比如说 SDS 进行修改之后,SDS 的 len 属性变成 10MB,那么程序会分配 1MB的未使用空间,SDS 的 buff 数组实际的长度就变为 10MB + 1MB +1Byte。
通过空间预分配策略,Redis 就可以减少字符串增长操作所需的内存重分配次数。
我们来分析一下上面的过程,刚开始有这么一个值 str"Redis",如图1-1所示,当我们进行"is"拼接的时候, SDS 会进行一次内存重分配,将 str 的长度修改为 8 字节,同时分配 8 字节的未使用空间,如图 1-2所示,当我们再拼接"fast"时,这次 SDS 不会进行内存重分配,因为我们还有 8 字节的未使用空间,这足以保存 4 字节的"fast",如图 1-3 所示。通过这种策略,将连续增长 N 次字符串所需内存重分配次数从必定 N 次降低为最多 N 次。
惰性空间释放
当我们截断字符串或者缩短字符串长度的时候,SDS 并不会内存重分配来回收那些不使用的内存,而是将它记录在 free 属性里以便后续使用。
还是用刚才的例子,我们得到最后的值 str "Redis is fast",如图2-1所示,现在我们需要截断字符串,去掉 "is fast",去掉以后,SDS 并不会内存重分配,而是将去掉的 8 字节保留了下来,并加入到了 free 属性里,当我们再拼接字符串 "is" 的时候,SDS 就不需要内存重分配扩展内存空间,SDS 剩余 11 字节的空间足以拼接 2 字节的 "is",通过这种惰性空间释放策略,避免了 SDS 在缩短字符串时需要内存重分配的操作,也为后续需要拼接增长的操作提供了优化。
二进制安全
因为 C 字符串是以空字符串来判断结尾的,那么存取的 C 字符串里面不能包含空字符,那么程序会误判断提前结束,这使得 C 字符串只能保存文本数据,不能保存图片、音频、视频和压缩文件这样的二进制数据。虽然数据库一般都保存文本数据,但是为了让数据库满足各种场景,SDS 的 API 是二进制安全的,数据在写入时是怎么样的,读取的时候就是怎么样的,并不会对数据进行处理,SDS 不会就出现像 C 字符串一样误判,是因为 SDS 是依靠 len 属性的值判断字符串是否结束而不是空字符。
总结
C 字符串 | SDS | |
---|---|---|
时间复杂度 | 需要遍历整个字符串统计长度,O(N) | 只需要访问 len 属性即可得到长度,O(1) |
缓冲区溢出 | API不安全,存在缓冲区溢出 | API安全,不存在缓冲区溢出 |
内存分配次数 | 字符串修改 N 次就需要 N 次内存重分配 | 字符串修改 N 次最多需要 N 次内存重分配 |
二进制安全 | 只保存文本,不安全 | 可以保存文本和二进制数据,安全 |
参考
《Redis设计与实现》黄建宏