前言
在 Redis 中没有直接使用 C 语言的字符串表示(以空字符结尾的字符数组),而是自己构建了 简单动态字符串(SDS) 的抽象类型,并将它用作 Redis 的默认字符串表示。
不过,C 字符串在 Redis 中会作为字符串字面量用在一些不需要对字符串值修改的地方,比如打印日志。
当 Redis 需要一个可以被修改的字符串值时,将使用 SDS 来表示字符串值。在 Redis 数据里面,包含字符串值的键值对在底层都是由 SDS 实现的。
比如,客户端执行命令:
SET msg "hello world"
Redis 将在数据库中创建一个新的键值对,其中:
1、键值对的键是一个字符串对象,底层实现是一个保存 "msg" 的 SDS;
2、键值对的值也是一个字符串对象,底层实现是一个保存字符串 "hello world" 的SDS。
1、SDS的定义
SDS 数据结构如下:
struct sdshdr { // 记录 buf 数组中已使用字节的数量 // 等于 SDS 所保存字符串的长度 int len; // 记录 buf 数组中未使用字节的数量 int free; // 字节数组,用于保存字符串 char buf[ ];};
图表示例如下:
sdshdr |
free 0 |
len 5 |
buf |
buf 对应的字符串:
R | e | d | i | s | \0 |
说明:
1、free 属性的值为0,表示这个 SDS 没有分配任何未使用空间;
2、len 属性的值为5,表示这个 SDS 保存了一个5字节长的字符串;
3、buf 是一个 char 类型的数组,数组的前5字节分别保存了 r e d i s 五个字符串,最后一个字节保存了空字符 `\0`。
SDS 遵循 C 字符串以空字符结尾的惯例,保存空字符的1字节不计算在 SDS 的 len 属性里面(这个都是由 SDS 函数自动完成的)。
下面展示另外一个 SDS 示例。
分配未使用空间的 SDS 如下:
sdshdr |
free 4 |
len 5 |
buf |
buf 对应的字符串:
R | e | d | i | s | \0 |
图中使用4个空格来表示4字节未使用空间。
2、SDS 与 C 字符串的区别
C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符 `\0`。
这种简单的字符串表示方式,并不能满足 Redis 对字符串在安全性、效率以及功能方面的要求。
2.1 常数复杂度获取字符串长度
因为 C 字符串并不记录自身的长度信息,如果需要获取一个字符串的长度,需要遍历整个字符串,并且对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。
跟 C 字符串不同,SDS 在 len 属性中记录了本身的长度,所以获取一个 SDS 的复杂度仅为 O(1)。这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。
2.2 杜绝缓冲区溢出
c 字符串不记录自身长度带来的另外一个问题是容易造成缓冲区溢出:
1、执行字符串拼接的时候,当原字符串没有足够多的内存可以容纳拼接后的字符串,将产生缓冲区溢出。
2、假设程序中有两个在内存中紧邻着的 C 字符串 s1 和 s2,当将 s1 修改成比原先 s1 更加大的字符串时,s1 的数据将溢出到 s2 所在的空间,导致别的字符串 s2 的内容被意外地修改。
例子:
在内存中紧邻的两个 c 字符串 "Go" 与 "Java"。
... | G | o | \0 | J | a | v | a | \0 | ... |
当将 "Go" 修改为 "Golang"时,忘记了为 "Go" 分配足够多的空间,那么:
... | G | o | l | a | n | g | \0 | ... |
SDS 与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改后所需的要求,如果不满足,API 会自动将 SDS 的空间扩展至所需的大小,再执行实际的修改操作。
2.3 减少修改字符串时带来的内存重分配次数
因为 C 字符串的长度和底层数组的长度存着关联性(包含 N 个 字符的 C 字符串的底层实现总是一个 N+1 个字符长的数组),所以每次增长或者缩短一个 C 字符串,程序总要对保存这个 C 字符串的数组进行一次内存分配操作。
1、如果需要增长字符串,事先需要通过内存重分配来扩展底层数组的空间大小(如果忘记就会产生缓冲区溢出);
2、如果是缩短字符串,事先需要通过内存重分配来释放不再使用的那部分空间(如果忘记则会产生内存泄漏)。
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作。
但是 Redis 作为数据库,经常被用于速度要求严苛、数据频繁修改的场合,如果这种修改频繁发生的话,可能会对性能造成影响。
为了避免这种影响,SDS 通过未使用空间解除了字符串长度和底层数组长度的关联。通过未使用空间,SDS 实现了 空间预分配 和 惰性空间释放 两种优化策略。
1、空间预分配
空间预分配主要用于优化 SDS 的字符串增长操作 。
当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为 SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间。
额外分配的未使用空间数量由以下公式决定:
1、如果对 SDS 修改后,SDS的长度将小于 1MB,那么程序将分配和 len 属性同样大小的未使用空间;
2、如果将大于等于 1MB,那么将分配 1MB的未使用空间。
例子:如果进行修改后,SDS 的 len 将变成13字节,那么程序也会分配13字节未使用空间,SDS 的 buf 数组的实际长度将变成 13+13+1=27 字节(额外一字节用于保存空字符)
在扩展 SDS 空间前,SDS API 会先检查未使用空间是否足够,如果足够,API 就会直接使用未使用空间,而不需要执行内存重分配。
通过这种策略,SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。
2、惰性释放
惰性释放主要用于优化 SDS 的字符串缩短操作。
当需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多余出来的字节,而是使用 free 属性将这些字节的数量记录起来,以供后续使用。
另外,SDS 也提供了相应的 API,可以去真正释放 SDS 未使用空间。
2.4 二进制安全
C 字符串中的字符必须符合某种编码(比如 ASII),并且出了字符串的末尾外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾。(这些限制使得 C 字符串只能保存文本数据,而不能保存图片、音频、压缩文件主要的二进制数据)。
而 SDS 的 API 都是二进制安全的,所有的 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据进行修改。
2.5 兼容部分 C 字符串函数
SDS 遵循 C 字符串以空字符结尾的惯例,在末尾设置为空字符,并且为 buf 数组分配空间时多分配一个字节来容纳这个空字符。 这是为了让那些保存文本数据的 SDS 可以重用一部分 库定义的函数。
2.6 总结
C 字符串和 SDS 之间的差别。
3. SDS API
函数名称 | 作用 | 复杂度 |
sdsempty | 创建一个只包含空字符串””的sds | O(N) |
sdsnew | 根据给定的C字符串,创建一个相应的sds | O(N) |
sdsnewlen | 创建一个指定长度的sds,接受一个指定的C字符串作为初始化值 | O(N) |
sdsdup | 复制给定的sds | O(N) |
sdsfree | 释放给定的sds | O(1) |
sdsupdatelen | 更新给定sds所对应的sdshdr的free与len值 | O(1) |
sdsMakeRoomFor | 对给定sds对应sdshdr的buf进行扩展 | O(N) |
sdscatlen | 将一个C字符串追加到给定的sds对应sdshdr的buf | O(N) |
sdscpylen | 将一个C字符串复制到sds中,需要依据sds的总长度来判断是否需要扩展 | O(N) |
sdscatprintf | 通过格式化输出形式,来追加到给定的sds | O(N) |
sdstrim | 对给定sds,删除前端/后端在给定的C字符串中的字符 | O(N) |
sdsrange | 截取给定sds,[start,end]字符串 | O(N) |
sdscmp | 比较两个sds的大小 | O(N) |
sdssplitlen | 对给定的字符串s按照给定的sep分隔字符串来进行切割 | O(N) |