二、简单动态字符串
2.1 SDS 的定义
SDS 是【简单动态字符串】,其结构如下:
- free 属性值为 0,表示这个 SDS 没有分配任何未使用的空间。
- len 属性值为 5,表示这个 SDS 保存了一个五字节长的字符串(’\0’不包括在内)。
- buf 属性是一个 char 类型的数组,保存数据,最后一个字节保存了空字符 ‘\0’。
2.2 SDS 与 C 字符串的区别
2.2.1 常数复杂度获取字符串长度
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符串进行计数,直到遇到了代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。而 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为 O(1)。
通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 O(N) 降低到 O(1),这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。
2.2.2 杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外,C 字符串不记录自身长度带来的另一个问题就是容易造成缓冲区移除。
例如:字符串拼接函数strcat(char *dest, const char *src),因为 C 字符串不记录自身的长度,所以 strcat 假定用户在执行这个函数时,已经为 dest 分配了足够多的内存,可以容纳下 src 字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。
与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性;当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足,API 会自动跳过 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作。
2.2.3 减少修改字符串时带来的内存重分配次数
因为 C 字符串并不记录自身的长度,所以对于一个包含 N 个字符串的 C 字符串来说,这个 C 字符串的底层实现总是一个 N+1 个字符长的数组(额外的一个字符空间用于保存空字符)。因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或缩短一个 C 字符串,程序都总要对保存这个 C 字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作,比如拼接操作,那么在执行这个操作之前,程序需要先通过内存分配来扩展底层数组的空间大小– 如果忘了这一点就会产生缓冲区溢出。
- 如果程序执行的是缩短字符串的操作,比如截断操作,那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间 – 如果忘了这一步就会产生内存泄漏。
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以他是一个比较耗时的操作。
- 在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。
- 但是 Redis 作为数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生的话,可能还会对性能造成影响。
为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联;在 SDS 中,buf 数组的长度不一定就是字符串数量加一,数组里面可以包含1未使用的字节,而这些字节的数量就由 SDS 的 free 属性记录。
通过未使用空间,SDS 实现了空间预分配和惰性空间释放两种优化策略。
1. 空间预分配
空间预分配用于优化 SDS 的字符串增长操作,当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间拓展的时候,程序不仅会为 SDS 分配修改所需要的空间,还会为 SDS 分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定:
- 如果对 SDS 进行修改之后,SDS 的长度将小于 1MB,那么程序分配和 len 属性同样大小的未使用空间,这时 SDS len 的属性的值将和 free 属性的值相同。
- 如果对 SDS 进行修改之后,SDS 的长度将大于 1MB,那么程序会分配 1MB 的未使用空间。
通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。
在扩展 SDS 空间之前,SDS API 会先检查未使用空间是否足够,如果足够的话,API 就会直接使用未使用空间,而无需执行内存重分配。
2. 惰性空间释放
惰性空间释放用于优化 SDS 的字符串缩短操作,当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录下来,并等待将来使用。
2.2.4 二进制安全
C 字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
为了确保 Redis 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全的,所有 SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是怎么样的,他被读取时就是怎么样的。
通过使用二进制安全的 SDS,而不是使用 C 字符串,使得 Redis 不仅可以一保存文本数据,还可以保存任意格式的二进制数据。
2.2.5 兼容部分 C 字符串函数
虽然 SDS 的 API 都是二进制安全的,但他们一样遵循 C 字符串以空字符结尾的惯例,这些 API 总会将 SDS 保存的数据的末尾设置为空字符,并且总会在 buf 数组分配空间时多分配一个字节来容纳这个空字符,这是为了保存文本数据的 SDS 可以宠用一部分 <string.h> 库定义的函数。
总结:
C 字符串 | SDS |
---|---|
获取字符串长度的复杂度为 O(N) | 获取字符串长度的复杂度为 O(1) |
API 是不安全的,可能会造成缓冲区溢出 | API 是安全的,不会造成缓冲区溢出 |
修改字符串长度 N 次必然需要执行 N 次内存重分配 | 修改字符串长度 N 次最多需要执行 N 次内存重分配 |
只能保存文本数据 | 可以保存文本或者二进制数据 |
可以使用 <string.h> 库中的函数 | 可以使用一部分 <string.h> 库中的函数 |