Redis-简单动态字符串SDS

Redis-简单动态字符串SDS

好久没写博客了,今天水一篇文章。

Redis没有使用c语言传统的字符串去表示。而是构建了一种名为简单动态字符串(simple dynamic string, SDS)的抽象类型,并将sds用作redis的默认字符串表示。

举个例子set msg "hello world"

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串”msg“的sds
  • 键值对的值也是一个字符串对象,对象的底层实现是一个保存着字符串“hello world"的sds

又比如rpush fruits "apple" "banana"

  • 键值对的键是一个字符串对象,对象的底层实现是一个保存着字符串”msg“的sds
  • 键值对的值是一个列表对象,列表对象包含了两个字符串对象,一个sds保存着apple,另一个保存着banana

SDS的定义

每个sds.h/sdshdr结构表示一个SDS值:

struct sdshdr {
    // 记录buf数组中已使用字节数量 等于sds所保存字符串的长度
    int len;
    
    // 记录buf数组中未使用的字节数量
    int free;
    
    // 字节数组 用于保存字符串
    char buf[];
}
image
  • free属性值为0,表示这个sds没有分配任何使用空间。
  • len属性值为5,表示这个sds保存了一个五字节长的字符串。
  • buf属性是一个char类型的数组,数组的前5个字节分别保存了为Redis五个字符,而最后一个字节则保存了空字符\0

sds遵循c语言字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在sdslen属性里面。并且为空字符分配额外的1字节空间及添加空字符到字符串末尾等操作都是有sds函数自动完成,所以这个空字符对于sds的使用者是完全透明的。

SDS与C字符串的区别

c语言字符串使用长度为N+1的字符数组表示长度为N的字符串,并且字符数组的最后一个元素为空字符\0

这种简单的字符串不能满足redis对字符串在安全性、效率性以及功能方面的要求。

常数复杂度获取字符串长度

c字符串并不记录自身的长度信息,所以获取一个c字符串的长度,程序需要遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,时间复杂度为O(N)

sdslen属性记录了sds本身的长度,所以获取一个sds的长度复杂度为O(1)

设置和更新sds的长度工作是由sdsapi在执行时自动完成的,使用sds无须进行任何手动修改长度的工作。

通过使用sds而不是c字符串,redis将获取字符串长度所需的时间复杂度从O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为redis的性能瓶颈。

杜绝缓冲区溢出

c字符串不记录自身长度将会带来另一个问题:容易造成缓冲区溢出(buffer overflow)

strcat函数可以将src字符串的内容拼接到dest字符串末尾:

char *strcat(char *dest, const char *src);

因为c字符串不记录自身的长度,所以stract假定用户在执行这个函数时,已经为dest分配了足够多的内存,可以容纳src字符串中的所有内容,而一旦这个假定不成立,就会产生缓冲区溢出。

举个例子,假设程序有两个在内存中紧邻着的c字符串s1s2,其中s1保存了字符串redis,而s2则保存了字符串MongoDb。如图:

image

如果一个程序决定通过执行:

strcat(s1, " Cluster");

s1的内容修改为Redis Cluster,但粗心的却忘记了在执行strcat之前为s1分配足够的空间,那么函数执行之后,s1的数据将溢出到s2所在的空间中,导致s2保存的内容被意外的修改。如图:

image

c字符串不同,sds的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当sdsapi需要对sds进行修改时,api会先检查sds的空间是否满足修改所需的要求,如果不满足,会自动将sds的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作。所以使用sds不需要手动修改sds的空间大小,也不会出现缓冲区溢出问题。

举个例子:

image
sdscat(s, " Cluster")

sdscat将在执行拼接操作之前检查s的长度是否足够,不够将扩展空间,才去执行拼接操作,拼接完成后的sds如图:

image

sdscat不仅为这个sds进行拼接操作,还分配了13字节的未使用空间,并且拼接之后的字符串正好也是13字节,这不是bug,而是与sds的分配空间策略有关,下面会说明。

减少修改字符串时带来的内存重分配次数

因为c字符串的长度和底层数组的长度直接存在着关联关系,所以c语言字符串每次增长或者缩短都要进行一次内存重新分配操作;

  • 如果程序执行增长字符串操作,执行操作前,程序需要先通过内存重新分配来扩展底层空间大小,忘记这步操作将会产生缓冲区溢出。
  • 如果程序执行缩短字符串操作,执行操作后,程序需要通过内存重新分配来释放字符串不使用的空间,忘记这步操作会产生内存泄露。

对于redis,经常被用于速度要求严苛、数据被频繁修改的场景,如果每次修改字符串长度都要执行一次内存分配,光是执行内存重新分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生,可能会对性能造成影响。

为了避免c字符串的这种缺陷,sds通过未使用空间解决了字符串长度和底层数组长度之间的关联。通过未使用空间,sds实现了空间预分配和惰性空间释放两种优化策略。

空间预分配

空间预分配用于优化sds的字符串增长操作:当sdsapi对一个sds进行修改,并且需要对sds进行空间扩展的时候,程序不仅会为sds分配修改所必须要的空间,还会为sds分配额外的未使用空间。

其中,额外分配未使用空间数量由以下公式决定:

  • 如果对sds进行修改后,sds的长度将小于1mb,那么程序会分配和len属性同样大小的未使用空间,这时sdslen属性和free的值相同。举个例子,进行修改后len10,那么free也为10sdsbuf数组的实际长度为10 + 10 + 1 = 21
  • 如果对sds进行修改后,sds的长度将大于1mb,那么程序会分配1mb的使用空间。举个例子,进行修改后len10mb,那么free1mbsdsbuf数组的实际长度为10mb + 1mb + 1byte

懒惰空间释放

懒惰空间释放用于优化sds的字符串缩短操作:当sdsapi需要缩短sds保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的自己,而是使用free属性将这些字节的数量记录起来,并等待将来使用。

举个例子,sdstrim函数接受一个sds和一个c字符串作为参数,从sds左右两端分别移出所以在c字符串中出现的字符。

如图:

image

执行:

sdstrim(s, "XY"); // 移除 sds 字符串中所有 ‘X’和‘Y’

会将sds修改成如下图:

image

注意执行sdstrim之后的sds并没有释放出来多余的8字节空间,而是将这8字节空间作为未使用空间保留在了sds里面,如果将来要sds进行增长操作,这些未使用空间就会用上。

举个例子,现在对s执行:

sdscat(s, " Redis");

那么完成这次sdscat操作将不需要执行内存重新分配,因为sds预留的8字节空间足以拼接6个字节的Redis,通过惰性空间释放策略,sds避免了缩短字符串所需的内存重分配操作,并为将来可能有的增长操作提供了优化,如图:

image

与此同时,sds也提供了相应的api,让我们可以在有需要时,真正的释放sds的未使用空间,所以不必担心惰性空间释放策略会造成内存浪费。

二进制安全

c字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符串,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得c字符串只能保存文本数据,不能保存图片、音频这样的二进制数据。

举个例子,如果有一种使用空字符串分割多个单词的特殊数据格式,那么这种格式就不能使用c字符串来保存,因为c字符串所用的函数之后识别出其中的Redis,而忽略之后的Cluster。如图:

image

sdsbuf属性被成为字节数组的原因——redis不是用这个数组来保存字符,而是用它来保存一系列二进制数据。

例如,使用sds来保存之前提到的数据格式就没有问题,因为sds使用len属性的值而不是空字符来判断字符串是否结束,如图:

image

兼容部分c字符串函数

虽然sdsapi都是二进制安全,但它们一样遵循c字符串以空字符串结尾的惯例:这些api总会将sds保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节来容纳这个字符串,这是为了让那些保存文本数据的sds可以重用一部分库定义的函数。

image

举个例子,如果有一个保存文本数据的sds,如上图,那么我们就可以重用/strasecmp函数,使用它来对比sds保存的字符串另一个c字符串:

strcasecmp(sds->buf, "hello world");

遵循c字符串以空字符结尾的惯例,sds可以在有需要时重用函数库,避免不必要的代码重复。

总结

C字符串 SDS
获取字符串长度的复杂度为O(N) 获取字符串长度的复杂度为O(1)
API是不安全的,可能会造成缓冲区溢出 API是安全的,不会造成缓冲区溢出
修改字符串长度N次必然需要执行N次内存重分配 修改字符串长度N次最多需要执行N次内存重分配
只能保存文本数据 可以保存文本数据或者二进制数据
可以使用库中的函数 可以使用一部分库中的函数

打赏

如果我的文章对您有帮助:打赏一下哟!传送门

你可能感兴趣的:(Redis-简单动态字符串SDS)