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[];
}
-
free
属性值为0,表示这个sds
没有分配任何使用空间。 -
len
属性值为5,表示这个sds
保存了一个五字节长的字符串。 -
buf
属性是一个char
类型的数组,数组的前5个字节分别保存了为R
,e
,d
,i
,s
五个字符,而最后一个字节则保存了空字符\0
。
sds
遵循c
语言字符串以空字符结尾的惯例,保存空字符的1字节空间不计算在sds
的len
属性里面。并且为空字符分配额外的1字节空间及添加空字符到字符串末尾等操作都是有sds
函数自动完成,所以这个空字符对于sds
的使用者是完全透明的。
SDS与C字符串的区别
c
语言字符串使用长度为N+1
的字符数组表示长度为N
的字符串,并且字符数组的最后一个元素为空字符\0
。
这种简单的字符串不能满足redis
对字符串在安全性、效率性以及功能方面的要求。
常数复杂度获取字符串长度
c
字符串并不记录自身的长度信息,所以获取一个c
字符串的长度,程序需要遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,时间复杂度为O(N)
。
sds
在len
属性记录了sds
本身的长度,所以获取一个sds
的长度复杂度为O(1)
。
设置和更新sds
的长度工作是由sds
的api
在执行时自动完成的,使用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
字符串s1
和s2
,其中s1
保存了字符串redis
,而s2
则保存了字符串MongoDb
。如图:
如果一个程序决定通过执行:
strcat(s1, " Cluster");
将s1
的内容修改为Redis Cluster
,但粗心的却忘记了在执行strcat
之前为s1
分配足够的空间,那么函数执行之后,s1
的数据将溢出到s2
所在的空间中,导致s2
保存的内容被意外的修改。如图:
与c
字符串不同,sds
的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当sds
的api
需要对sds
进行修改时,api
会先检查sds
的空间是否满足修改所需的要求,如果不满足,会自动将sds
的空间扩展至执行修改所需要的大小,然后才执行实际的修改操作。所以使用sds
不需要手动修改sds
的空间大小,也不会出现缓冲区溢出问题。
举个例子:
sdscat(s, " Cluster")
sdscat
将在执行拼接操作之前检查s
的长度是否足够,不够将扩展空间,才去执行拼接操作,拼接完成后的sds
如图:
sdscat
不仅为这个sds
进行拼接操作,还分配了13
字节的未使用空间,并且拼接之后的字符串正好也是13
字节,这不是bug
,而是与sds
的分配空间策略有关,下面会说明。
减少修改字符串时带来的内存重分配次数
因为c
字符串的长度和底层数组的长度直接存在着关联关系,所以c
语言字符串每次增长或者缩短都要进行一次内存重新分配操作;
- 如果程序执行增长字符串操作,执行操作前,程序需要先通过内存重新分配来扩展底层空间大小,忘记这步操作将会产生缓冲区溢出。
- 如果程序执行缩短字符串操作,执行操作后,程序需要通过内存重新分配来释放字符串不使用的空间,忘记这步操作会产生内存泄露。
对于redis
,经常被用于速度要求严苛、数据被频繁修改的场景,如果每次修改字符串长度都要执行一次内存分配,光是执行内存重新分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生,可能会对性能造成影响。
为了避免c
字符串的这种缺陷,sds
通过未使用空间解决了字符串长度和底层数组长度之间的关联。通过未使用空间,sds
实现了空间预分配和惰性空间释放两种优化策略。
空间预分配:
空间预分配用于优化sds
的字符串增长操作:当sds
的api
对一个sds
进行修改,并且需要对sds
进行空间扩展的时候,程序不仅会为sds
分配修改所必须要的空间,还会为sds
分配额外的未使用空间。
其中,额外分配未使用空间数量由以下公式决定:
- 如果对
sds
进行修改后,sds
的长度将小于1mb
,那么程序会分配和len
属性同样大小的未使用空间,这时sds
的len
属性和free
的值相同。举个例子,进行修改后len
为10
,那么free
也为10
,sds
的buf
数组的实际长度为10 + 10 + 1 = 21
。 - 如果对
sds
进行修改后,sds
的长度将大于1mb
,那么程序会分配1mb
的使用空间。举个例子,进行修改后len
为10mb
,那么free
为1mb
,sds
的buf
数组的实际长度为10mb + 1mb + 1byte
。
懒惰空间释放
懒惰空间释放用于优化sds
的字符串缩短操作:当sds
的api
需要缩短sds
保存的字符串时,程序并不会立即使用内存重分配来回收缩短后多出来的自己,而是使用free
属性将这些字节的数量记录起来,并等待将来使用。
举个例子,sdstrim
函数接受一个sds
和一个c
字符串作为参数,从sds
左右两端分别移出所以在c
字符串中出现的字符。
如图:
执行:
sdstrim(s, "XY"); // 移除 sds 字符串中所有 ‘X’和‘Y’
会将sds
修改成如下图:
注意执行sdstrim
之后的sds
并没有释放出来多余的8
字节空间,而是将这8
字节空间作为未使用空间保留在了sds
里面,如果将来要sds
进行增长操作,这些未使用空间就会用上。
举个例子,现在对s
执行:
sdscat(s, " Redis");
那么完成这次sdscat
操作将不需要执行内存重新分配,因为sds
预留的8
字节空间足以拼接6
个字节的Redis
,通过惰性空间释放策略,sds
避免了缩短字符串所需的内存重分配操作,并为将来可能有的增长操作提供了优化,如图:
与此同时,sds
也提供了相应的api
,让我们可以在有需要时,真正的释放sds
的未使用空间,所以不必担心惰性空间释放策略会造成内存浪费。
二进制安全
c
字符串中的字符必须符合某种编码(比如 ASCII)
,并且除了字符串的末尾之外,字符串里面不能包含空字符串,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得c
字符串只能保存文本数据,不能保存图片、音频这样的二进制数据。
举个例子,如果有一种使用空字符串分割多个单词的特殊数据格式,那么这种格式就不能使用c
字符串来保存,因为c
字符串所用的函数之后识别出其中的Redis
,而忽略之后的Cluster
。如图:
而sds
的buf
属性被成为字节数组的原因——redis
不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
例如,使用sds
来保存之前提到的数据格式就没有问题,因为sds
使用len
属性的值而不是空字符来判断字符串是否结束,如图:
兼容部分c字符串函数
虽然sds
的api
都是二进制安全,但它们一样遵循c
字符串以空字符串结尾的惯例:这些api
总会将sds
保存的数据的末尾设置为空字符,并且总会在为buf
数组分配空间时多分配一个字节来容纳这个字符串,这是为了让那些保存文本数据的sds
可以重用一部分
库定义的函数。
举个例子,如果有一个保存文本数据的sds
,如上图,那么我们就可以重用
函数,使用它来对比sds
保存的字符串另一个c
字符串:
strcasecmp(sds->buf, "hello world");
遵循c
字符串以空字符结尾的惯例,sds
可以在有需要时重用
函数库,避免不必要的代码重复。
总结
C字符串 | SDS |
---|---|
获取字符串长度的复杂度为O(N) | 获取字符串长度的复杂度为O(1) |
API是不安全的,可能会造成缓冲区溢出 | API是安全的,不会造成缓冲区溢出 |
修改字符串长度N次必然需要执行N次内存重分配 | 修改字符串长度N次最多需要执行N次内存重分配 |
只能保存文本数据 | 可以保存文本数据或者二进制数据 |
可以使用 库中的函数 |
可以使用一部分 库中的函数 |
打赏
如果我的文章对您有帮助:打赏一下哟!传送门