Redis 设计与实现 3:字符串 SDS

在 Redis 中,字符串都用自定义的结构简单动态字符串(Simple Dynamic Strings,SDS)
Redis 中使用到的字符串都是用 SDS,例如 key、string 类型的值、sorted set 的 member、hash 的 field 等等等等。。。

数据结构

旧版本的结构

3.2 版本之前,sds 的定义是这样的:

struct sdshdr {
    // buf 数组中已使用的字节数量,也就是 sds 本身的字符串长度
    unsigned int len;
    // buf 数组中未使用的字节数量
    unsigned int free;
    // 字节数组,用于保存字符串
    char buf[];
};
旧版本 SDS 结构示例

这样的结构有几个好处

  • 单独记录长度len,获取字符串长度的时间复杂度是 O(1) 。传统的 C 字符串获取长度需要遍历字符串,直到遇到\0,时间复杂度是 O(N)
  • buf 数组末尾遵循 C 字符串以 \0 结尾的惯例,可以兼容 C 处理字符串的函数。
  • 减少修改字符串带来的内存重分配次数,Redis 使用了 空间预分配(预先申请大一点点的空间) 和 空间惰性释放(字符串变短修改len字段即可)来减少字符串修改引起的内存重新分配。
  • 不以\0为结尾的判断,二进制安全。因为图片等二进制数据中,可能包含\0,传统 C 字符串一遇到 \0 就认为字符串结束了,会导致不能完整保存。

缺点:

  • lenfree 的定义用了 4 个字节,可以表示 2^32 的长度。但是我们实际使用的字符串,往往没有那么长。4 个字节造成了浪费。

新版本的结构

旧版本中我们说到,lenfree 的缺点是用了太长的变量,新版本解决了这个问题。
我们来看一下新版本的 SDS 结构。

在 Redis 3.2 版本之后,Redis 将 SDS 划分为 5 种类型:

类型 字节
sdshdr5 < 1 <8
sdshdr8 1 8
sdshdr16 2 16
sdshdr32 4 32
sdshdr64 8 64

新版本新增加了一个 flags 字段来标识类型,长度 1 字节(8 位)。
类型只占用了前 3 位。在 sdshdr5 中,后 5 位用来保存字符串的长度。其他类型后 5 位没有用。

struct __attribute__ ((__packed__)) sdshdr5 {
    unsigned char flags; /* 前 3 位保存类型,后 5 位保存字符串长度 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len; /* 字符串长度,1 字节 8 位 */
    uint8_t alloc; /* 申请的总长度,1 字节 8 位 */
    unsigned char flags; /* 前 3 位保存类型,后 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len; /* 字符串长度,2 字节 16 位 */
    uint16_t alloc; /* 申请的总长度,2 字节 16 位 */
    unsigned char flags; /* 前 3 位保存类型,后 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; /* 字符串长度,4 字节 32 位 */
    uint32_t alloc; /* 申请的总长度,4 字节 32 位 */
    unsigned char flags; /* 前 3 位保存类型,后 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; /* 字符串长度,8 字节 64 位 */
    uint64_t alloc; /* 申请的总长度,8 字节 64 位 */
    unsigned char flags; /* 前 3 位保存类型,后 5 位未使用 */
    char buf[];
};
新版本 SDS 结构示例

优点:

  • 旧版本相对于传统 C 字符串的优点,新版本都有
  • 相对于旧版本,新版本可以通过字符串的长度,选择不同的结构,可以节约内存
  • 使用 __attribute__ ((__packed__)) ,让编译器取消结构在编译过程中的优化对齐,按照实际占用字节数进行对齐,可以节约内存

SDS 的初始化

sds 的定义,跟传统的C语言字符串保持类型兼容 char *。但是 sds 是二进制安全的,中间可能包含\0

sds.h

typedef char *sds;

sds.c

// 初始化 sds
sds sdsnewlen(const void *init, size_t initlen) {
    // 指向 sdshdr 开始地方的指针
    void *sh;
    // sds 实际是一个指针,指向 buf 开始的位置
    sds s;
    // 根据初始化的长度,返回 sds 的类型
    char type = sdsReqType(initlen);
    // initlen == 0,是空字符串,空字符串往往就是用来往后添加字节的,使用 SDS_TYPE_8 比 SDS_TYPE_5 更好
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    // 根据类型获取 struct sdshdr 的长度
    int hdrlen = sdsHdrSize(type);
    // flags 字段的指针
    unsigned char *fp;
    
    // 开始分配空间,+1 是为了最后一个的结束符号 \0
    sh = s_malloc(hdrlen+initlen+1);
    if (sh == NULL) return NULL;
    // const char *SDS_NOINIT = "SDS_NOINIT";
    if (init==SDS_NOINIT)
        init = NULL;
    else if (!init)
        // 不是 init 则清空 sh 的内存
        memset(sh, 0, hdrlen+initlen+1);
    // s 指向了 buf 开始的地址
    // 从上面结构可以看出,内存地址的顺序: len, alloc, flag, buf
    // 因为 buf 本身不占用空间,hdrlen 实际上就是结构的头(len、alloc、flags)
    s = (char*)sh+hdrlen;
    // flags 占用 1 个字节,所以 s 退一位就是 flags 的开始位置了
    fp = ((unsigned char*)s)-1;
    switch(type) {
        case SDS_TYPE_5: {
            // #define SDS_TYPE_BITS 3
            // 前 3 位保存类型,后 5 位保存长度
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            // define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (void*)((s)-(sizeof(struct sdshdr##T)));
            // sh 变量赋值了 struct sdshdr
            SDS_HDR_VAR(8,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        // 下面是对 SDS_TYPE_16、SDS_TYPE_32、SDS_TYPE_64 的初始化,跟 SDS_TYPE_8 的类似,篇幅有限,省略...
    }
    // 如果 init 非空,则把 init 字符串赋值给 s,实际上也是 buf 的初始化
    if (initlen && init)
        memcpy(s, init, initlen);
    // 最后加一个结束标志 \0
    s[initlen] = '\0';
    return s;
}

SDS 的扩/缩容

扩容

扩容就不跟初始化一样写注释写得那么详细了,直接拉最重要的几句代码就行。

sds sdsMakeRoomFor(sds s, size_t addlen) {
    // #define SDS_MAX_PREALLOC (1024*1024)
    // 当新的长度小于 1M 的时候,长度会增长一倍
    // 当新的长度达到 1M 之后,最多就增长 1M 了
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
        newlen += SDS_MAX_PREALLOC;
    // ...
}

缩容

sds 缩短不会真正缩小 buf,而是只改长度而已,类型也不变。

sds.c

// 删掉字符串的左右字符中指定的字符
sds sdstrim(sds s, const char *cset) {
    char *start, *end, *sp, *ep;
    size_t len;

    sp = start = s;
    ep = end = s+sdslen(s)-1;
    while(sp <= end && strchr(cset, *sp)) sp++;
    while(ep > sp && strchr(cset, *ep)) ep--;
    len = (sp > ep) ? 0 : ((ep-sp)+1);
    if (s != sp) memmove(s, sp, len);
    
    // 结尾符
    s[len] = '\0';
    // 缩短长度
    sdssetlen(s,len);
    return s;
}

sds.h

static inline void sdssetlen(sds s, size_t newlen) {
    // 设置sds长度,只是修改 sdshdr 结构中的长度字段,类型不会变
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            {
                unsigned char *fp = ((unsigned char*)s)-1;
                *fp = (unsigned char)(SDS_TYPE_5 | (newlen << SDS_TYPE_BITS));
            }
            break;
        case SDS_TYPE_8:
            SDS_HDR(8,s)->len = (uint8_t)newlen;
            break;
        case SDS_TYPE_16:
            SDS_HDR(16,s)->len = (uint16_t)newlen;
            break;
        case SDS_TYPE_32:
            SDS_HDR(32,s)->len = (uint32_t)newlen;
            break;
        case SDS_TYPE_64:
            SDS_HDR(64,s)->len = (uint64_t)newlen;
            break;
    }
}

本文的分析没有特殊说明都是基于 Redis 6.0 版本源码
redis 6.0 源码:https://github.com/redis/redis/tree/6.0

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

推荐阅读更多精彩内容