0%

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

引言

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

简单动态字符串

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

  • 二进制安全

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

  • 数据结构

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

    1
    2
    3
    4
    5
    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” 终止符,保证了二进制安全。



📚 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

1
2
3
4
5
6
/* 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__)) 的作用就是避免编译器进行字节对齐优化,采用实际的字节对齐。 这样做的好处就是节省内存,同时可以通过指针灵活的获取各个字段的地址(如果对齐优化了,那么指针可能获取到填充的地址空间)。



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



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

sds.h

1
2
3
4
5
#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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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 函数中很大一部分用到了宏运算,这里先介绍一下相关的宏以及常量
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    // 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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/*
* 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 释放内存:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    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 并没有真正被清除,新的数 据可以覆盖写,而不用重新申请内存。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    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 来实现,代码如下:

    1
    2
    3
    4
    // 注意 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 将两个字符串拼接在一起,指定了相关长度,故该过程保证了二进制安全。最后需要加上结束符。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    99
    100
    101
    102
    103
    104
    105
    106
    107
    108
    109
    110
    111
    112
    113
    114
    115
    116
    117
    118
    /* 将指针 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;
    }

    扩容流程如图:



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


参考