引言
本系列文章开始讲解 Redis 相关源码,文章不定时更新,并且周期可能会很长,请大家谅解。作为系列文章的开篇,我们从最简单的数据结构 SDS 开始讲解,源码取自 6.0.8 版本。
简单动态字符串
简单动态字符串(Simple Dynamic Strings,SDS)是 Redis 的基本数据结构之一,用于存储字符串和整型数据。SDS 兼容 C 语言标准字符串处理函数,且在此基础上保证了二进制安全。
二进制安全
什么是二进制安全?通俗地讲,C 语言中,用 “\0” 表示字符串的结束,如果字符串中本身就有 “\0” 字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。
数据结构
SDS 既然是字符串,那么首先需要一个字符串指针;为了方便上层接口调用,该结构还需要记录一些统计信息,如当前数据长度和剩余容量等,如:
1
2
3
4
5struct sds {
int len; // buf 中已占用字节数
int free; // buf 中剩余可用字节数
char buf[]; // 数据空间
};在 64 位系统下,字段 len 和字段 free 各 占 4 个字节,紧接着存放字符串。我们分析一下这样设计有什么好处?
####优势有单独的统计变量 len 和 free(称为头部)。可以很方便地得到字符串长度。
内容存放在柔性数组 buf 中,SDS 对上层暴露的指针不是指向结构体 SDS 的指针,而是直接指向柔性数组 buf 的指针。上层可像读取 C 字符串一样读取 SDS 的内容,兼容 C 语言处理字符串的各种函数。
由于有长度统计变量 len 的存在,读写字符串时不依赖 “\0” 终止符,保证了二进制安全。
%E7%AE%80%E5%8D%95%E5%8A%A8%E6%80%81%E5%AD%97%E7%AC%A6%E4%B8%B2SDS/01.png)
📚 Tips
上例中的 buf[] 是一个柔性数组。柔性数组成员 (flexible array member),也叫伸缩性数组成员,只能被放在结构体的末尾。包含柔性数组成员的结构体,通过 malloc 函数为柔性数组动态分配内存。之所以用柔性数组存放字符串,是因为柔性数组的地址和结构体 是连续的,这样查找内存更快(因为不需要额外通过指针找到字符串 的位置);可以很方便地通过柔性数组的首地址偏移得到结构体首地 址,进而能很方便地获取其余变量。
改进
我们实现了一个最基本的动态字符串,但是该结构是否有改进的空间呢?我们从一个简单的问题开始思考:不同长度的字符串是否有必要占用相同大小的头部?一个 int 占 4 字节,在实际应用中,存放于 Redis 中的字符串往往没有这么长,每个字符串都用 4 字节存储未免太浪费空间了。我们考虑三种情况:短字符串,len 和 free 的长度为 1 字节就够了;长字符串,用 2 字节或 4 字节;更长的字符串,用 8 字节。这样确实更省内存,但依然存在以下问题:
- 如何区分这 3 种情况?
- 对于短字符串来说,头部还是太长了。以长度为 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 | /* Note: sdshdr5 is never used, we just access the flags byte directly. |
sdshdr5 结构中,flags 占 1 个字节,其低 3 位(bit)表示 type,高 5 位(bit)表示长度,能表示的长度区间为 0~31(2^5 -1), flags 后面就是字符串的内容。
Tips
__attribute__ ((__packed__)) 的作用就是避免编译器进行字节对齐优化,采用实际的字节对齐。 这样做的好处就是节省内存,同时可以通过指针灵活的获取各个字段的地址(如果对齐优化了,那么指针可能获取到填充的地址空间)。
%E7%AE%80%E5%8D%95%E5%8A%A8%E6%80%81%E5%AD%97%E7%AC%A6%E4%B8%B2SDS/02.png)
而长度大于 31 的字符串,1 个字节依然存不下。我们按之前的思路,将 len 和 free 单独存放。sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的结构相同,sdshdr16 结构如图所示。
%E7%AE%80%E5%8D%95%E5%8A%A8%E6%80%81%E5%AD%97%E7%AC%A6%E4%B8%B2SDS/03.png)
其中“表头”共占用了 S[2(len) + 2(alloc) + 1(flags)] 个字节。flags 的内容与 sdshdr5 类似,依然采用 3 位存储类型,但剩余 5 位不存储长度。
在 Redis 的源代码中,对类型的宏定义如下:
sds.h
1 | define SDS_TYPE_5 0 |
sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的数据结构如下:
sds.h
1 | struct __attribute__ ((__packed__)) sdshdr8 { |
可以看到,这 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;
// 类型掩码,按位与可求出对应的数据类型
// 初始化 sh 指针,让 sh 指向对应结构体的首地址。T 就是对应的结构体类型,s 是指向 buf 的指针
// 与 SDS_HDR_VAR 类似,只不过这里返回的是结构对象,需要 "SDS_HDR(T,s) -> len "方式使用
// 1M 内存分配量
sds.c
1 | /* |
📚 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
23void 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
34void 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;
}扩容流程如图:
%E7%AE%80%E5%8D%95%E5%8A%A8%E6%80%81%E5%AD%97%E7%AC%A6%E4%B8%B2SDS/04.png)
本文主要介绍了 SDS 相关的基础概念以及比较核心方法,SDS 为上层提供了丰富的 API,本文就不再介绍了。通过这几个核心方法的分析,其实大家可以看到,大多数都是围绕 s 以及常用的宏展开的,只要大家把宏看懂记住配合指针操作,那么 SDS 的其他 API 也同样简单。当然,本文主要讲解 Redis 源码,所以默认大家是熟练 C 的,所以对一些相关运算及概念不会做过多介绍。感兴趣的可以看看我的关于 C 系列的文章
参考
- 【Redis 5 设计与源码分析】
- 【Redis 6.0.x】