SDS

众所周知,redis对外支持五种数据结构:string、hash、list、set、sorted set。而在内部实现中,则主要依赖于如下七种数据结构:

  1. SDS(simple dynamic string):简单动态字符串
  2. ADList(a generic doubly linked list):双向链表
  3. dict(Hash Tables):字典
  4. intset:整数集合
  5. ziplist:压缩表
  6. quickList:快速列表
  7. 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
//www.greatytc.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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,744评论 6 502
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,505评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 163,105评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,242评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,269评论 6 389
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,215评论 1 299
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,096评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,939评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,354评论 1 311
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,573评论 2 333
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,745评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,448评论 5 344
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,048评论 3 327
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,683评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,838评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,776评论 2 369
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,652评论 2 354