Redis 源码解析(一)简单动态字符串 SDS

引言

本系列文章开始讲解 Redis 相关源码,文章不定时更新,并且周期可能会很长,请大家谅解。作为系列文章的开篇,我们从最简单的数据结构 SDS 开始讲解,源码取自 6.0.8 版本。

简单动态字符串

简单动态字符串(Simple Dynamic Strings,SDS)是Redis的基本数据结构之一,用于存储字符串和整型数据。SDS 兼容 C 语言标准字符串处理函数,且在此基础上保证了二进制安全。

  • 二进制安全

什么是二进制安全?通俗地讲,C 语言中,用 “\0” 表示字符串的结束,如果字符串中本身就有 “\0” 字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。

  • 数据结构

SDS 既然是字符串,那么首先需要一个字符串指针;为了方便上层接口调用,该结构还需要记录一些统计信息,如当前数据长度和剩余容量等,如:

struct sds { 
  int len;     // buf 中已占用字节数 
  int free;    // buf 中剩余可用字节数 
  char buf[];  // 数据空间
};

在 64 位系统下,字段 len 和字段 free 各 占 4 个字节,紧接着存放字符串。我们分析一下这样设计有什么好处?

优势

  • 有单独的统计变量 len 和 free(称为头部)。可以很方便地得到字符串长度。
  • 内容存放在柔性数组 buf 中,SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接指向柔性数组 buf 的指针。上层可像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。
  • 由于有长度统计变量 len 的存在,读写字符串时不依赖 “\0” 终止符,保证了二进制安全。

image.png

Tips

上例中的 buf[] 是一个柔性数组。柔性数组成员 (flexible array member),也叫伸缩性数组成员,只能被放在结构体的末尾。包含柔性数组成员的结构体,通过 malloc 函数为柔性数组动态分配内存。之所以用柔性数组存放字符串,是因为柔性数组的地址和结构体 是连续的,这样查找内存更快(因为不需要额外通过指针找到字符串 的位置);可以很方便地通过柔性数组的首地址偏移得到结构体首地 址,进而能很方便地获取其余变量。

改进

我们实现了一个最基本的动态字符串,但是该结构是否有改进的空间呢?我们从一个简单的问题开始思考:不同长度的字符串是否有必要占用相同大小的头部?一个 int 占 4 字节,在实际应用中,存放于 Redis 中的字符串往往没有这么长,每个字符串都用 4 字节存储未免太浪费空间了。我们考虑三种情况:短字符串,len 和 free 的长度为 1 字节就够了;长字符串,用 2 字节或 4 字节;更长的字符串,用 8 字节。这样确实更省内存,但依然存在以下问题:

  1. 如何区分这 3 种情况?
  2. 对于短字符串来说,头部还是太长了。以长度为 1 字节的字符串为例,len 和 free 本身就占了 2 个字节,能不能进一步压缩呢?

对于问题 1,我们考虑增加一个字段 flags 来标识类型,用最小的 1 字节来存储,且把 flags 加在柔性数组 buf 之前,这样虽然多了1 字节, 但通过偏移柔性数组的指针即能快速定位 flags,区分类型,也可以接受;对于问题 2,由于len已经是最小的1字节了,再压缩只能考虑用位来存储长度了。
结合两个问题,5 种类型(长度 1 字节、2 字节、4 字节、8 字节、 小于 1 字节)的 SDS 至少要用 3 位来存储类型(2 ^ 3 =8),1 个字节 8 位,剩余的 5 位存储长度,可以满足长度小于 32 的短字符串。我们用如下结构来存储长度小于 32 的短字符串:

sds.h

/* 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 {
    unsigned char flags; /* 低 3 位存储类型, 高 5 位存储长度 */
    char buf[];
};

sdshdr5 结构中,flags 占 1 个字节,其低 3 位(bit)表示 type,高 5 位(bit)表示长度,能表示的长度区间为 0~31(2^5 -1), flags 后面就是字符串的内容。
Tips

__attribute__ ((__packed__)) 的作用就是避免编译器进行字节对齐优化,采用实际的字节对齐。 这样做的好处就是节省内存,同时可以通过指针灵活的获取各个字段的地址(如果对齐优化了,那么指针可能获取到填充的地址空间)。

image.png

而长度大于 31 的字符串,1 个字节依然存不下。我们按之前的思路,将 len 和 free 单独存放。sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的结构相同,sdshdr16 结构如图所示。


image.png

其中“表头”共占用了 S[2(len) + 2(alloc) + 1(flags)] 个字节。flags 的内容与 sdshdr5 类似,依然采用 3 位存储类型,但剩余 5 位不存储长度。
在 Redis 的源代码中,对类型的宏定义如下:

sds.h

#define SDS_TYPE_5  0
#define SDS_TYPE_8  1
#define SDS_TYPE_16 2
#define SDS_TYPE_32 3
#define SDS_TYPE_64 4

sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的数据结构如下:

sds.h

struct __attribute__ ((__packed__)) sdshdr8 {
    uint8_t len;          /* 已用的长度 */
    uint8_t alloc;        /* 总的长度 */
    unsigned char flags;  /* 低 3 为代表类型,高 5 位未使用 */
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr16 {
    uint16_t len;  
    uint16_t alloc;  
    unsigned char flags; 
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr32 {
    uint32_t len; 
    uint32_t alloc; 
    unsigned char flags; 
    char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
    uint64_t len; 
    uint64_t alloc; 
    unsigned char flags; 
    char buf[];
};

可以看到,这 4 种结构的成员变量类似,唯一的区别是 len 和 alloc 的类型不同。结构体中 4 个字段的具体含义分别如下。

  • len :表示 buf 中已占用字节数。
  • alloc :表示buf中已分配字节数,不同于 free,记录的是为 buf 分配的总长度。
  • flags :标识当前结构体的类型,低 3 位用作标识位,高 5 位预留。
  • buf :柔性数组,真正存储字符串的数据空间。

基本操作

数据结构的基本操作不外乎增、删、改、查,SDS 也不例外。

c 函数中很大一部分用到了宏运算,这里先介绍一下相关的宏以及常量

// sds 是 char 类型的指针
typedef char *sds;

// 类型掩码,按位与可求出对应的数据类型
#define SDS_TYPE_MASK 7

// 初始化 sh 指针,让 sh 指向对应结构体的首地址。T 就是对应的结构体类型,s 是指向 buf 的指针
#define SDS_HDR_VAR(T,s) struct sdshdr##T *sh = (struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T)));

// 与 SDS_HDR_VAR 类似,只不过这里返回的是结构对象,需要 "SDS_HDR(T,s) -> len "方式使用
#define SDS_HDR(T,s) ((struct sdshdr##T *)((s)-(sizeof(struct sdshdr##T))))


// 1M 内存分配量
#define SDS_MAX_PREALLOC (1024*1024)
  • 创建字符串

Redis 通过 sdsnewlen 函数创建 SDS。在函数中会根据字符串长度选择合适的类型,初始化完相应的统计值后,返回指向字符串内容的指针,根据字符串长度选择不同的类型:

sds.c

/*
 * init 指向字符串的指针,initlen 字符串的长度;如:
 * mystring = sdsnewlen("abc",3); 
 */
sds sdsnewlen(const void *init, size_t initlen) {  
    void *sh;
    sds s;
    // 根据初始大小获取对应的类型
    char type = sdsReqType(initlen);
    // SDS_TYPE_5 类型会转换成 SDS_TYPE_8 
    if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
    // 根据类型计算出该结构对应的大小
    int hdrlen = sdsHdrSize(type);
    // 指向 flags 指针
    unsigned char *fp;
   /*
    * 分配内存,内存大小包括:结构自身占用的内存 + 数据存储的内存 + 1 * 字节的 '\0'
    */
    sh = s_malloc(hdrlen+initlen+1);
    // 内存分配失败返回 NULL
    if (sh == NULL) return NULL;
    if (init == SDS_NOINIT)
        init = NULL;
    else if (!init)
        // 开始初始化内存
        memset(sh, 0, hdrlen+initlen+1);
    // 此时 s 指向了 buf(sh 是内存的首地址,hdrlen 是结构体大小,注意这里的指针转换)
    s = (char*)sh+hdrlen;
    // 由于内存是紧凑的,这里可以直接算出 flags 的内存地址,所以 fp 指向了 flags
    fp = ((unsigned char*)s)-1;
    // 通过 type 的类型,我们就可以给 flags 赋值了
    switch(type) {
        case SDS_TYPE_5: {
            // 这里不用过多介绍,按位或算出高低位
            *fp = type | (initlen << SDS_TYPE_BITS);
            break;
        }
        case SDS_TYPE_8: {
            // 这步在做什么?SDS_HDR_VAR 其实是一个宏,该步的操作就是初始化 sh,将 sh 指针强制转换成对应的结构体指针,因为 sh 起初是 void * 指针,所以无法通过指针访问其成员。通过结构体大小(第一个参数)以及分配的内存大小(第二个参数)可以推算出结构体首地址
            SDS_HDR_VAR(8,s);
            // 初始化 len 成员
            sh->len = initlen;
            // 初始化 alloc 成员
            sh->alloc = initlen;
            // 初始化 flag
            *fp = type;
            break;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            sh->len = initlen;
            sh->alloc = initlen;
            *fp = type;
            break;
        }
    }
    
    if (initlen && init)
        // 将 init 指向的字符串赋值到 s 中(也就是 buf)
        memcpy(s, init, initlen);
    // 不要忘记末尾的 '\0'
    s[initlen] = '\0';
    // 返回字符串地址,即 buf 地址
    return s;
}

// 根据字符串的大小返回其对应的 SDS 类型
static inline char sdsReqType(size_t string_size) {
    if (string_size < 1<<5)
        return SDS_TYPE_5;
    if (string_size < 1<<8)
        return SDS_TYPE_8;
    if (string_size < 1<<16)
        return SDS_TYPE_16;
#if (LONG_MAX == LLONG_MAX)
    if (string_size < 1ll<<32)
        return SDS_TYPE_32;
    return SDS_TYPE_64;
#else
    return SDS_TYPE_32;
#endif
}

Tips

Redis 3.2 后的 SDS 结构由 1 种增至 5 种,且对于 sdshdr5 类型,在创建空字符串时会强制转换为 sdshdr8。原因可能是创建空 字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为 sdshdr8。

从源码中我们可以看到,其实 s 就是一个字符数组的指针,即结构中的 buf。这样设计的好处在于直接对上层提供了字符串内容指针,兼容了部分 C 函数,且通过偏移能迅速定位到 SDS 结构体的各处成员变量。

  • 释放字符串

SDS 提供了直接释放内存的方法——sdsfree,该方法通过对 s 的偏移,可定位到 SDS 结构体的首部,然后调用 s_free 释放内存:

void sdsfree(sds s) {
    // 如果字符串为空,直接返回
    if (s == NULL) return;
    // 直接释放内存,s[-1] 指向的是 flag,s - flag 得出的就是内存的首地址
    s_free((char*)s-sdsHdrSize(s[-1]));
}

// 通过类型获取对应的结构体内存大小
static inline int sdsHdrSize(char type) {
    switch(type&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return sizeof(struct sdshdr5);
        case SDS_TYPE_8:
            return sizeof(struct sdshdr8);
        case SDS_TYPE_16:
            return sizeof(struct sdshdr16);
        case SDS_TYPE_32:
            return sizeof(struct sdshdr32);
        case SDS_TYPE_64:
            return sizeof(struct sdshdr64);
    }
    return 0;
}

为了优化性能(减少申请内存的开销),SDS 提供了不直接释放 内存,而是通过重置统计值达到清空目的的方法——sdsclear。该方 法仅将 SDS 的 len 归零,此处已存在的 buf 并没有真正被清除,新的数 据可以覆盖写,而不用重新申请内存。

void sdsclear(sds s) {
    // 将 len 设置成 0
    sdssetlen(s, 0);
    // buf 首位设置成 '\0'
    s[0] = '\0';
}

// 将 len 属性设置成 newlen 对应的值
static inline void sdssetlen(sds s, size_t newlen) {
    // 获取 flags 值
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            {
                // 获取 fp 指针
                unsigned char *fp = ((unsigned char*)s)-1;
                *fp = SDS_TYPE_5 | (newlen << SDS_TYPE_BITS);
            }
            break;
        case SDS_TYPE_8:
            SDS_HDR(8,s)->len = newlen;
            break;
        case SDS_TYPE_16:
            SDS_HDR(16,s)->len = newlen;
            break;
        case SDS_TYPE_32:
            SDS_HDR(32,s)->len = newlen;
            break;
        case SDS_TYPE_64:
            SDS_HDR(64,s)->len = newlen;
            break;
    }
}

  • 拼接字符串

拼接字符串操作本身不复杂,可用 sdscatsds 来实现,代码如下:

// 注意 const
sds sdscatsds(sds s, const sds t) {
    return sdscatlen(s, t, sdslen(t));
}

sdscatsds 是暴露给上层的方法,其最终调用的是 sdscatlen。由于其中可能涉及 SDS 的扩容,sdscatlen 中调用 sdsMakeRoomFor 对带拼接的字符串 s 容量做检查,若无须扩容则直接返回 s;若需要扩容,则返回扩容好的新字符串 s。函数中的 len、curlen 等长度值是不含结束符的,而拼接时用 memcpy 将两个字符串拼接在一起,指定了相关长度,故该过程保证了二进制安全。最后需要加上结束符。

/* 将指针 t 的内容和指针 s 的内容拼接在一起,该操作是二进制安全的 */ 
sds sdscatlen(sds s, const void *t, size_t len) {
    // 计算 s 对应的 len 属性
    size_t curlen = sdslen(s);
    // 根据 s 以及 len 选择合适的扩容策略
    s = sdsMakeRoomFor(s,len);
    // 扩容失败,直接返回
    if (s == NULL) return NULL;
    // 将 t 中的 len 长度的内容复制到 s + curlen 指向的内存地址
    memcpy(s+curlen, t, len);
    // 更新 s 中的 len 属性
    sdssetlen(s, curlen+len);
    // 在 buf 的末尾添加结束符
    s[curlen+len] = '\0';
    return s;
}
// 通过 s(即 buf 地址),获取该结构对应的 len 属性
static inline size_t sdslen(const sds s) {
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5:
            return SDS_TYPE_5_LEN(flags);
        case SDS_TYPE_8:
            return SDS_HDR(8,s)->len;
        case SDS_TYPE_16:
            return SDS_HDR(16,s)->len;
        case SDS_TYPE_32:
            return SDS_HDR(32,s)->len;
        case SDS_TYPE_64:
            return SDS_HDR(64,s)->len;
    }
    return 0;
}
// 扩容方法,s 待扩容的 buf,addlen 是目标字符串内存大小
sds sdsMakeRoomFor(sds s, size_t addlen) {
    void *sh, *newsh;
    // 算出 s 中可用内存(alloc - len)
    size_t avail = sdsavail(s);
    size_t len, newlen;
    char type, oldtype = s[-1] & SDS_TYPE_MASK;
    int hdrlen;

    // 如果剩余空间足够容纳目标字符串,那么直接返回 s
    if (avail >= addlen) return s;
    // 获取 s 中的 len
    len = sdslen(s);
    // sh 指向原始 s 的结构首地址
    sh = (char*)s-sdsHdrSize(oldtype);
    
    newlen = (len+addlen);
    
    // 如果新增之后的内存大小 < 1M,那么采用 2 倍内存扩容
    if (newlen < SDS_MAX_PREALLOC)
        newlen *= 2;
    else
       // 如果 > 1M,那么以 1M 的内存增量扩容
        newlen += SDS_MAX_PREALLOC;

    // 算出新的 type 类型
    type = sdsReqType(newlen);

    // 如果是 SDS_TYPE_5,强制转换成 SDS_TYPE_8,避免频繁扩容
    if (type == SDS_TYPE_5) type = SDS_TYPE_8;
    // 算出对应的结构体大小
    hdrlen = sdsHdrSize(type);
    // 类型相同
    if (oldtype==type) {
        // 直接扩容 buf 大小
        newsh = s_realloc(sh, hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        // 新的 s 的地址
        s = (char*)newsh+hdrlen;
    } else {
        // 如果类型不同了,那么需要重新分配内存
        newsh = s_malloc(hdrlen+newlen+1);
        if (newsh == NULL) return NULL;
        // 将 s 的内容以及末尾的结束符复制到新的 buf 中
        memcpy((char*)newsh+hdrlen, s, len+1);
        // 释放 sh 内存
        s_free(sh);
        // 获取 s 的地址
        s = (char*)newsh+hdrlen;
        // 设置 type 类型
        s[-1] = type;
        // 设置 len 属性
        sdssetlen(s, len);
    }
    // 设置 alloc 属性
    sdssetalloc(s, newlen);
    return s;
}
// 求出 s 中的剩余空间,也就是 alloc - len 的数值
static inline size_t sdsavail(const sds s) {
    // 第一步都是获取 flags,然后通过 flags 获取其对应的数据类型
    unsigned char flags = s[-1];
    switch(flags&SDS_TYPE_MASK) {
        case SDS_TYPE_5: {
            return 0;
        }
        case SDS_TYPE_8: {
            SDS_HDR_VAR(8,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_16: {
            SDS_HDR_VAR(16,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_32: {
            SDS_HDR_VAR(32,s);
            return sh->alloc - sh->len;
        }
        case SDS_TYPE_64: {
            SDS_HDR_VAR(64,s);
            return sh->alloc - sh->len;
        }
    }
    return 0;
}

扩容流程如图:


image.png

本文主要介绍了 SDS 相关的基础概念以及比较核心方法,SDS 为上层提供了丰富的 API,本文就不再介绍了。通过这几个核心方法的分析,其实大家可以看到,大多数都是围绕 s 以及常用的宏展开的,只要大家把宏看懂记住配合指针操作,那么 SDS 的其他 API 也同样简单。当然,本文主要讲解 Redis 源码,所以默认大家是熟练 C 的,所以对一些相关运算及概念不会做过多介绍。感兴趣的可以看看我的关于 C 系列的文章


参考

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

推荐阅读更多精彩内容