Redis源码分析之sds篇

Redis源码分析之sds篇

Redis 是 C 语言编写的,C 语言里的字符串其实就是 char *,那 Redis 也是这样实现的吗?

如果不是的话,Redis 为什么要单独设计数据结构来存储字符串呢?

传统字符串

在 C 语言的传统字符串里,判断字符串结束是通过末尾的 ‘\0’ 标记,这也意味着字符串里不能有该字符,否则会导致字符串提前结束。

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <string.h>

int main () {
char *stra = "red\0is";
char *strb = "redis\0";
printf("%lu\n", strlen(stra)); // 3
printf("%lu\n", strlen(strb)); // 5
return 0;
}

另外我们知道,Redis 如此盛行的一个原因就是,每秒10万级别的读写速率和丰富的数据结构类型,我们在存取字符串时,经常要获取长度(len)和进行拼接操作(append),在 C 语言里,这个操作需要遍历一边操作的字符串,即需要 O(N) 的复杂度,如果空间不足还需要分配新的空间,而 Redis 作为高性能的数据库中间件,肯定要对这种慢操作进行优化。

所以传统字符串有以下缺点:

  • 二进制不安全;
  • 操作时间复杂度高。

SDS

为了解决这样的问题,Redis 中的字符串采用了 SDS (simple dynamic string)简单动态字符串结构,其定义如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
typedef char *sds;

// SDS 5 比较特殊
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};

// 除 SDS8、SDS16外,还有SDS32、SDS64
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; // 已经使用的空间
uint8_t alloc; // 字符串数组可分配的最大空间,不包括最后的'\0'字符
unsigned char flags; // 1字节,低3bit用来区分是哪一种SDS,高5bit暂未使用
char buf[]; // 真正的字符串
};
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* used */
uint16_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
  • len 已经使用的空间;
  • alloc 字符串数组可分配的最大空间,不包括最后的’\0’字符;
  • flags 低 3bit 用来区分是哪一种SDS,高 5bit 暂未使用;
  • buf[] 真正的字符串。

为什么会有这样的设计?

首先,字符串肯定也是有大小的,有的可能很短,有的可能很长,如果设置为统一的长度,对于短字符串来说太浪费了,所以 Redis 针对不同长度的字符串设置了不同的数据结构。

怎么解决传统字符串中的问题呢

  1. 使用 len 字段直接标识当前字符串用了多少空间,那么用的时候直接读取这么多字节的数据就行了,自然不用关心中间出现的字符;

  2. 使用 len 字段和 alloc 字段,取消了获取字符串长度时的遍历操作,在追加时也可以根据 alloc 是否可以容纳 len + 新字符串的长度 来判断。

有一些细节需要注意:

  • 在 struct 结构体后,有 __attribute__ ((__packed__))的代码,它的意思是告诉编译器把结构体里的成员紧凑排列,原因是,sds *s 的这个指针,指向的其实是 char[] 的内容,如果紧凑排列,s-1 就是 flags 字段的内容,再取低 3bit 就知道当前 SDS 的类型了。
  • 在各个header的定义中最后有一个char buf[]。注意这是一个没有指明长度的字符数组,这是 C 语言中定义字符数组的一种特殊写法,称为柔性数组,只能定义在一个结构体的最后一个字段上。它在这里只是起到一个标记的作用,表示在 flags 字段后面就是一个字符数组,或者说,它指明了紧跟在 flags 字段后面的这个字符数组在结构体中的偏移位置。而程序在为 header 分配的内存的时候,它并不占用内存空间。如果计算 sizeof(struct sdshdr16) 的值,那么结果是 5个字节,其中没有 buf 字段。

看一下获取 SDS 长度的函数

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
#define SDS_TYPE_MASK 7

#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

#define SDS_TYPE_BITS 3 // 给SDS5使用
#define SDS_TYPE_5_LEN(f) ((f)>>SDS_TYPE_BITS) // SDS5的flags字段右移3位

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;
}

flags & 7其实就是取 flags 低 3bit 的值,进而知道 SDS 的类型,接着传入该类型对应的标识,获取结构体里的 len 字段,SDS5则比较特殊,flags 字段既标识类型也标识长度。

那 SDS 的创建函数就很清晰了

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
/*
* 创建新的字符串
* mystring = sdsnewlen("abc",3);
*/
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);

// 当前传的是空字符串,不要用 SDS5 类型,原因是 SDS5 没法标识出剩余容量等,而空字符串接下来大概率要进行拼接操作,直接使用 SDS8
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;

int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags pointer. */

// 新建 SDS 结构,并分配内存空间
sh = s_malloc(hdrlen + initlen + 1);
if (sh == NULL) return NULL;
if (!init)
memset(sh, 0, hdrlen + initlen + 1);

s = (char *) sh + hdrlen; // s 指向内部的 buf 字符串数组

fp = ((unsigned char *) s) - 1;
switch (type) {
case SDS_TYPE_5: {
*fp = type | (initlen << SDS_TYPE_BITS);
break;
}
case SDS_TYPE_8: {
SDS_HDR_VAR(8, s);
sh->len = initlen;
sh->alloc = initlen;
*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)
memcpy(s, init, initlen);

// 给字符串最后一位设置为 '\0',兼容 C 语言原生字符串
s[initlen] = '\0';
return s;
}

/* Create an empty (zero length) sds string. Even in this case the string
* always has an implicit null term. */
sds sdsempty(void) {
return sdsnewlen("", 0);
}

/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}

/* Duplicate an sds string. */
sds sdsdup(const sds s) {
return sdsnewlen(s, sdslen(s));
}

/* Free an sds string. No operation is performed if 's' is NULL. */
void sdsfree(sds s) {
if (s == NULL) return;
s_free((char *) s - sdsHdrSize(s[-1]));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 根据字符串长度大小,返回合适的字符串类型
static inline char sdsReqType(size_t string_size) {
if (string_size < 1 << 5) // string_size < (1 << 5) 即长度 < 2^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
}

以上代码需要注意的点:

  • 新建 SDS 时候,如果字符串长度为0,则选择 SDS8,而不是 SDS5,原因是空字符串接下来很可能进行追加操作,而 SDS5 类型不适合追加数据(会引发内存的重新分配);
  • 新建 SDS 时候,存放数据的字符数组最后一位设置为了’\0’,这样可以兼容原生的 C 语言字符串;
  • alloc 初始化成了 initlen,alloc 不是最开始至今被分配为当前 SDS 类型的最大值(2^8^,2^16^),而是慢慢增加的。

alloc 的变动具体可以参考字符串拼接,sdscatlen函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 字符串拼接函数,拼接中可能由于原类型长度不够导致重新分配空间,因此所有引用必须用调用返回的新指针来替换
// 参数为 要拼接的字符串 t,原字符串 s,t 字符串的长度 len
sds sdscatlen(sds s, const void *t, size_t len) {
// 获取字符串现在的长度
size_t curlen = sdslen(s);

// 根据要追加的长度 len,和现有字符串 s 判断是否需要扩大
s = sdsMakeRoomFor(s, len);
if (s == NULL) return NULL;

// 将要追加的字符串 t 追加到 s 后面
memcpy(s + curlen, t, len);

// 给 sds 设置最新的长度
sdssetlen(s, curlen + len);

// 末尾设置为 '\0'
s[curlen + len] = '\0';
return s;
}

关键步骤就是在 sdsMakeRoomFor 函数

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
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;

// 获取该 SDS 的剩余容量 alloc - len
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;

/* Return ASAP if there is enough space left. */
// 剩余容量够,直接返回
if (avail >= addlen) return s;

len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);

// 记录追加完后的长度
newlen = (len+addlen);

// 新的长度少于 1024 * 1024,扩大为该值的2倍,否则新增 1M
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;

// 获取新长度对应的 SDS 类型
type = sdsReqType(newlen);

/* Don't use type 5: the user is appending to the string and type 5 is
* not able to remember empty space, so sdsMakeRoomFor() must be called
* at every appending operation. */
// 不要使用 SDS5,追加会重新内存分配,不友好
if (type == SDS_TYPE_5) type = SDS_TYPE_8;

// 新类型对应的头长度
hdrlen = sdsHdrSize(type);

// 不需要改变类型
if (oldtype==type) {
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* Since the header size changes, need to move the string forward,
* and can't use realloc */
newsh = s_malloc(hdrlen+newlen+1);
if (newsh == NULL) return NULL;
memcpy((char*)newsh+hdrlen, s, len+1);
s_free(sh);
s = (char*)newsh+hdrlen;
s[-1] = type;
sdssetlen(s, len);
}

// 给新的 SDS 设置新的 alloc
sdssetalloc(s, newlen);
return s;
}

44字节

Redis 在存储字符串时,如果长度不超过 44 字节,会采用 embstr 形式,否则采用 row 形式:

1
2
3
4
5
6
7
8
127.0.0.1:6379> set name zhangsan
OK
127.0.0.1:6379> object encoding name
"embstr"
127.0.0.1:6379> set address zheshiyigechaoguosishisiweidezifuchuandaibiaodeshizhangsandedizhi
OK
127.0.0.1:6379> object encoding address
"raw"

这个 44 字节是哪来的呢?

1
2
3
4
5
6
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len;
uint8_t alloc; // 字符串数组可分配的最大空间,不包括最后的'\0'字符
unsigned char flags;
char buf[];
};

在 sds8 中,结构体长度占 3 字节,字符串由于末尾要有 ‘\0’,所以整体是 4 字节。由于 Redis 的 Value 是用一个叫 RedisObject 的类型包装的,RedisObject 占 16 字节。

cpu cache line 的大小通常是 64B,所以如果字符串长度不大于 44 字节时,redis 会将字符串与 RedisObject 紧密存储,这样 cpu 在读取数据时无需再次访问内存,充分利用 cpu cache。

但是注意,如果对某个字符串进行了 append 等操作,redis 会将其修改为 raw 类型,即使依然不超 44 字节。

1
2
3
4
5
6
7
8
9
10
127.0.0.1:6379> append name feng
(integer) 12
127.0.0.1:6379> get name
"zhangsanfeng"
127.0.0.1:6379> object encoding name
"raw"
127.0.0.1:6379> set name2 zhangsanfeng
OK
127.0.0.1:6379> object encoding name2
"embstr"

小结

Redis 摒弃 C 语言原生字符串,使用 SDS 简单动态字符串,核心是用带元信息的结构体替代纯 char*,完美解决传统字符串二进制不安全、获取长度 / 拼接操作效率低的痛点。

SDS 根据字符串长度划分 5 种类型(SDS5/8/16/32/64),通过len记录已用长度、alloc记录总分配容量,剩余空间 = alloc-len;创建字符串时不预分配冗余空间,alloc初始等于len,拼接扩容时按需倍增分配,兼顾内存节省与操作性能。

其指针指向字符串缓冲区,通过紧凑排列的结构体和flags字段快速识别类型,兼容 C 语言\0结尾规则;结合 RedisObject 实现,≤44 字节的字符串采用 embstr 编码,利用 CPU 缓存提升读取效率,修改后转为 raw 编码。


Redis源码分析之sds篇
https://zhuwenjie0716.github.io/2026/05/11/Redis源码分析之sds篇/
作者
Wenjie Zhu
发布于
2026年5月11日
许可协议