一、为什么要设计SDS而不用 char*
在Redis中,字符串是一个很常用的的数据类型。键值对中的键是字符串,值有时也是字符串,Redis 实例和客户端交互的命令和数据,也都是用字符串表示的。那为什么要独立设计一个动态字符串,而不用C语言中的 char * 呢? 首先来讨论下使用 char * 有哪些缺陷?
在C语言中,char * 是字符数组,一块连续的内存空间,以字符"\0"作为结束标志。例如当使用strlen函数获取字符串长度时,需要从第一个字符变量遍历直到"\0",这显然时间复杂度会比较高。另外它以字符"\0"作为结束标志,如果我们要保存的数据中,本身就有“\0”,那么数据在“\0”处就会被截断,而这就不符合 Redis 希望能保存任意二进制数据的需求了。
基于 char * 实现的字符串以上两点不足之处,Redis 设计了简单动态字符串(Simple Dynamic String,SDS)的结构,用来表示字符串。相比于 C 语言中的字符串实现,SDS 这种字符串的实现方式,会提升字符串的操作效率,并且可以用来保存二进制数据。
二、SDS结构设计
Redis在设计SDS时,为了尽量利用C标准库中的字符串操作函数,依然使用了字符数组来保存实际的数据,且虽然保存了字符串的长度,依然保留了使用"\0"终止符,这样可以使用C标准库的一些函数,例如printf等。SDS实现的代码位于源文件 sds.h 和 sds.c 中,通过如下定义可以看到SDS依然使用了char 。使用 typedef 给 char 类型定义了一个别名,这个别名就是 sds。
typedef char *sds;
同时,SDS 结构里还包含了三个元数据,分别是字符数组现有长度 len、分配给字符数组的空间长度 alloc,以及 SDS 类型 flags。结构如下:
为了节约内存,SDS设计了5种类型。如下:
// sdshdr5不会被使用,我们只是直接访问标志字节。 然而,此处旨在记录5类SDS字符串的布局。
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 8字节长度,最大存储255长的字符串
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 { // 最大长度 65535
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 { // 最大长度
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
这 5 种类型的主要区别就在于,它们数据结构中的字符数组现有长度 len 和分配空间长度 alloc,这两个元数据的数据类型不同。其中flags属性就是表示的是 SDS 类型。其中sdshdr5已经被弃用了,它使用flags属性的前三个字节表示类型,后边5个字节表示字符串实际长度,因此最大支持32字节的字符串,实际使用中及时字符串小于32字节,Redis也会提升使用 sdshdr8 类型。
我们以 sdshdr8 类型为例,使用8位无符号整型表示 len 和 alloc。因此它能表示的字符数组长度(包括数组最后一位\0)不会超过 256 字节。这样针对不同长度的字符串使用不同的类型就可以达到节省内存空间的效果。
attribute ((packed))的作用就是告诉编译器,在编译 sdshdr8 结构时,不要使用字节对齐的方式,而是采用紧凑的方式分配内存。这是因为在默认情况下,编译器会按照 8 字节对齐的方式,给变量分配内存。也就是说,即使一个变量的大小不到 8 个字节,编译器也会给它分配 8 个字节。
三、SDS类型的常用API接口
在Redis中针对SDS的操作API还是非常多的,有在sds.h中通过宏定义实现和一些静态方法,也有在sds.c中实现的更多字符串操作。
3.1、静态方法
SDS这些静态方法基本都是是 sdshdr 结构内的元素的get set操作。列表如下:
| 函数原型 | 说明 |
|---|---|
| static inline unsigned char sdsType(const sds s) | 获取字符串sds的类型 |
| static inline size_t sdslen(const sds s) | 获取字符串sds的长度 |
| static inline size_t sdsavail(const sds s) | 获取字符串sds的剩余可用空间 |
| static inline void sdssetlen(sds s, size_t newlen) | 设置sds的长度 |
| static inline size_t sdsAllocSize(sds s) | 获取sds实际申请的总内存大小, 包括结构本身和字符串终止符’\0' |
| static inline size_t sdsalloc(const sds s) | 获取sds存储字符串申请的内存大小 sdsalloc() = sdsavail() + sdslen() |
| static inline void sdssetalloc(sds s, size_t newlen) | 设置sds申请的内存大小,不包括终止符和hdr结构的大小, 即设置alloc属性 |
| static inline int sdsHdrSize(char type) | 获取hdr结构体的大小 |
如上所示,为一些静态方法的实现,这是我们以 sdsAllocSize 函数为例展开说明。实现如下:
static inline size_t sdsAllocSize(sds s) { // 获取sds实际申请的总内存大小, +1 是需要字符串终止符'\0'
switch(sdsType(s)) {
case SDS_TYPE_5:
return sizeof(struct sdshdr5) + SDS_TYPE_5_LEN(s) + 1;
case SDS_TYPE_8:
return sizeof(struct sdshdr8) + SDS_HDR(8,s)->alloc + 1;
case SDS_TYPE_16:
return sizeof(struct sdshdr16) + SDS_HDR(16,s)->alloc + 1;
case SDS_TYPE_32:
return sizeof(struct sdshdr32) + SDS_HDR(32,s)->alloc + 1;
case SDS_TYPE_64:
return sizeof(struct sdshdr64) + SDS_HDR(64,s)->alloc + 1;
}
return 0;
}
static inline unsigned char sdsType(const sds s) { // 获取字符串sds的类型
unsigned char flags = s[-1];
return flags & SDS_TYPE_MASK; // 获取sdshdr的flag表示,即取出flag成员的低三位
}
如上所示:首先根据flags标志判断sds类型,然后计算sdshdr结构体的大小 加上 alloc内存的大小即字符串可用最大内存再加上1字节的终止符"\0", 即可以获取到该sds从操作系统申请的总内存大小。其他的函数就不再一一解释,例如sdslen函数就只直接返回了sds结构体的len属性。
3.2、SDS的基本操作
在实际实现中,SDS除了基本的增删改查之外,还有许多的辅助函数,由于篇幅限制,我们不对这些辅助函数进行详细说明,对使用到的仅做简单的介绍。例如: sdsReqType 函数实现的功能是传入一个整型参数,根据该参数的大小判断需要使用的SDS类型。
3.2.1、 SDS的创建
接下来我们来看SDS的创建,主要用到了sdsnew函数,我们来看下具体实现:
sds sdsnew(const char *init) { // 根据实际字符串大小初始化sds,自动计算长度
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}
sds sdsnewlen(const void *init, size_t initlen) { // 根据传入字符串和长度初始化,如果失败就终止进程
return _sdsnewlen(init, initlen, 0);
}
sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) { // 传入字符串和初始化大小,创建一个新的sds
void *sh;
char type = sdsReqType(initlen);
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8; // 如果创建空字符串,就使用type 8,因为type 5 添加字符不太擅长,要做位运算
int hdrlen = sdsHdrSize(type);
size_t bufsize;
assert(initlen + hdrlen + 1 > initlen); /* Catch size_t overflow 用于判断是否超出size_t类型支持的最大值 */
sh = trymalloc?
s_trymalloc_usable(hdrlen+initlen+1, &bufsize) : // 申请内存,bufsize为实际可用的内存大小
s_malloc_usable(hdrlen+initlen+1, &bufsize);
if (sh == NULL) return NULL;
adjustTypeIfNeeded(&type, &hdrlen, bufsize); // 重新调整合适的类型
return sdsnewplacement(sh, bufsize, type, init, initlen); // 初始化sds结构,给成员变量赋值
}
如上所示,当调用sdsnew函数时,传入一个初始化字符串即可创建一个sds。它调用了sdsnewlen,sdsnewlen又调用了_sdsnewlen。 在_sdsnewlen函数中才真正实现了sds的内存申请和结构创建。这样的API分层实现拥有非常好的可扩展性。
在_sdsnewlen函数中,请注意,当初始化字符串长度比较小使用 SDS_TYPE_5就已经足够时,Redis会升级为 SDS_TYPE_8, 这也就是前面说 SDS_TYPE_5 已经被废弃的原因。因为type 5 添加字符不太删除需要做位运算。但是代码中依然保留,我想是因为可能要兼容老版本吧,例如低版本的RDB文件加载到新版本,可能会用到已经废弃的类型。 其次我们看_sdsnewlen提供了一个trymalloc参数,这个参数可以传0或1,这其实涉及到了redis的内存申请失败的处理,当使用了try后内存申请失败直接返回NULL,当不使用try内存申请失败则直接报错结束进程。
3.2.2、SDS的字符串拼接
在传统的C标准库中,使用strcat函数进行字符串拼接时,实现代码如下:
char *strcat(char *dest, const char *src) {
char *tmp = dest; //将目标字符串复制给tmp变量
while(*dest) //用一个while循环遍历目标字符串,直到遇到“\0”跳出循环,指向目标字符串的末尾
dest++;
while((*dest++ = *src++) != '\0' ) //将源字符串中的每个字符逐一赋值到目标字符串中,直到遇到结束字符
return tmp;
}
从以上代码可以看出,这个函数的时间复杂度很高,因此性能会比较差,接下来我们看一下SDS中对这个功能的实现:
sds sdscat(sds s, const char *t) {
return sdscatlen(s, t, strlen(t));
}
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s); // 获取目标字符串s的当前长度
s = sdsMakeRoomFor(s,len); // 根据要追加的长度len和目标字符串s的现有长度,判断是否要增加新的空间
if (s == NULL) return NULL;
memcpy(s+curlen, t, len); // 将源字符串t中len长度的数据拷贝到目标字符串结尾
sdssetlen(s, curlen+len); // 设置目标字符串的最新长度:拷贝前长度curlen加上拷贝长度
s[curlen+len] = '\0'; // 拷贝后,在目标字符串结尾加上\0
return s;
}
如上所示,在SDS中,使用sdscat函数进行字符串拼接,本质调用了sdscatlen, sdscatlen会判断当前sds的剩余空间是否足够,不够则进行扩容,够直接进行内存拷贝。和 C 语言中的字符串操作相比,SDS 通过记录字符数组的使用长度和分配空间大小,避免了对字符串的遍历操作,降低了操作开销,进一步就可以帮助诸多字符串操作更加高效地完成。
SDS 把目标字符串的空间检查和扩容封装在了 sdsMakeRoomFor 函数中,并且在涉及字符串空间变化的操作中,如追加、复制等,会直接调用该函数。实现如下:
sds _sdsMakeRoomFor(sds s, size_t addlen, int greedy) { // sds字符串变更扩充空间
......
if (greedy == 1) { // 判断扩充内存是否是贪婪模式,如果不使用贪婪模式,则根据实际需要的大小去扩内存
if (newlen < SDS_MAX_PREALLOC) // 如果扩充后总长度小于 1024*1024,则扩充一倍
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC; // 否则扩充 1024*1024。即每次预申请的内存必须小于 1024*1024
}
type = sdsReqType(newlen); // 计算新sds的类型
if (type == SDS_TYPE_5) type = SDS_TYPE_8; // 不使用type 5类型
hdrlen = sdsHdrSize(type);
use_realloc = (oldtype == type); // 如果扩充长度不需要改变sds的类型,即旧类型仍然可以容纳就使用realloc重新分配
......
usable = bufsize - hdrlen - 1; // 更新sds成员变量
sdssetalloc(s, usable);
return s;
}
如上所示:sdsMakeRoomFor本质是调用了_sdsMakeRoomFor,_sdsMakeRoomFor有两种内存预分配模式,贪婪模式和非贪婪模式。sdsMakeRoomFor函数是使用的贪婪模式,即greedy参数为1。贪婪模式中如何扩充后需要的内存小于 1MB则直接扩充一倍,否则就扩充1M,即预申请的内存需要小于1M。 这里进行内存预分配是多申请一些空间,在后续sds再次发生长度增大时不再需要进行内存重分配,提升了SDS的性能。 在非贪婪模式中,不会进行内存预分配,即需要多少就申请多大。另外从该函数的实现中,也可以看到Redis已经弃用了 sdshdr5 这个字符串类型。
3.3、更多API汇总
由于篇幅的限制,无法做到对每个函数的实现进行分析,在本文中我们只介绍了SDS的核心实现。通过介绍核心代码来体验为什么SDS能节省内存和提升效率。这里我们罗列部分SDS常用的API,更多的实现代码可以到源码文件中去查看。
| 函数 | 作用 | 算法复杂度 |
|---|---|---|
| sdsnewlen | 创建一个指定长度的 sds,接受一个 C 字符串作为初始化值 | O(N) |
| sdsempty | 创建一个只包含空白字符串 "" 的 sds | O(1) |
| sdsnew | 根据给定 C 字符串,创建一个相应的 sds | O(N) |
| sdsdup | 复制给定 sds | O(N) |
| sdsfree | 释放给定 sds | O(N) |
| sdsupdatelen | 更新给定 sds 所对应 sdshdr 结构的 free 和 len | O(N) |
| sdsclear | 清除给定 sds 的内容,将它初始化为 "" | O(1) |
| sdsMakeRoomFor | 对 sds 所对应 sdshdr 结构的 buf 进行扩展 | O(N) |
| sdsRemoveFreeSpace | 在不改动 buf 的情况下,将 buf 内多余的空间释放出去 | O(N) |
| sdsAllocSize | 计算给定 sds 的 buf 所占用的内存总数 | O(1) |
| sdsIncrLen | 对 sds 的 buf 的右端进行扩展(expand)或修剪(trim) | O(1) |
| sdsgrowzero | 将给定 sds 的 buf 扩展至指定长度,无内容的部分用 \0 来填充 | O(N) |
| sdscatlen | 按给定长度对 sds 进行扩展,并将一个 C 字符串追加到 sds 的末尾 | O(N) |
| sdscat | 将一个 C 字符串追加到 sds 末尾 | O(N) |
| sdscatsds | 将一个 sds 追加到另一个 sds 末尾 | O(N) |
| sdscpylen | 将一个 C 字符串的部分内容复制到另一个 sds 中,需要时对 sds 进行扩展 | O(N) |
| sdscpy | 将一个 C 字符串复制到 sds | O(N) |
sds 还有另一部分功能性函数, 比如 sdstolower 、 sdstrim 、 sdscmp , 等等, 基本都是标准 C 字符串库函数的 sds 版本, 这里不一一列举了。
四、总结
Redis 的字符串表示为 sds ,而不是 C 字符串(以 \0 结尾的 char*)。对比 C 字符串, sds 有以下有点:
- 内存预分配,可以更高效地执行长度计算、内容追加等操作
- 惰性空间释放,SDS 字符串缩短时, 空余出来的空间并不会直接释放,而是会被保留,等待下次再次使用。
- 二进制安全,SDS 的 API 会以处理二进制的方式来处理存放在 bu f数组里的数据,不会对里面的数据做任何的限制。即\0 不会异常结束String
因此在SDS中存放视频、图片也是可以的。
「真诚赞赏,手留余香」
真诚赞赏,手留余香
使用微信扫描二维码完成支付