什么是SDS?
SDS是简单动态字符串,是Redis字符串使用的底层数据结构。
struct sdshdr {
// 记录 buf 数组中已使用字节的数量
// 等于 SDS 所保存字符串的长度
int len;
// 记录 buf 数组中未使用字节的数量
int free;
// 字节数组,用于保存字符串
char buf[];
};
SDS遵循C语言中字符串以空字符串结尾的惯例,保存空字符的那1个空间不算在len中。这样可以兼容一部分原生的C字符串函数。
比如redis在SDS中len为5,而不是6。
为什么redis要使用SDS?
Redis是用C语言写的,那么为什么不直接使用C语言原生的字符串,而是使用SDS?
因为C语言使用的这种简单的字符串表示方式, 并不能满足Redis对字符串在安全性、效率、以及功能方面的要求,
常数复杂度获得字符串的长度
原生的C字符串并不记录字符串的长度,要获取原生字符串的长度,需要循环遍历整个字符串,时间复杂度是O(n)。
但是SDS中的len字段记录了字符串的长度,因此获得字符串长度的时间复杂度是O(1)。
杜绝缓冲区溢出
原生的字符串极其容易忽略掉缓冲区溢出的情况,导致将其他的内存覆写。
但是SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS的API需要对SDS进行修改时, API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现前面所说的缓冲区溢出问题。
减少修改字符串带来的内存重分配次数
原生的字符串API,每次增长或者缩进一个字符,程序都会对这个字符串进行一个内存重分配的操作:
- 如果程序执行的是增长字符串的操作, 比如拼接操作(
append), 那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。 - 如果程序执行的是缩短字符串的操作, 比如截断操作(
trim), 那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
而内存重分配涉及到复杂的算法,并且可能需要执行系统调用,一般都是比较耗时的操作。
Redis经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都要进行一次内存重分配,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。
为了避免原生字符串的这种缺陷,SDS通过未使用空间free解除了字符串长度和底层数组长度之间的关联。
空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外的未使用空间。
额外分配的未使用空间的大小由下方公式确定:
- 如果修改后
SDS的len小于1MB,那么free和len的值相等 - 如果修改后
SDS的len大于1MB,那么free的大小为1MB
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作: 当SDS的API需要缩短SDS保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节, 而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。
二进制安全
原生字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外, 字符串里面不能包含空字符, 否则最先被程序读入的空字符将被误认为是字符串结尾。这些限制使得 C 字符串只能保存文本数据, 而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
而SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据, 程序不会对其中的数据做任何限制、过滤、或者假设 —— 数据在写入时是什么样的, 它被读取时就是什么样。
总结
| C 字符串 | SDS |
|---|---|
| 获取字符串长度的复杂度为 | 获取字符串长度的复杂度为 |
| API 是不安全的,可能会造成缓冲区溢出。 | API 是安全的,不会造成缓冲区溢出。 |
修改字符串长度 N 次必然需要执行 N 次内存重分配。 | 修改字符串长度 N 次最多需要执行 N 次内存重分配。 |
| 只能保存文本数据。 | 可以保存文本或者二进制数据。 |
可以使用所有 <string.h> 库中的函数。 | 可以使用一部分 <string.h> 库中的函数。 |