众所周知,redis对外支持五种数据结构:string、hash、list、set、sorted set。而在内部实现中,则主要依赖于如下七种数据结构:
- SDS(simple dynamic string):简单动态字符串
- ADList(a generic doubly linked list):双向链表
- dict(Hash Tables):字典
- intset:整数集合
- ziplist:压缩表
- quickList:快速列表
- skipList:跳跃链表
在上述七种数据结构中,本文主要就SDS进行简单的讨论。在redis的实现代码中,SDS有着非常广泛的使用。实际上,C语言字符串(也就是字符指针char*)仅被用于一些无需对字符串的值进行修改的地方,比如日志。其他位置的实现,比如对外暴露的string数据结构、AOF的缓冲区、客户端状态的输入缓冲区,都在广泛使用SDS。如今,SDS目前已被独立出来成了一个单独的项目,链接在此:https://github.com/antirez/sds。
数据结构
首先,先来了解下SDS这种数据结构的的基本实现。
在redis的3.2版本,SDS的实现有了比较大的改动,不过对于理解其数据结构影响不大。
由于3.2之前的版本实现比较简单,所以这里以此为例进行分析,在最后会针对3.2前后的改动进行详细说明。
typedef char *sds
struct sdschar {
// buf[] 中已使用的字节数
int len;
// buf[] 中未使用的字节数
int free;
// 字符数组,用于实际存储字符串内容
char buf[];
}
如图中注释所示,在SDS中,并不是需要多少字节,就去申请多大的空间,而是会多申请一部分空间留以备用。举个简单的例子,假设现在要存储一个字符串“redis”,那么是用传统的C语言字符串实现的话,其存储示意图如下:
而对于sds而言,其存储就可能是这样:
与char*的比较
很显然,SDS相比于使用char*,在某些方面是具有一定的优势的,而其中优势则主要集中于如下几个方面:
能够以O(1)时间复杂度来获取字符串的长度
由于C语言字符串,在实现上仅仅是一个char指针,指向字符串对应内存的头部,并不会记录自身的长度信息。所以每次需要获取其长度时,比如从字符串开始位置,一直往后遍历,直到遇到代表字符串结尾的终结符(也就是”\0”)。自然,这个操作的时间复杂度时O(n)。
而对于SDS,自身记录了两个int数据:已经使用的字节数(len)和未使用的字节数(free)。所以,在需要时,只需要将代表已经使用字节数的len数据返回即可,时间复杂度自然是O(1)。
不过,要注意,这里的len不会计算”\0”所占用的空间(与C语言保持一致)。
避免缓冲区溢出
这里,先来了解一下什么是缓冲区溢出。而在此之前,先来看一个例子。
假设有两个字符串s1和s2,其在内存中的存储位置相邻,如下图所示:
这时候,如果想在s1后面追加一段数据,比如追加“ Cluster”,正确的操作应该是:
第一步:申请足够的空间
第二步:执行strcat(s1, “ Cluster”);
但是,假设忘记执行第一步,直接进行第二步操作,这时新的字符“ Cluster”将会直接写在s1字符串的末尾位置,也就是将抹掉s2的部分数据。此时内存中的数据将变成下图的样子:
这种现象就被称为缓冲区溢出。而假如此时取出s2的数据,将会得到错误的字符串“Cluster”,而不是原先的“MongoDB”。
而对于SDS而言,作者专门封装了一个类似于strcat的方法:sdscatlen,作用正是对SDS进行字符串拼接,具体实现代码如下:
sds sdscatlen(sds s, const void *t, size_t len) {
struct sdshdr *sh;
size_t curlen = sdslen(s); // 获取当前已用的长度(就是直接返回sds数据结构中的len字段)
s = sdsMakeRoomFor(s,len); // 判断是否要扩容,如果需要,执行扩容操作
if (s == NULL) return NULL;
sh = (void*) (s-sizeof *sh);;
memcpy(s+curlen, t, len);
sh->len = curlen+len; // 修改len字段
sh->free = sh->free-len; // 修改free字段
s[curlen+len] = '\0'; // 填充终结字符"\0"
return s;
}
如图中注释所述,SDS在长度发生变化时,会自动检测是否需要进行扩容,这就规避了上文示例中缓冲区溢出的风险。这便是SDS的一大特点:可自主扩容,不需要使用者关心空间的申请。
在上面的代码中,有一个比较重要的函数:sdsMakeRoomFor,其实现代码如下:
sds sdsMakeRoomFor(sds s, size_t addlen) {
struct sdshdr *sh, *newsh;
size_t free = sdsavail(s); // 获取未使用的字节数(就是直接返回sds数据结构中的free字段)
size_t len, newlen;
if (free >= addlen) return s; // 如果free还够用,直接返回
len = sdslen(s);
sh = (void*) (s-sizeof *sh);;
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC) // SDS_MAX_PREALLOC的值为 1024*1024,也就是1M
newlen *= 2; // 如果追加新字符串后,长度小于1M,则直接申请两倍的空间备用
else
newlen += SDS_MAX_PREALLOC; // 如果追加新字符串之后,长度大于1M,则多申请1M备用
newsh = realloc(sh, sizeof *newsh+newlen+1);
if (newsh == NULL) return NULL;
newsh->free = newlen - len; // 因为这时候还没有做实际的字符串追加,所以 free = newlen - len
return newsh->buf;
}
可以看到,这里规定了SDS的扩容规则:如果新字符串长度小于1M,申请双倍空间,如果新字符串长度大于1M,则只会多申请1M。
减少字符串变更带来的内存重分配次数
首先解释一下C语言中字符串变更引发的内存重分配行为:
如上文所述,在C语言中,字符串是不会记录自身长度的。获取一个字符串内容时,是从当前位置开始一直往后读取,直到遇到终结字符“\0”。由此,在执行字符串的变更操作时,如果长度有增加,要考虑提前申请空间,否则有可能造成缓冲区溢出。
而在申请空间时,总避不开malloc、realloc、calloc等几个函数。下面看一段关于malloc的非官方解释:
对于malloc、calloc函数,是直接申请了一整块儿足够大的内存。所以想要完成字符串的扩展,在申请完空间之后,还需要进行字符串的拷贝工作。而realloc会检查当前字符串所在内存段,判断其后有没有紧邻的未使用空间。如果有的话,则会直接将这部分未使用空间划过来,这时候就不需要进行后续的字符串拷贝了。但是如果没有足够的未使用空间,realloc也会与malloc一样,新申请一整块儿空间,然后必须执行字符串拷贝才能完成字符串的扩展。如果发生了字符串的拷贝,则字符串之前所占用的空间就会自动释放掉(类似于调用free函数)。
上面所描述的对于内存的申请和释放行为就是内存的重分配。可以看到,内存的重分配还是比较复杂的,换句话说,是比较耗费资源的。而SDS的一个优势就在于此,SDS每次扩展长度的时候,并不是需要多少,申请多少,而总是会预留出一定的空间备用。这样的话,就可以避免每次字符串长度扩大都去申请空间,也就减少了需要进行内存重分配的次数。一句话总结一下,就是,SDS的内存预分配规则,保证了一个字符串连续增长N次所需要的内存重分配次数,从必定N次,降低到了最多N次。
对二进制数据更加友好
还是如上文所述的原因,C语言中,判断一个字符串结尾,是会从指针所指向位置开始,一直往后遍历,直到遇到“\0”。对于常用的文本数据,当然没有问题。但是对于图片、视频这些数据,如果要以字符串形式进行存储,就会出现问题,因为这类数据自身携带的“\0”会影响到正常的字符串读取。不过,使用SDS的话,可以避开此类问题。因为SDS并不以“\0”作为字符串结尾的判断,而是每次总会读取len所记录长度的字符串(这也就能保证,存进去的数据是什么样子的,读出来的就是什么样子的,不会少,不会多)。
不过,SDS依然会给整个字符串的末尾给带上”\0”,这是为了方便复用C语言本身的字符串库函数,不需要每个相关的库函数,都得自己重新封装。
显而易见,为了具有上述的几个特点,SDS要比C语言字符串占用更多的空间。也就是说,SDS其实就是在拿空间换时间。所以,具体SDS和char*,在表示字符串时,谁更有优势,还得考虑实际的业务场景。而在redis这个场景下,既然作者选用了SDS,那么姑且可以认为SDS在这里带来的速度优势要比多占用的那部分空间更有价值。
其他
redis3.2中SDS的改版
在redis3.2,对SDS做过比较大的改版,主要就在于对于不同长度的字符串,使用不同的数据结构。其数据结构的实现代码如下:
/* Note: sdshdr5 is never used, we just access the flags byte directly.
* However is here to document the layout of type 5 SDS strings. */
struct __attribute__ ((__packed__)) sdshdr5 { // 对应字符串长度小于 1<<5(32)
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 { // 对应字符串长度小于 1<<8(256)
uint8_t len; /* used(目前已经使用的长度)*/
uint8_t alloc; /* excluding the header and null terminator(分配的总长度)*/
unsigned char flags; /* 3 lsb of type, 5 unused bits(3bit用来表示类型,剩余的5bit目前没用) */
char buf[]; // 字符数组,用于实际存储字符串内容
};
struct __attribute__ ((__packed__)) sdshdr16 { // 对应字符串长度小于 1<<16(64k)
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 { // 对应字符串长度小于 1<<32(4G)
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 { // 对应字符串长度小于 1<<64
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[];
};
可以看到,新版本要求针对不同长度的字符串,选用不同的struct。而各个struct的区别就在于,len和alloc的类型不一样。其中,uint8_t占用1个字节,uint16_t占用2个字节,uint32_t占用4个字节,uint64_t占用8个字节。而旧版本中统一使用的int占用4个字节(32位机器和64位机器都是4个字节)。
此外,attribute ((packed))要求编译器取消字节对齐,所以最终结构体的大小,就是结构体每个成员的大小之和。但是因为字节对齐能够大大加快CPU的内存访问速度,所以新版本中,redis直接封装了一下malloc方法,在申请空间的时候,手动做字节对齐,相关代码如下(注意:这部分代码在redis的源码中zmalloc.c文件中,sds中的源码中并没有相关的内容):
#define update_zmalloc_stat_alloc(__n) do { \
size_t _n = (__n); \
if (_n&(sizeof(long)-1)) _n += sizeof(long)-(_n&(sizeof(long)-1)); \
atomicIncr(used_memory,__n); \
} while(0)
#define update_zmalloc_stat_free(__n) do { \
size_t _n = (__n); \
if (_n&(sizeof(long)-1)) _n += sizeof(long)-(_n&(sizeof(long)-1)); \
atomicDecr(used_memory,__n); \
} while(0)
显而易见,上述两个改版都是为了优化SDS的内存占用,尽可能避免无谓的内存消耗。
SDS的惰性空间释放
从上文的描述中,可以发现,在字符串长度扩大时,如果空间不够用时,会自动申请更多空间。不过,如果字符串长度缩小时,sds并不会自动进行空间释放。它仍然会保留已申请的所有空间,以做备用。这样显然有可能造成空间的浪费,所以,作者提供了一个专门的函数sdsRemoveFreeSpace,支持手动对SDS的空闲空间进行清理,不过这个函数只会保留已用的空间,会将所有未用的空间都给释放掉。
内存对齐
对于CPU而言,每次从内存中存取数据都需要一定的开销。为了减少存取次数,CPU每次总会以2/4/8/16/32字节为单位进行存取操作。这个存取单位就叫做内存存取粒度。
为了迎合CPU的这种存取机制,应用程序应该尽可能相关联的数据所占用的数据块儿大小,刚好是存取单位字节数的整数倍,这样才能够保证CPU的存取次数最小。
举个例子:
存取粒度为4个字节,对比如下两种场景:
场景一:应用程序数据所在位置为0到7,其中,位置0代表一份单独的数据;位置1 - 位置4代表第二份单独的数据;位置5 - 位置7代表第三份单独的数据。
现在CPU要获取第二份数据。那么,CPU需要先从0读取4个字节,然后向上偏移1个字节,保留下位置1 - 位置3的数据;然后从位置4读取4个字节,然后向下偏移3个字节,保留位置4的数据。最后将两部分数据拼接起来,交给寄存器。
场景二:应用程度数据所在位置为0到12,其中,位置0代表一份单独的数据;位置1 - 位置3填充空字符;位置4 - 位置6代表第二份单独的数据;位置6 - 位置7填充空字符;位置8 - 位置10代表第三份数据;位置11填充空字符。
现在CPU要获取第二份数据。那么,CPU只需要从位置4开始,读取4个字节即可。
显然,对于第一种情况,CPU的存取开销会大增,部分CPU甚至会直接进入异常状态,告知程序无法执行。当然,第二种情况会有一定的空间消耗,不过个人认为大多数情况下,一份应用数据的大小总会大于一个存取单位的大小,由于内存对齐引起的空间浪费率并没有那么高。
参考
https://juejin.im/post/5b53ee7e5188251aaa2d2e16
https://zhuanlan.zhihu.com/p/38380467
https://blog.csdn.net/yangbodong22011/article/details/78419966
https://cloud.tencent.com/developer/article/1441547
http://redisbook.com/preview/sds/different_between_sds_and_c_string.html
https://blog.csdn.net/czrzchao/article/details/78990345
https://www.jianshu.com/p/a371e2613ec8
https://zhuanlan.zhihu.com/p/65737134
https://github.com/antirez/sds
https://github.com/antirez/redis
https://www.cnblogs.com/coder2012/p/3150757.html