Redis源码分析--- String
简单动态字符串(simple dynamic string,SDS),它和C字符串不一样,C字符串只会作为字符串字面变量用在一些无须对字符串值进行修改的地方,比如打印日志的地方。
当Redis需要的不仅仅是一个字符串字面变量,而是一个可以被修改的字符串值时,Redis就会使用SDS来表示字符串值,比如在Redis的数据库里面,包含字符串值的键值对在底层都是由SDS来实现的。
SDS的定义
每个sds.h/sdshdr结构表示一个SDS值:
struct sdshdr{
//记录buf数组中已使用字节的数量
//等于SDS所保存字符串的长度
int len;
//记录buf数组中未使用字节的数量
int free;
//字节数组,用于保存字符串
char buf[];
};
图2-1展示了一个SDS示例:
free属性的值为0,表示这个SDS没有分配任何未使用空间。
len属性的值为5,表示这个SDS保存了一个五字节长的字符串
buf属性是一个char类型的数组,数组的前五个字节分别保存了‘R’,‘e’,‘d’,‘i’,‘s’五个字符,而最后一个字节则保存了空字符‘\0’
SDS与C字符串的区别
根据传统,C语言使用长度为N+1的字符串数组来表示长度为N的字符串,并且字符数组的最后一个元素总是空字符‘\0’。
C语言使用这种简单的字符串表示方式,并不能满足Redis对字符串在安全性、效率以及功能方面的要求,接下来的内容详细对比C字符串和SDS之间的区别,并说明SDS比C字符串更适用于Redis的原因。
常数复杂度获取字符串长度
因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)。
和C字符串不同,因为SDS在len属性中记录了SDS本身的长度,所以获取一个SDS长度的复杂度仅为O(1)。Redis的SDS结构中有一个len属性就是直接记录存入的字符串的长度。
设置和更新SDS长度的工作是由SDS的API在执行时自动完成的,使用SDS无须进行任何手动修改长度。
通过SDS而不是C字符串,Redis将获取字符串长度所需的复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为Redis的性能瓶颈
杜绝缓冲区溢出
除了获取字符串长度的复杂度高之外,C字符串不记录自身长度带来的另一个问题时容易造成缓冲区溢出(buffer overfolw)。
因为C字符串不记录自身的长度,所以strcat假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而且一旦这个假定不成立时,就会产生缓冲区溢出。
SDS完全杜绝了发生缓冲区溢出的可能性:当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改SDS的空间大小,也不会出现在前面所说的缓冲区溢出问题。因为SDS字符串添加时会先获取这个字符串空间的大小, 判断是否能够将要插入的字符串插入, 如果空间不够,sdscat会先扩展SDS空间,然后才执行拼接操作。
减少修改字符串时带来的内存重新分配次数
如前边所说,C语言字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个C字符串的底层实现总是一个N+1个字符长的数组。因为C字符串的长度和底层数组的长度之间存在这个关联性,所以每次增加或者缩短一个C字符串,程序都总要对保存这个C字符串的数组进行一次内存重分配操作:
如果程序执行的是增长字符串的操作,比如append,那么在执行这个操作之前,程序需要先通过内存重新分配来扩展底层数组的空间大小---如果忘了这一步就会产生缓冲区溢出。
如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重新分配来释放字符串不再使用的那部分空间-----如果忘了这一步就会产生内存泄漏。
在一般程序中,如果修改字符串长度的情况下不太常出现,那么每次修改都执行一次内存重新分配是可以接受的。
但是作为Redis数据库,经常被用于速度要求严苛、数据被频繁修改的场合,如果每次修改字符串的长度都需要执行一次内存重新分配的话,那么光是执行内存重新分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生的话,可能还会对性能造成影响。
为了避免C字符串的这种缺陷,SDS通过未使用空间解除了字符串长度和底层数组长度之间的关联:在SDS中,buf数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。
通过未使用空间,SDS实现了空间与分配和惰性空间释放两种优化策略。
1.空间预分配
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必要的空间,还会为SDS分配额外的未使用空间。
其中,额外分配的未使用空间数量由以下公式决定:
如果对SDS进行修改之后,SDS的长度将小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS len属性的值将和free属性的值相同。
如果对SDS进行修改后,SDS的长度将大于等于1MB,那么程序会分配1MB的未使用空间。举个例子,如果进行修改之后,SDS的len将变成30MB,那么程序会分配1MB的未使用空间,SDS的buf数组的实际长度将为30MB+1MB+1byte。
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
在扩展SDS空间之前,SDS API会先检查未使用空间是否足够,如果足够的话,API就会直接使用未使用空间,而无需执行内存重新分配。
通过这种预分配策略,SDS将连续增长N次字符串所需的内存重新分配次数从必定N次降低为最多N次。
2.惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重新分配来回收缩短后多出来的字节,而是使用free属性将这些字节的数量纪录起来,并等待将来使用。
举个例子,sdstrim函数接受一个SDS和一个C字符串作为参数,移除SDS中所有在C字符串中出现的字符。
比如对于图2-14所示的SDS值s来说,执行:
sdstrim(s,"XY");//移除SDS字符串中的所有‘X’和‘Y’
会将SDS修改成图2-15所示的样子
注意执行sdstrim之后的SDS并没有释放多出来的8字节空间,而是将这8字节空间作为未使用空间保留在了SDS里面,如果将来要对SDS进行增长操作的话,这些未使用空间就可能会派上用场。
二进制安全
C字符串中的字符必须符合某种编码,并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得C字符串只能保存文本数据,而不能保存像图片、音频、压缩文件这样的二进制数据。
如果有一种使用空字符来分割多个单词的特殊数据格式,那么这种格式就不能使用C字符串来保存,因为C字符串所用的函数只会识别出其中的‘Redis’,而忽略之后的‘Cluster’。
虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的(binary-safe),所有的SDS API都会以处理二进制的方式来处理SDS存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
这也是我们将SDS的buf属性称为字节数组的原因---Redis不会使用这个数组来保存字符,而是用它来保存一系列二进制数据。
使用SDS来保存之前提到的特殊数据格式没有任何问题,因为SDS使用len属性的值而不是空字符来判断字符串是否结束,如下图所示:
通过使用二进制安全的SDS,而不是C字符串,使得Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。
兼容部分C字符串函数
虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的SDS可以重用一部分<string.h>库定义的函数。
举个例子,如上图所示,如果我们有一个保存文本数据的SDS值sds,那么我们就可以重用<string.h>/strcasecmp函数,使用它来对比SDS保存的字符串和另一个C字符串:
strcasecmp(sds->buf,"hoell world!");
这样Redis就不用自己专门去写一个函数来对比SDS值和strcat函数的第二个参数,将SDS保存的字符串追价到一个C字符串的后面:
strcat(c_string,sds->bfu);
这样Redis就不用专门编写一个将SDS字符串追加到C字符串之后的函数了。
通过遵循C字符串以空字符结尾的惯例,SDS可以在有需要时重用<string.h>函数库,从而避免了不必要的代码重复。
总结
对C字符串和SDS之间的区别进行了总结
比起C字符串,SDS具有以下优点:
(1)常数复杂度获取字符串长度
(2)杜绝缓冲区溢出
(3)减少修改字符串长度时所需的内存重新分配次数
(4)二进制安全
(5)兼容部分C字符串函数