Redis 动态字符串 SDS 源码解析

本文作者: Pushy
本文链接: http://pushy.site/2019/12/21/redis-sds/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 许可协议。转载请注明出处!

1. 什么是 SDS

众所周知,在 Redis 的五种数据解构中,最简单的就是字符串:

redis> set msg "Hello World"

而 Redis 并没有直接使用 C 语言传统的字符串表示,而是自己构建了一个名为简单动态字符串(Simple dynamic string,即 SDS)的抽象数据结构。

执行上面的 Redis 命令,在 Server 的数据库中将创建一个键值对,即:

  • 键为 “msg” 的 SDS;
  • 值为 “Hello World” 的 SDS。

我们再来看下 SDS 的定义,在 Redis 的源码目录 sds.h 头文件中,定义了 SDS 的结构体:

struct sdshdr {
    // 记录 buf 数组中当前已使用的字节数量
    unsigned int len;
    // 记录 buf 数组中空闲空间长度
    unsigned int free;
    // 字节数组
    char buf[];

};

可以看到,SDS 通过 lenfree 属性值来描述字节数组 buf 当前的存储状况,这样在之后的扩展和其他操作中有很大的作用,还能以 O(1) 的复杂度获取到字符串的长度(我们知道,C 自带的字符串本身并不记录长度信息,只能遍历整个字符串统计)

那么为什么 Redis 要自己实现一套字符串数据解构呢?下面慢慢来研究!

2. SDS 的优势

杜绝缓冲区溢出

除了获取字符串长度的复杂度为较高之外,C 字符串不记录自身长度信息带来的另一个问题就是容易造成内存溢出。举个例子,通过 C 内置的 strcat 方法将字符串 motto 追加到 s1 字符串后边:

void wrong_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = s a question. 

但是输出却出乎意料,我们只想修改 s1 字符串的值,而 s2 字符串也被修改了。这是因为 strcat 方法假定用户在执行前已经为 s1 分配了足够的内存,可以容纳 motto 字符串中的内容。而一旦这个假设不成立,就会产生缓冲区溢出

通过 Debug 我们看到,s1 变量内存的初始位置为 94458843619936 (10进制), s2 初始位置为 94458843619968,是一段相邻的内存块:

[图片上传失败...(image-b46e0c-1577019474368)]

所以一旦通过 strcat 追加到 s1 的字符串 motto 的长度大于 s1 到 s2 的内存地址间隔时,将会修改到 s2 变量的值。而正确的做法应该是在 strcat 之前为 s1 重新调整内存大小,这样就不会修改 s2 变量的值了:

void correct_strcat() {
    char *s1, *s2;

    s1 = malloc(5 * sizeof(char));
    strcpy(s1, "Hello");
    s2 = malloc(5 * sizeof(char));
    strcpy(s2, "World");

    char *motto = " To be or not to be, this is a question.";
    // 为 s1 变量扩展内存,扩展的内存大小为 motto * sizeof(char) + 空字符结尾(1)
    s1 = realloc(s1, (strlen(motto) * sizeof(char)) + 1);
    s1 = strcat(s1, motto);

    printf("s1 = %s \n", s1);
    printf("s2 = %s \n", s2);
}

// s1 = Hello To be or not to be, this is a question. 
// s2 = World 

可以看到,扩容后的 s1 变量内存地址起始位置变为了 94806242149024(十进制),s2 起始地址为 94806242148992。这时候 s1 与 s2 内存地址的间隔大小已经足够 motto 字符串的存放了:

[图片上传失败...(image-eca5dd-1577019474368)]

而与 C 字符串不同, SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性,具体的实现在 sds.c 中。通过阅读源码,我们可以明白之所以 SDS 能杜绝缓冲区溢出是因为再调用 sdsMakeRoomFor 时,会检查 SDS 的空间是否满足修改所需的要求(即 free >= addlen 条件),如果满足 Redis 将会将 SDS 的空间扩展至执行所需的大小,在执行实际的 concat 操作,这样就避免了溢出发生:

// 与 C 语言 string.h/strcat 功能类似,其将一个 C 字符串追加到 sds
sds sdscat(sds s, const char *t) {
    return sdscatlen(s, t, strlen(t));
}

sds sdscatlen(sds s, const char *t, size_t len) {
    struct sdshdr *sh;
    size_t curlen = sdslen(s);  // 获取 sds 的 len 属性值

    s = sdsMakeRoomFor(s, len);
    if (s == NULL) return NULL;
    // 将 sds 转换为 sdshdr,下边会介绍
    sh = (void *) (s - sizeof(struct sdshdr));
    // 将字符串 t 复制到以 s+curlen 开始的内存地址空间
    memcpy(s + curlen, t, len);
    sh->len = curlen + len;     // concat后的长度 = 原先的长度 + len
    sh->free = sh->free - len;  // concat后的free = 原来 free 空间大小 - len
    s[curlen + len] = '\0';     // 与 C 字符串一样,都是以空字符 \0 结尾
    return s;
}

// 确保有足够的空间容纳加入的 C 字符串, 并且还会分配额外的未使用空间
// 这样就杜绝了发生缓冲区溢出的可能性
sds sdsMakeRoomFor(sds s, size_t addlen) {
    struct sdshdr *sh, *newsh;
    size_t free = sdsavail(s);  // 当前 free 空间大小
    size_t len, newlen;

    if (free >= addlen) {
        /* 如果空余空间足够容纳加入的 C 字符串大小, 则直接返回, 否则将执行下边的代码进行扩展 buf 字节数组 */
        return s;
    }
    len = sdslen(s);  // 当前已使用的字节数量
    sh = (void *) (s - (sizeof(struct sdshdr)));
    newlen = (len + addlen);  // 拼接后新的字节长度

    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL; // 申请内存失败

    /* 新的 sds 的空余空间 = 新的大小 - 拼接的 C 字符串大小 */
    newsh->free = newlen - len;
    return newsh->buf;
}

另外,在看源码时我对 sh = (void *) (s - sizeof(struct sdshdr)); 一脸懵逼,如果不懂可以看:Redis(一)之 struct sdshdr sh = (void) (s-(sizeof(struct sdshdr)))讲解

减少修改字符带来的内存重分配次数

对于包含 N 个字符的 C 字符串来说,底层总是由 N+1 个连续内存的数组来实现。由于存在这种关系,因此每次修改时,程序都需要对这个 C 字符串数组进行一次内存重分配操作:

  • 如果是拼接操作:扩展底层数组的大小,防止出现缓冲区溢出(前面提到的);
  • 如果是截断操作:需要释放不使用的内存空间,防止出现内存泄漏

Redis 作为频繁被访问修改的数据库,为了减少修改字符带来的内存重分配的性能影响,SDS 也变得非常需要。因为在 SDS 中,buf 数组的长度不一定就是字符串数量 + 1,可以包含未使用的字符,通过 free 属性值记录。通过未使用空间,SDS 实现了以下两种优化策略:

Ⅰ、空间预分配

空间预分配用于优化 SDS 增长的操作:当对 SDS 进行修改时,并且需要对 SDS 进行空间扩展时,Redis 不仅会为 SDS 分配修改所必须的空间,还会对 SDS 分配额外的未使用空间

在前面的 sdsMakeRoomFor 方法可以看到,额外分配的未使用空间数量存在两种策略:

  • SDS 小于 SDS_MAX_PREALLOC:这时 len 属性值将会和 free 属性相等;
  • SDS 大于等于 SDS_MAX_PREALLOC:直接分配 SDS_MAX_PREALLOC 大小。
sds sdsMakeRoomFor(sds s, const char *t, size_t len) {
    ...
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    newsh = realloc(sh, sizeof(struct sdshdr) + newlen + 1);
    if (newsh == NULL) return NULL;
    newsh->free = newlen - len;
    return newsh->buf;
}

通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。

Ⅱ、惰性空间释放

惰性空间释放用于优化 SDS 字符串缩短操作,当需要缩短 SDS 保存的字符串时,Redis 并不立即使用内存重分配来回收缩短多出来的字节,而是使用 free 属性将这些字节记录起来,并等待来使用

举个例子,可以看到执行完 sdstrim 并没有立即回收释放多出来的 22 字节的空间,而是通过 free 变量值保存起来。当执行 sdscat 时,先前所释放的 22 字节的空间足够容纳追加的 C 字符串 11 字节的大小,也就不需要再进行内存空间扩展重分配了。

#include "src/sds.h"

int main() {
    // sds{len = 32, free = 0, buf = "AA...AA.a.aa.aHelloWorld     :::"}
    s = sdsnew("AA...AA.a.aa.aHelloWorld     :::");  
    // sds{len = 10, free = 22, buf = "HelloWorld"}
    s = sdstrim(s, "Aa. :");  
    // sds{len = 21, free = 11, buf = "HelloWorld! I'm Redis"}
    s = sdscat(s, "! I'm Redis");   
    return 0;
}

通过惰性空间释放策略,SDS 避免了缩短字符串时所需内存重分配操作,并会将来可能增长操作提供优化。与此同时,SDS 也有相应的 API 真正地释放 SDS 的未使用空间。

二进制安全

C 字符串必须符合某种编码,并且除了字符串的末尾之外,字符串不能包含空字符(\0),否则会被误认为字符串的末尾。这些限制导致不能保存图片、音频等这种二进制数据。

但是 Redis 就可以存储二进制数据,原因是因为 SDS 是使用 len 属性值而不是空字符来判断字符串是否结束的。

兼容部分 C 字符串函数

我们发现, SDS 的字节数组有和 C 字符串相似的地方,例如也是以 \0 结尾(但是不是以这个标志作为字符串的结束)。这就使得 SDS 可以重用 <string.h> 库定义的函数:

#include <stdio.h>
#include <strings.h>
#include "src/sds.h"

int main() {
    s = sdsnew("Cat");
    // 根据字符集比较大小
    int ret = strcasecmp(s, "Dog");
    printf("%d", ret);
    return 0;
}

3. 总结

看完 Redis 的 SDS 的实现,终于知道 Redis 只所以快,肯定和 epoll 的网络 I/O 模型分不开,但是也和底层优化的简单数据结构分不开。

SDS 精妙之处在于通过 len 和 free 属性值来协调字节数组的扩展和伸缩,带来了较比 C 字符串太多的性能更好的优点。什么叫牛逼?这就牛逼!

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

推荐阅读更多精彩内容