Redis 的底层是C语言,但是其实Redis并没有使用C语言传统的字符串表示,而是自己构建了一种名为
简单动态字符串(simple dynamic string,SDS)
的抽象类型,并将SDS用作Redis的默认字符串表示。
每个 sds.h/sdshdr
结构表示一个SDS值
struct sdshdr{
int len; // 记录buf数组中已使用字节的数量
// 等于SDS所保存字符串的长度
int free; // 记录buf数组中未使用字节的数量
char buf[];// 字符数组,用于保存字符串
}
SDS 遵循C字符串以空字符结尾的惯例,保存空字符的1字节空间不计 算再SDS的 len 属性里面,并且为空字符分配额外的1字节空间,以及添加空字符串末尾等操作,都是SDS函数自动完成的。
因为C字符串并不记录自身的长度信息,所以为了获取一个C字符串的长度,程序必须遍历整个字符串,对遇到的每一个字符都进行计数,直到遇到代表字符串结尾的空字符为止。复杂度O(N)
。
而SDS在len属性中就已经记录了SDS本身的长度,所以获取一个SDS长度的复杂度为O(1)
。设置和更新SDS长度的工作是由SDS的api在执行时自动完成
的,使用sds无需进行任何手动修改长度的工作
。
所以通过使用SDS而不是C字符串,Redis将获取字符串的长度所需要的复杂度从O(N)降低了O(1)
,这确保了长度的工作不会是redis的性能瓶颈
,即时是一个非常长的字符串,我们反复执行 STRLEN 命令,那么这个复杂度始终是O(1),不会对系统造成任何的影响。
C字符串不记录自身长度带来的另一个问题就是容易造成缓冲区溢出。
例如:
函数将 src 字符串中 的内容拼接到 dest 字符串 的末尾。
char *strcat(char *dest,const char *src)
因为C字符串不记录自身长度,所以strcat假定用户在执行这个函数时,已经为 dest 分配了足够多的内存
,可以容纳 src 字符串中的所有内容,而一旦这个假定不成立时,就会产生缓冲区溢出。
与C字符串不同的是,SDS的空间 分配策略完全杜绝了发生缓冲区溢出的可能性:
当SDS API需要对SDS进行修改时,API会先检查SDS的空间是否满足修改所需的要求,如果不满足的话,API会自动将SDS的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作,所以使用SDS既不需要手动修改
SDS的空间大小,也不会出现前面所说的缓冲区溢出
的问题。
因为C字符串并不记录自身的长度,所以对于一个包含了N个字符的C字符串来说,这个字符串的底层实现总是一个N+1个字符长的数组(额外的一个字符串空间用来保存空字符串)。
所以C字符串的长度和底层数组的长度之间存在着这种关联性,所以 每次增长或是缩短一个C字符串
,程序都总要对 保存这个C的字符串数组进行一次内存重分配操作
:
缓冲区的溢出。
内存泄漏。
在一般程序中,如果修改字符串长度的情况不太常出现,那么每次修改都执行一次内存重分配是可以接受的。
但是redis作为数据库,常常被用于速度要求严苛,数据被频繁修改的场景,如果每次修改字符串的长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去 修改字符串
所需要的大部分时间,如果修改频繁发生就会对性能造成影响。
所以为了避免这种情况,SDS通过未使用空间free
解除了字符串长度和底层数组长度之间的关联:
在SDS中,buf数组的长度不一定是字符数量+1,数组里面可以包含
未使用的字节
,而这些字节的数量就由SDS的free属性
记录。
空间预分配用于优化SDS的字符串增长操作:当SDS的API对一个SDS进行修改,并且需要对SDS进行空间扩展的时候,程序不仅会为SDS分配修改所必须要的空间,还会为SDS分配额外未使用的空间。
其中,额外分配的未使用空间数量由以下公式决定:
len属性同样大小的未使用空间
,这时候SDS的 len属性将和 free属性的值一致。分配1MB的未使用空间
。通过空间预分配策略,Redis可以 减少连续执行字符串增长操作所需的内存重分配次数
。
惰性空间释放用于优化 SDS 的字符串缩短操作:当SDS的API需要缩短SDS保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节
,而是使用free属性将这些字节的数量记录起来
,并等待将来使用。
当然,于此同时,SDS也提供了对应的API,让我们可以在有需要的时候,真正地释放SDS的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
C字符串中的字符必须符合某种编码(比如ASCⅡ),并且除了字符串的末尾之外,字符串里面不能包含空字符串,否则最先被程序读入的空字符串将被误认为是字符串结尾
,这些限制使用C字符串只能保存文本数据
,而不能保存像图片、音频、视频、压缩文件这种的二进制数据。
虽然数据库一般用于存储文本数据,但是数据库保存二进制数据的场景并不少见。因此,为了确保Redis可以适用于各种不同的使用场景,SDS的API都是二进制安全的,所以SDS API都会处理二进制的方式来处理buf数组里的数据,程序不会对其中的数据做任何限制,过滤或是假设。数据写入的时候什么样子,读取出来的时候就是什么样子。
这也是我们将SDS的buf属性成为字节数组的原因,Redis不是用这个数组来保存字符,而是用他来保存一系列的二进制数据。
通过使用二进制安全的SDS,而不是C字符串,使用Redis不仅可以保存文本数据,还可以保存任意格式的二进制数据。
虽然SDS的API都是二进制安全的,但是他们一样遵循C字符串以空字符结尾的惯例:
这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时,多分配一个字节来容纳这个空字符串,这是为了让那些保存文本数据的SDS可以重用一部分
举个例子:
如果我们有下图一个保存文本数据的SDS值得sds
那么我们就可以重用
,使用它来对比SDS的buf属性保存的字符串和另一个C字符串:
strcasecmp(sds->buf,"sheep");
这样redis就不用自己专门去写一个函数来对比SDS值和C字符串值了。