redis的数据结构——简单动态字符串
一、前言
redis采用C语言来实现,但并没有使用C语言传统字符串的表示,而是自己构建了一种名为简单动态字符串(simple dynamic string,SDS)的数据结构,并将SDS作为Redis的默认字符串表示。在Redis里C字符串只会作为字面量用在一些无需对字符串修改的地方,入打印日志。
二、一个简单的例子
执行下图的命令,那么redis将创建一个键值对。其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存着"msg"字符串的SDS。
- 键值对的值是一个字符串对象,对象的底层实现是一个保存着"hellow world"字符串的SDS。
又如,如果客户端执行如下命令:
那么Redis将在数据库中创建一个新的键值对。其中:
- 键值对的键是一个字符串对象,对象的底层实现是一个保存着”fruit“字符串的SDS。
- 键值对的值是一个列表对象,该列表对象包含三个字符串对象,这三个字符串对象都是由SDS实现的:第一个SDS保存着字符串”apple“,第二个SDS保存着字符串”banana“,第三个SDS保存着字符串”cherry“。
除了用来保存数据库中的字符串之外,SDS还被用作缓冲区:AOF模块中的AOF缓冲区,以及客户端状态中的输入缓冲区都是由SDS来实现的。
三、SDS的定义
每一个SDS都是由如下结构表示的:
struct sdshdr { //记录buf数组中已经使用的字节数 //等于SDS所保存字符串的长度 int len; //记录buf数组中未使用字节的数量 int free; //字节数组,用于保存字符串 char buf[]; };
上图展示了一个Redis的SDS示例:
- free属性的值为0,表示这个SDS没有分配任何未使用的空间。
- len属性的值为5,表示这个SDS保存了一个5字节长的字符串。
- buf属性是一个char类型的数组,数组的前五个字节分别保存了‘R’,‘e’、‘d’、‘i’、‘s’五个字符。而最后一个字节保存了空字符‘\0’。
需要注意的是,SDS遵循C语言字符串以空字符结尾的规范,保存的空字符的一个字节不计算到len属性中,并且为空字符分配额外的空间,以及添加空字符到字符串末尾都是由SDS的函数自动完成的,所以这个空字符串在SDS中是完全透明的。遵循C语言字符串的规范带来的好处是可以复用C语言提供的一些字符串操作函数。
四、SDS与C字符串的区别
1、常数复杂度获取字符串长度
因为C语言字符串并不记录自身长度信息,所以想要得到C语言字符长度必须遍历字符串,直到遇到结尾的空字符串,这个操作时间复杂度为O(n)。而SDS通过len属性记录了字符串的长度,只要访问len属性就可以得到字符串的长度,这个操作的时间复杂度为O(1)。设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无需进行任何手动修改长度的工作。
2、杜绝缓冲区溢出
除了获取字符串长度时间复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出(buffer overflow)。例如对C字符串进行拼接操作时使用char *strcat (char *dest, char *src) 函数。如果没有为dest分配足够多的内存,那么拼接时就会产生缓冲区溢出。
与C语言不同的是SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当SDS的API需要修改SDS时,API会先检查SDS的空间是否满足修改需求,如果不满足,API自动将SDS的空间扩展至执行修改所需要的大小,然后再进行修改,所以SDS既不需要手动修改SDS空间的大小,也不会出现缓冲区溢出的问题。
3、减少修改字符串时带来的内存重分配次数
对与一个包含N个字符的字符串,C语言的底层实现总是一个N+1长度的字符数组。因为C字符串的长度和底层数组的长度存在这种关联性,所以每次增长或缩短一个C字符串时,程序都总要对保存这个C字符串的数组进行一次内存重分配。
- 如果程序要增长字符串,那么在执行这个操作之前需要通过内存重分配扩展字符数组的空间大小——如果不进行会产生缓冲区溢出。
- 如果程序要缩短字符串,那么在执行这个操作之前需要通过内存重分配来释放字符串不在使用的那部分空间——如果不进行会产生内存泄漏。
因为内存重分配涉及复杂的算法,并且可能会发生系统调用,所以它通常是一个比较耗时的操作:一般的程序中,如果修改字符串的操作不太频繁出现,那么每次执行内存重分配是可以接受的,但redis经常被用于速度要求严苛,数据被频繁修改的场合,如果每次修改字符串都需要执行内存重分配,那么光是分配内存就占据了大量的时间,如果这种修改频繁发生的话,可能还会对性能造成影响。
为了避免这种情况的发生,SDS实现了空间预分配和惰性空间释放两种优化策略。
空间预分配
当API需要对SDS的空间进行扩展时,程序不仅会为SDS分配扩展所必须的空间,还会为SDS分配额外未使用的空间。
额外分配空间的策略如下:
- 如果对SDS进行修改后,SDS的长度(len属性的值)小于1MB,那么程序分配和len属性相同大小的未使用空间,这时len属性的值将和free属性的值相同。例如,如果修改之后的len将变成13个字节,那么程序也会分配13个字节的未使用空间,buf数组的长度将变成13+13+1=27。
- 如果对SDS进行修改后,SDS的长度大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len属性变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度为30MB + 1MB + 1byte。
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需要的内存分配次数,SDS连续增长N次字符串所需的内存分配次数从必定N次降低为最多N次。
惰性空间释放
当进行缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量记录下来,并等待将来使用。同时SDS也提供了相应的API,让我们可以在有需要时,真正释放SDS未使用的空间,所以不用担心惰性空间释放策略会造成空间浪费。
4、二进制安全
C字符串必须符号某种编码(如ASCII编码),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则会认为是字符串的结尾,这些限制使得C字符串只能保存文本数据。不能保存图片、音频、视频、压缩文件这样的二进制数据。而SDS的API都是二进制安全的,所有SDS 的API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤或假设,数据写入时什么样子,读取时就是什么样。这也是我们将SDS的buf属性称为字节数组的原因——Redis不是用这个数组保存字符,而是用它来保存一系列二进制数据。所以SDS不仅可以保存文本数据,还可以保存任意格式的二进制数据。
5、兼容部分C字符串函数
虽然SDS的API是二进制安全的,但它们一样遵循C字符串以空字符结尾的的惯例:这些API会将SDS保存的数据的末尾设置为空字符,并且总会为buf数组分配空间是多分配一个字节容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分C定义的函数,避免代码重复。
五、总结
Redis只会使用C字符串作为字面量,大多数情况下,Redis使用SDS作为字符串表示。
比起C字符串,SDS具有如下优势:
- 常数复杂度获取字符串长度。
- 杜绝缓冲区溢出。
- 减少修改字符串带来的空间重分配次数。
- 二进制安全。
- 重用部分C语言字符串函数。