本文正在参加「技术专题19期 漫谈数据库技术」活动
字符串在我们平时的应⽤开发中⼗分常⻅,⽽对于
Redis来说,键值对中的键是字符串,值有时也是字符串。我们在Redis中写⼊⼀条⽤⼾信息,记录了 ⽤⼾姓名、性别、所在城市等,这些都是字符串。此外,Redis实例和客⼾端交互的命令和数据,也都是⽤字符串表⽰的。 接下来我们通过分析Redis源码来探索Redis中字符串实现的奥秘。
字符串的使⽤场景十分⼴泛和关键,就使得我们在实现字符串时,需要尽量满⾜以下三个要求:
- 能支持丰富且高效的字符串操作,比如获取字符串长度、字符串追加、比较等操作。
- 能保存各种形式的数据,例如二进制等。
- 尽可能节省内存开销等。
redis 使用 C 语言开发的,在 C 语言中可以使用 char* 字符数组来实现字符串,且 C 语⾔标准库 string.h 中也定义了多种字符串的操作函数,那么 Redis 是不是复⽤ C 语⾔中对字符串的实现呢。实际上,我们在使⽤ C 语⾔字符串时,经常需要⼿动检查和分配字符串空间,⽽且,图⽚等数据还⽆法⽤字符串保存,也就限制了应⽤范围。
针对以上问题,Redis 设计了简单动态字符串(Simple Dynamic String,SDS 的结构,⽤来表⽰字符串。我们下面来了解为什么 Redis 没有复⽤ C 语⾔的字符串实现⽅法。
C 语言字符串的缺点
操作函数复杂度
我们以 strlen 为例,因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N)。
缓冲区溢出
我们再来看另⼀个常⽤的操作函数:字符串追加函数 strcat。strcat 函数是将⼀个源字符串 src 追加到⼀个⽬标字符串的末尾。该函数的代码如下所⽰:
char *strcat(char *dest, const char *src) {
//将⽬标字符串复制给tmp变量
char *tmp = dest;
//⽤⼀个while循环遍历⽬标字符串,直到遇到“\0”跳出循环,指向⽬标字符串的末尾
while(*dest) dest++;
//将源字符串中的每个字符逐⼀赋值到⽬标字符串中,直到遇到结束字符
while((*dest++ = *src++) != '\0' )
return tmp;
}
假设程序里有两个在内存中紧邻着的 C 字符串 s1 和 s2 ,其中 s1 保存了字符串 "redis",而 s2 则保存了字符串 "test",如下图所示:
当执行以下代码时
strcat(s1, " ohno");
将 s1 的内容修改为 "redis ohno",但没有在执行 strcat 之前为 s1 分配足够的空间,那么在 strcat 函数执行之后,s1 的数据将溢出到 s2 所在的空间中,导致 s2 保存的内容被意外地修改,如下图所示
二进制安全
C 字符串中的字符必须符合某种编码(比如 ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符,否则最先被程序读入的空字符将被误认为是字符串结尾,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
SDS的设计思想
因为 Redis 使⽤ C 语⾔开发的,为了保证能尽量复⽤ C 标准库中的字符串操作函数,Redis 保留了使⽤字符数组来保存实际的数据。但是,和 C 语⾔仅⽤字符数组不同,Redis 还专⻔设计了 SDS(即简单动态字符串)的数据结构。
SDS 结构设计
SDS 本质还是字符数组,只是在字符数组基础上增加了额外的元数据。⾸先,SDS 结构⾥包含了⼀个字符数组buf[],⽤来保存实际数据。同时,SDS 结构⾥还包含了三个元数据,分别是字符数组现有⻓度 len、分配给字符数组的空间⻓度 alloc,以及 SDS 类型 flags。
同时 Redis 为字符数组定义了别名,在需要⽤到字符数组时可以直接使⽤ sds 这个别名。
typedef char *sds;
SDS 创建
在创建新的字符串时,Redis 会调⽤ SDS 创建函数 sdsnewlen。sdsnewlen 函数会新建 sds 类型变量 (也就是 char* 类型变量),并新建 SDS 结构体,把 SDS 结构体中的数组 buf[] 赋给 sds 类型变量。最后,sdsnewlen 函数会把要创建的字符串拷⻉给 sds 变量。具体的代码逻辑如下所示:
sds sdsnewlen(const void *init, size_t initlen) {
// 指向 SDS 结构体的指针
void *sh;
// sds 类型变量,即 char* 字符数组
sds s;
...
// 新建 SDS 结构,并分配内存空间
sh = s_malloc(hdrlen+initlen+1);
...
// sds类型变量指向SDS结构体中的buf数组指针地址
s = (char*)sh+hdrlen;
...
if (initlen && init)
// 将要传⼊的字符串拷⻉给 sds 类型的变量 s
memcpy(s, init, initlen);
// 变量 s 末尾增加 \0,表⽰字符串结束
s[initlen] = '\0';
return s;
}
SDS 追加
在了解了 SDS 结构的定义后,我们再来看看 SDS 追加操作的改进之处。Redis 中实现字符串追加的函数是 sds.c ⽂件中的 sdscatlen 函数。这个函数的参数⼀共有三个,分别是⽬标字符串 s、源字符串 t 和要追加的⻓度 len,源码如下所⽰:
sds sdscatlen(sds s, const void *t, size_t len) {
// 获取目标字符串 s 的长度
size_t curlen = sdslen(s);
// 根据目标字符串现有的长度和追加的长度判断是否需要扩容
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
// 将源字符串 t 中 len 的数据拷⻉到⽬标字符串结尾
memcpy(s+curlen, t, len);
// 设置⽬标字符串的最新⻓度:拷⻉前⻓度curlen加上拷⻉⻓度
sdssetlen(s, curlen+len);
// 在⽬标字符串结尾加上\0
s[curlen+len] = '\0';
return s;
}
通过分析这个函数的源码,我们可以看到 sdscatlen 的实现较为简单,其执⾏过程分为三步:
- ⾸先,获取⽬标字符串的当前⻓度,并调⽤
sdsMakeRoomFor函数,根据当前⻓度和要追加的⻓度,判断是否要给⽬标字符串新增空间。这⼀步主要是保证⽬标字符串有⾜够的空间接收追加的字符串。 - 其次,在保证了⽬标字符串的空间⾜够后,将源字符串中指定⻓度len的数据追加到⽬标字符串。
- 最后,设置⽬标字符串的最新⻓度。
我们来重点看一下 sdsMakeRoomFor 函数,这个函数是扩容的主要方法。下面我们来看一下源码:
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
// 获取当前可用空间长度。如果当前可用空间长度满足要求,则直接返回
size_t avail = sdsavail(s);
size_t len, newlen, reqlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
reqlen = newlen = (len+addlen);
assert(newlen > len); /* Catch size_t overflow */
// 如果追加后的长度小于 1M,则申请 2 倍的追加后的内存长度
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
// 如果追加后的长度大于 1M,则申请追加后的内存长度 + 1M的内存空间
newlen += SDS_MAX_PREALLOC;
// 根据新长度获取 SDS 类型
type = sdsReqType(newlen);
/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
assert(hdrlen + newlen + 1 > reqlen); /* Catch size_t overflow */
// 如果扩容后sds还是同一类型,则使用s_realloc函数申请内存。否则,由于sds结构已经变动,必须移动整个sds,直接分配新的内存空间,并将原来的字符串内容复制到新的内存空间。
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}
sdssetalloc(s, newlen);
return s;
}
通过分析这个函数的源码,我们可以看到 sdsMakeRoomFor 的过程主要是:
- 获取当前可用空间长度。如果当前可用空间长度满足新增字符长度的要求,则直接返回。
- 如果对
SDS进行修改之后,SDS的长度小于1MB,那么程序分配 2 倍修改后SDS长度的内存。如果大于1MB,则申请追加后的内存长度 +1MB的内存空间。 - 根据新长度获取
SDS类型。 - 如果扩容后
SDS还是同一类型,则使用s_realloc函数申请内存。否则,由于SDS结构已经变动,必须移动整个SDS,直接分配新的内存空间,并将原来的字符串内容复制到新的内存空间。
紧凑型字符串结构的编程技巧
在分析 sds.h 时可以看到定义了五种 sdshdr,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64。这 5 种类型的主要区别就在于,它们数据结构中的字符数组现有⻓度 len 和分配空间⻓度 alloc,这两个元数据的数据类型不同。 因为 sdshdr5 这⼀类型 Redis 已经不再使⽤了,所以我们这⾥主要来了解下剩余的 4 种类型。以 sdshdr8为例,它的定义如下所⽰:
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 字符数组使用的长度 */
uint8_t alloc; /* 字符数组申请的长度 */
unsigned char flags; /* sds 类型 */
char buf[]; /* 字符数组 */
};
我们可以看到,现有⻓度 len 和已分配空间 alloc 的数据类型都是 uint8_t。uint8_t 表示 8 位⽆符号整型,会占⽤ 1 字节的内存空间。当字符串类型是 sdshdr8 时,它能表⽰的字符数组⻓度(包括数组最后⼀位 \0)不会超过 256 字节。
⽽对于 sdshdr16、sdshdr32、sdshdr64 三种类型来说,它们的 len 和 alloc 数据类型分别是uint16_t、 uint32_t、uint64_t,即它们能表⽰的字符数组⻓度。这两个元数据占⽤的内存空间在 sdshdr16、sdshdr32、sdshdr64类型中,则分别是 2 字节、4 字节和 8 字节。
SDS 之所以设计不同的结构头(即不同类型),是为了能灵活保存不同⼤⼩的字符串,从⽽有效节省内存空间。因为在保存不同⼤⼩的字符串时,结构头占⽤的内存空间也不⼀样,这样⼀来,在保存⼩字符串时,结构头占⽤空间也⽐较少。
好了,除了设计不同类型的结构头,Redis 在编程上还使⽤了专⻔的编译优化来节省内存空间。在刚才介绍的 sdshdr8 结构定义中,我们可以看到,在 struct 和 sdshdr8 之间使⽤了 __attribute__ ((__packed__))。
其实这⾥,__attribute__ ((__packed__)) 的作⽤就是告诉编译器,在编译 sdshdr8 结构时,不要使⽤字节对⻬的⽅式,⽽是采⽤紧凑的⽅式分配内存。这是因为在默认情况下,编译器会按照 8 字节对⻬的⽅式,给变量分配内存。也就是说,即使⼀个变量的⼤⼩不到 8 个字节,编译器也会给它分配 8 个字节。
小结
通过上文分析我们了解到和 C 语⾔中的字符串操作相⽐,SDS 通过记录字符数组的使⽤⻓度和分配空间⼤⼩,避免了对字符串的遍历操作,降低了操作开销,进⼀步就可以帮助诸多字符串操作更加⾼效地完成,⽐如创建、追加、复制、⽐较等等操作。 此外,SDS把⽬标字符串的空间检查和扩容封装在了 sdsMakeRoomFor 函数中,并且在涉及字符串空间变化的操作中,如追加、复制等,会直接调⽤该函数。 这⼀设计实现,就避免了开发⼈员因忘记给⽬标字符串扩容,⽽导致操作失败的情况。最后通过 SDS 类型的设计以及编译器优化等体现出 Redis 对内存使用的精打细算。