简单动态字符串
Redis中的字符串并不是传统的C语言字符串(即字符数组,以下简称C字符串),而是自己构建了一种简单动态字符串(simple dynamic string,SDS),并将SDS作为Redis的默认字符串表示。在Redis中,C字符串一般只用在无需对字符串值进行修改的地方,比如Redis的启动时的日志。Redis需要的字符串是一个可修改字符长度的字符串,就会用到SDS来表示一个字符串。比如下面这个例子:
127.0.0.1:6379> set msg "hello world" OK
这是一条很简单的命令,将"hello world"这个字符串与msg这个键建立映射关系。而"hello world"在Redis中的表示,就是一个SDS。说了那么久的SDS,那这个SDS到底长什么样呢?我们来看看
sds.h
struct sdshdr { //记录buf数组中已使用字节的数量 //等于SDS所保存字符串的长度 int len; //记录buf数组中尚未使用的字节数量 int free; //字节数组,用于保存字符串 char buf[]; };
图1-1展示了一个SDS的示例:
- free属性的值为0,表示这个SDS没有任何剩余的可使用字节数
- len为5,表示这个SDS保存了一个长度为5的字符串
- buf属性是一个char类型的数组,数组的前五个字节分别保存了'R'、'e'、'd'、'i'、's'五个字符,而最后一个字节则保存空字符' ',代表字符串结束
图1-1
看到这里,可能还有人不明白使用SDS的好处。没关系,我们接下来再看看另一个示例。我们看图1-2,这个SDS和图1-1的SDS不一样,虽然都保存字符串“Redis”。但图1-2中SDS的buf字符数组长度以及free所保存的值都与图1-1的SDS不一样
图1-2
我们都知道,Redis作为一款非关系型的内存数据库,他的值很容易变动。同时我们也知道,C语言中字符数组的长度是无法变动的。如果Redis中使用的字符串是C字符串,而不是SDS,当我们变动一个键所对应的字符串,如果新字符串的长度小于等于原先字符串的长度,那么我们只要替换字符数组上的内容,再把代表字符串结尾的提前(如果新旧字符串长度相等,则空字符串还留在原先的位置)。但如果新字符串的长度大于原先旧字符串的长度,那么很不幸,我们只能重新申请一个能容纳新字符串长度的数组,用于保存新字符串,这对Redis无疑是不利的
于是,Redis在为一个字符串创建一个SDS对象时,通常会申请比字符串长度更长的字节数组(buf),Redis将字符串保存进这个数组,同时在len这个变量保存字符串的长度,再用free这个变量保存buf尚未使用的字节数量。当客户端要求变动一个键所对应的字符串时,如果buf的长度大于新字符串的长度,那么就无需再声明一个新的数组来容纳新字符串了
我们再来看sdshdr这个结构体,这里面有free、len和buf这三个域。那么这个len会不会有些多余?因为free已经记录尚未使用的字节数量了,同时len我们也可以通过:strlen(buf)的方式来计算字符串长度,那么这个len真的是多余的吗?其实不是,如果有使用过Java、Python等这些高级语言的人都有经验,在这些高级语言中,我们可以轻而易举的调用一个函数来获取字符串的长度,而这些高级语言的字符串内部实现,同样也记录了字符串的长度,假设我们要计算一个字符串长度,每次都要调用strlen(buf),这个操作的时间复杂度为O(N),图1-3展示了C程序中计算字符串长度的过程
图1-3
从图1-3我们可以知道,当通过strlen(buf)计算一个C字符串的长度,游标会遍历到空字符处才停止,而大家在编程中,多多少少在一些业务场景会重复用到某个字符串长度。于是,SDS中,len域的作用就在于此,我们只需要计算一次字符串的长度,当需要用到时直接从len中取,这时的时间复杂度为O(1)
除了获取字符串长度的时间复杂度高之外,C字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出。C语言中的strcat函数可以拼接两个字符串,具体定义如下:
#include <string.h> char *strcat(char *dest, const char *src);
strcat函数可以将src字符串中的内容拼接到dest字符串之后,但因为C本身不记录字符串长度,默认认为dest已经分配了足够的内存空间。举个例子,假设程序中有紧邻的两个字符串S1和S2,其中S1保存了字符串“Redis”,而S2保存了字符串“MongoDB”,如图1-4所示
图1-4 在内存中紧邻的两个C字符串
如果程序员没有注意S1的长度,直接执行strcat(S1, "Cluster"),那么势必会覆盖到S2的内存,换言之S2所对应的字符数组的内容会被修改,如图1-5所示
如图1-5 S1的内容溢出到S2所在的位置上
与C字符串不同,SDS的空间分配策略完全杜绝了发生缓冲区溢出的可能性,当SDS API需要对SDS进行修改时,API会检查SDS的空间是否满足修改的要求,如果不满足的话,API会自动将SDS的空间扩展至执行所需的大小,然后才执行修改操作
SDS的API里面有一个用于执行拼接操作的sdscat函数,它可以将一个C字符串拼接到给定的SDS所保存的字符串后面,但是在执行拼接操作之前,sdscat会检查给定的SDS的空间是否足够,如果不够的话,sdscat就会扩展SDS的空间,然后才执行拼接操作
例如,我们执行sdscat(s, " Cluster"),其中SDS值s如图1-6所示,那么sdscat检查后发现,目前s的空间并不足以拼接" Cluster",之后,sdscat就会扩展s空间,然后执行拼接" Cluster"操作,拼接完之后的SDS如图1-7所示
图1-6 sdscat执行之前的SDS
图1-7 sdscat执行之后的SDS
Redis作为内存数据库,经常被用于速度要求严苛,数据被频繁修改的场合,如果每次修改字符串长度都需要执行一次内存重分配的话,那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分,如果这种修改频繁发生的话,可能还会对性能造成影响。为了避免C字符串的缺陷,SDS通过未使用空间解除字符串长度和底层数组长度之间的关联。在SDS中,buf数组的长度不一定是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由SDS的free属性记录。通过未使用空间,SDS实现了空间预分配和惰性空间释放两种优化策略
空间预分配
空间预分配用于优化SDS的字符串增长操作,我们都知道当SDS的API对一个SDS进行修改时,除了分配给本身所需的字节空间,还会再额外分配一些备用空间。那么,这个备用空间是多大呢?备用空间由以下公式决定:
- 如果对SDS进行修改后,SDS的长度(即len属性的值)小于1MB,那么程序分配和len属性同样大小的未使用空间,这时SDS的free属性的值将于len属性的值相同。比如经过修改之后,SDS的len将变为13个字节,那么程序也会分配13个字节的备用空间,外加一个字节用于存储空字符串标识字符串结束,所以SDS的buf数组实际长度为13+13+1=27字节
- 如果对SDS进行修改之后,SDS的长度大于等于1MB,那么程序会多分配1MB的未使用时间。比如经修改后,SDS的len为30MB,那么程序会多分配1MB的未使用空间,SDS的buf数组的实际长度为30MB+10MB+1byte
惰性空间释放
惰性空间释放用于优化SDS的字符串缩短操作,当SDS的API需要缩短SDS保存的字符串时,程序其实并不立即使用内存重分配回收缩短后多出来的字节,而是将修改后尚未被使用的字节数存放在free中,用于以后使用
二进制安全
C字符串的必须必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符串将被误认到达字符串末尾,这限制了C字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据
虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见。因此,为了确保Redis可以适用于各种不同的场景,SDS的API都是二进制安全的,所有SDS API都会以处理二进制的方式来处理存放在buf数组里的数据,程序不会对其中的数据做任何限制、过滤、或假设,数据在写入时是什么样的,它被读取时就是什么样的
这也是我们将SDS的buf属性称为字节数组的原因,因为Redis不是用这个数组来保存字符,而是用它来保存一系列的二进制数据。且在SDS中,并非以一个空字符来判断是否到达字符数组的末尾,而是通过len属性的值,如图1-8
图1-8 保存了特殊数据格式的SDS
兼容部分C字符串函数
虽然SDS的API都是二进制安全的,但它们一样遵循C字符串以空字符结尾的惯例:这些API总会将SDS保存的数据的末尾设置为空字符,并且总会在为buf数组分配空间时多分配一个字节的空间来容纳空字符,这是为了让保存了文本数据的SDS可以重用一部分<string.h>库定义的函数。比如:strcasecmp是用于忽略大小写比较两个字符串的函数,使用它来对比SDS保存的字符串和另一个C字符串
strcasecmp(sds->buf, "hello world");
又或者,我们可以将sds中的buf所保存的内容,追加到另一个C字符串上。这样,就无需再去编写另外一套与<string.h>中功能类似的函数了
strcat(c_string, sds->buf);
现在,我们对C字符串和SDS的区别进行总结
C字符串 | SDS |
获取字符串长度的时间复杂度为O(N) | 获取字符串长度的时间复杂度为O(1) |
API是不安全的,可能会造成缓冲区溢出 | API是安全的,不会造成缓冲区溢出 |
修改字符串长度N次必然需要执行N次内存重分配 | 修改字符串长度N次最多需要执行N次内存重分配 |
只能保存文本数据 | 可以保存文本或二进制数据 |
可以使用所有<string.h>库中的函数 | 可以使用一部分<string.h>库中的函数 |
SDS API
函数 | 作用 | 时间复杂度 |
sdsnew | 创建一个包含给定C字符串的SDS | O(N) ,N 为给定C字符串的长度 |
sdsempty | 创建一个不包含任何内容的空SDS | O(1) |
sdsfree | 释放给定的SDS | O(1) |
sdslen | 返回SDS的已使用空间字节数 | 这个值可以通过读取SDS的len属性来直接获得,复杂度为O(1) |
sdsavail | 返回SDS的未使用空间字节数 | 这个值可以通过读取SDS的free属性来直接获得,复杂度为 O(1) |
sdsdup | 创建一个给定SDS的副本(copy) | O(N),N为给定SDS的长度 |
sdsclear | 清空SDS保存的字符串内容 | 因为惰性空间释放策略,复杂度为O(1) |
sdscat | 将给定C字符串拼接到SDS字符串的末尾 | O(N),N为被拼接C字符串的长度 |
sdscatsds | 将给定SDS字符串拼接到另一个SDS字符串的末尾 | O(N),N为被拼接SDS字符串的长度 |
sdscpy | 将给定的C字符串复制到SDS里面,覆盖SDS原有的字符串 | O(N),N为被复制C字符串的长度 |
sdsgrowzero | 用空字符将SDS扩展至给定长度 | O(N),N为扩展新增的字节数 |
sdsrange | 保留SDS给定区间内的数据,不在区间内的数据会被覆盖或清除 | O(N),N为被保留数据的字节数 |
sdstrim | 接受一个SDS和一个C字符串作为参数,从SDS左右两端分别移除所有在C字符串中出现过的字符 | O(M*N),M为SDS的长度,N为给定C字符串的长度 |
sdscmp | 对比两个SDS字符串是否相同 | O(N),N为两个SDS中较短的那个SDS的长度 |