本文参考源码为
redis6.2
Redis 只会使用 C 字符串作为字面量,在大多数情况下,Redis 使用SDS(Simple Dynamic String,简单动态字符串
)作为字符串表示。
SDS 的出现是为了解决 C 字符串在 Redis 三高
场景下的不足,分别有 安全性
、效率
以及功能
等方面。
相信大家都学过 C 语言,对 C 中的字符串已经比较清楚了;在 C 中,如果我们要存储一个字符串,可以先定义一个字符数组 char[] 或者 char *p ,然后将字符串挨个存进去。
这种方式存在什么问题?
当我们想要获取字符串的实际长度时,需要遍历整个字符串 char[];你可能觉得单次操作性能应该还行,然而 redis 服务的是以万+ 的QPS,这种操作就有些问题了。
显然,直接用 C 中的字符串不太现实,但可以在其之上,做一层数据结构封装,一款适用于高并发场景下,能高效的解决 C 字符串缺陷的特殊结构,这就是 SDS。
SDS 既然是字符串,那么首先需要一个字符串指针
;为了方便上层的接口调用,该结构还需要记录一些关键信息,如当前数据长度
和剩余容量
等,数据结构如下:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
其中,len 表示当前字符串长度、free 表示剩余可用空间、buf[] 存储实际数据。
值得注意的是
,以上结构是 redis3.2
版本之前的结构,3.2 已经做出了些许调整;至于原因,咱们继续往后看 ~
从上面可以看到,sds
与 普通 C 字符串
的区别就是新增了一层数据结构包装了原始的字符数组。
细心的你可能已经注意到了,上面的结构体命名为 sdshdr
,它和 sds
有什么关系?
从源码我们找到 sds 的定义为:
typedef char *sds;
也就是说,sds 是一个指向字符的指针;实际上,sds 就指向了我们 sdshdr 结构 buf[] 数据字段,这样做的好处是,能更好的兼容 C 字符,从而使用其相关库函数。
SDS 遵循 C 字符串以空字符
结尾的惯例,保存空字符的 1 字节空间不计算在 SDS 的 len 属性里面,并且为空字符分配额外的 1 字节空间,以及添加空字符到字符串末尾等操作,都是由 SDS 函数自动完成的,所以这个空字符对于 SDS 的使用者来说是完全透明的。
遵循空字符结尾这一惯例的好处是,SDS 可以直接重用一部分 C 字符串函数库里面的函数。
你可能有疑问,如何通过 sds 指针拿到 sdshdr 其他字段的信息?
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;
}
这是拿 len 字段信息,当然,拿去其他字段以及修改字段值也都是类似的方法。具体原理,咱们接着往下看~
SDS与C字符串的区别:
根据传统,C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串,并且字符数组的最后一个元素总是空字符'\0'
。
C 语言使用的这种简单的字符串表示方式,并不能满足 Redis 对字符串在安全性
、效率
以及功能
方面的要求
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串,对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为O(N)
。
和 C 字符串不同,因为 SDS 在 len 属性中记录了 SDS 本身的长度,所以获取一个 SDS 长度的复杂度仅为O(1)
。
设置和更新 SDS 长度的工作是由 SDS 的 API 在执行时自动完成的,使用 SDS 无须进行任何手动修改长度的工作。
通过使用 SDS 而不是 C 字符串,Redis 将获取字符串长度所需的复杂度从 O(N)降低到了O(1),这确保了获取字符串长度的工作不会成为 Redis 的性能瓶颈。
例如,因为字符串键在底层使用 SDS 来实现,所以即使我们对一个非常长的字符串键反复执行 STRLEN 命令,也不会对系统性能造成任何影响,因为 STRLEN 命令的复杂度仅为O(1)
除了获取字符串长度的复杂度高之外,C 字符串不记录自身长度带来的另一个问题是容易造成缓冲区溢出
(buffer overflow)。
例子:字符串拼接:
与 C 字符串不同,SDS 的空间分配策略完全杜绝了发生缓冲区溢出的可能性:当 SDS API 需要对 SDS 进行修改时,API 会先检查 SDS 的空间是否满足修改所需的要求,如果不满足的话,API 会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出问题。
举个例子,SDS 的 API 里面也有一个用于执行拼接操作的 sdscat 函数,它可以将一个 C 字符串拼接到给定 SDS 所保存的字符串的后面,但是在执行拼接操作之前
,sdscat 会先检查给定 SDS 的空间是否足够,如果不够的话,sdscat 就会先扩展 SDS 的空间,然后才执行拼接操作。
因为 C 字符串并不记录自身的长度,所以对于一个包含了 N 个字符的 C 字符串来说,这个 C 字符串的底层实现总是一个 N + 1 个字符长的数组(额外的一个字符空间用于保存空字符)。
因为 C 字符串的长度和底层数组的长度之间存在着这种关联性,所以每次增长或者缩短一个 C 字符串,程序都总要对保存这个 C 字符串的数组进行一次内存重分配
操作:
增长字符串
的操作,比如拼接操作(append),那么在执行这个操作之前,程序需要先通过内存重分配来扩展底层数组的空间大小——如果忘了这一步就会产生缓冲区溢出
。缩短字符串
的操作,比如截断操作(trim),那么在执行这个操作之后,程序需要通过内存重分配来释放字符串不再使用的那部分空间——如果忘了这一步就会产生内存泄漏
。因为内存重分配涉及复杂的算法,并且可能需要执行系统调用
,所以它通常是一个比较耗时
的操作:
为了避免 C 字符串的这种缺陷,SDS 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 SDS 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些字节的数量就由 SDS 的 free(即 alloc - len) 属性记录。
通过未使用空间,SDS 实现了空间预分配
和惰性空间释放
两种优化策略。
1)空间预分配:
空间预分配用于优化 SDS 的字符串增长操作:当 SDS 的 API 对一个 SDS 进行修改,并且需要对 SDS 进行空间扩展的时候,程序不仅会为SDS 分配修改所必须要的空间,还会为 SDS 分配额外的未使用空间
。
其中,额外分配的未使用空间数量由以下规则决定:
2 * newlen
,即 free = len。举个例子,如果进行修改之后,SDS 的 len 将变成13字节,那么程序也会分配 13 字节的未使用空间,SDS的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。1MB
的未使用空间。举个例子,如果进行修改之后,SDS 的 len 变成 30MB,那么程序会分配 1MB 的未使用空间,SDS 的 buf 数组的实际长度将为 30MB + 1MB + 1byte。源码 sdsMakeRoomFor
中的代码片段:
reqlen = newlen = (len+addlen);
// SDS_MAX_PREALLOC = 1024 * 1024,即 1MB
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
通过空间预分配策略,Redis可以减少连续执行字符串增长操作所需的内存重分配次数。
2)惰性空间释放:
惰性空间释放用于优化 SDS 的字符串缩短
操作:当 SDS 的 API 需要缩短 SDS 保存的字符串时,程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来,并等待将来使用。
空间释放时机?
在源码 object.c
中:
void trimStringObjectIfNeeded(robj *o) {
if (o->encoding == OBJ_ENCODING_RAW &&
sdsavail(o->ptr) > sdslen(o->ptr)/10)
{
o->ptr = sdsRemoveFreeSpace(o->ptr);
}
}
可以看出,这里释放 未使用空间(free) 的条件是,free > 10% * len
,这样的目的就是为了防止多次 apend 这样的操作导致的多次 realloc
调用。
再往上溯源,我们发现,最终调用之一是 t_string.c
中解析并处理客户端命令时触发该方法调用;也就说,释放多余空间
这个操作也是由客户端触发的,所以叫惰性
。
需要注意的是,redis3.2
之前的版本使用字段叫做 free
,表示未使用空间长度;而 redis3.2 以及之后的版本已经改成了 alloc
字段,表示分配的总空间大小;为了表述方便,文中仍使用 free 字段表示未使用空间。
什么是二进制安全?通俗地讲,C语言中,用“\0”表示字符串的结束,如果字符串中本身就有“\0”字符,字符串就会被截断,即非二进制安全;若通过某种机制,保证读写字符串时不损害其内容,则是二进制安全。
C 字符串中的字符必须符合某种编码(比如ASCII),并且除了字符串的末尾之外,字符串里面不能包含空字符
,否则最先被程序读入的空字符将被误认为是字符串结尾
,这些限制使得 C 字符串只能保存文本数据,而不能保存像图片、音频、视频、压缩文件这样的二进制数据。
虽然数据库一般用于保存文本数据,但使用数据库来保存二进制数据的场景也不少见,因此,为了确保 Redis 可以适用于各种不同的使用场景,SDS 的 API 都是二进制安全
的(binary-safe),所有SDS API 都会以处理二进制的方式来处理 SDS 存放在 buf 数组里的数据,程序不会对其中的数据做任何限制、过滤、或者假设,数据在写入时是什么样的,它被读取时就是什么样。
这也是我们将 SDS 的 buf 属性称为字节数组的原因——Redis 不是用这个数组来保存字符,而是用它来保存一系列二进制数据。
例如,使用 SDS 来保存之前提到的特殊数据格式就没有任何问题,因为 SDS 使用 len 属性的值而不是空字符来判断字符串是否结束
通过使用二进制安全的 SDS,而不是 C 字符串,使得 Redis 不仅可以保存文本数据,还可以保存任意格式的二进制数据。
兼容部分C字符串函数
虽然 SDS 的 API 都是二进制安全的,但它们一样遵循 C 字符串以空字符结尾的惯例:这些 API 总会将 SDS 保存的数据的末尾设置为空字符,并且总会在为 buf 数组分配空间时多分配一个字节来容纳这个空字符,这是为了让那些保存文本数据的 SDS 可以重用一部分<string.h>库定义的函数。
我们知道,redis 中的基本数据结构用于支持上层数据类型实现;自然,SDS 也不例外,比如,上层数据类型 string
的底层实现中对 SDS 的使用就非常广泛。
另外,在 Redis 内部,SDS 应用也十分广泛;比如,内部异常信息的封装、数据元素使用 SDS 存储等等。
因此,SDS 对于 redis 来说,是最基本的数据结构之一,值得深入研究。
前面我们聊到, redis3.2 版本之前 sds 的数据结构长这样:
struct sdshdr {
unsigned int len;
unsigned int free;
char buf[];
};
我们来看看 redis3.2 及之后的版本改进之路
不同长度的字符串是否有必要占用相同大小的头部?
一个 int 占4字节,在实际应用中,存放于 Redis 中的字符串往往没有这么长,每个字符串都用4字节存储未免太浪费空间了。我们考虑三种情况:
按照这个改进,我们看看需要进一步解决的问题:
问题1:这三种情况如何区分?
问题2:对于超短字符串,能否小于 len + free = 2 字节?
针对问题1,我们可以考虑使用最小一个字节的 flags
字段标识,并且将其放置在 buf[] 之前,这个可以通过 buf[] 的偏移量 减一
即可快速定位。
针对问题2,由于 len 已经是最小的1字节了,再压缩只能考虑用位来存储长度了。
结合两个问题,5 种类型(长度 1 字节、2 字节、4 字节、8 字节、小于1 字节)的 SDS 至少要用 3 位来存储类型(2^3 = 8),1 个字节 8 位,剩余的 5 位存储长度,可以满足长度小于 32 的短字符串。我们用如下结构来存储长度小于 32 的短字符串:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 前3位存储类型,后5位存储长度 */
char buf[]; /* 存储实际数据 */
};
而长度大于31的字符串,1个字节依然存不下。我们按之前的思路,将len和free单独存放。sdshdr8、sdshdr16、sdshdr32 和 sdshdr64 的结构相同,sdshdr16 结构如图:
struct __attribute__ ((__packed__)) sdshdr16 {
uint16_t len; /* 已使用长度,2字节 */
uint16_t alloc; /* 总长度,2字节,不包括头部和空结束符 */
unsigned char flags; /* 低三位存储类型,高5位预留 */
char buf[];
};
值得注意的是
,redis3.2 之前的版本使用的是 free 字段,而之后的版本已经改成了 alloc 字段。
其他结构定义如下:
/* 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__)) sdshdr8 {
uint8_t len; /* 已使用长度,1字节 */
uint8_t alloc; /* 总长度,1字节,不包括头部和空结束符 */
unsigned char flags; /* 低三位存储类型,高5位预留 */
char buf[]; /* 存放实际数据 */
};
struct __attribute__ ((__packed__)) sdshdr32 {
uint32_t len; /* used */
uint32_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr64 {
uint64_t len; /* used */
uint64_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
char buf[];
};
源码中的__attribute__((packed))需要重点关注
它的作用是,告诉编译器取消结构体在编译过程中的优化对齐,按照实际占用字节数进行对齐
。
一般情况下,结构体会按其所有变量大小的最小公倍数做字节对齐,而用 packed 修饰后,结构体则变为按1字节对齐。
以 sdshdr32 为例,修饰前按4字节对齐大小为 12(4×3) 字节;修饰后按1字节对齐,注意 buf 是个 char 类型的柔性数组,地址连续,始终在 flags 之后。packed 修饰前后示意如图所示:
这样做有以下两个好处:
// 创建 sds
sds sdsnew(const char *init);
sds sdsnewlen(const void *init, size_t initlen);
// 创建一个空字符串,长度为0,内容为 ""
sds sdsempty(void);
// 复制给定的 sds
sds sdsdup(const sds s);
// 释放 sds 字符串
void sdsfree(sds s);
// 将 sds 扩容到指定长度,并用 0 填充
sds sdsgrowzero(sds s, size_t len);
// 拼接字符串
sds sdscatsds(sds s, const sds t);
sds sdscatlen(sds s, const void *t, size_t len);
// 将字符串复制到 sds 中
sds sdscpy(sds s, const char *t);
sds sdscpylen(sds s, const char *t, size_t len);
// 从 sds 两端清除所有给定的字符
sds sdstrim(sds s, const char *cset);
// 更新 sds 相关值
void sdsupdatelen(sds s);
// 与 sdsMakeRoomFor 正好相反,对空闲过多的 sds 做缩容
sds sdsRemoveFreeSpace(sds s);
void sdsclear(sds s);
// 比较给定的两 sds 实际大小
int sdscmp(const sds s1, const sds s2);
// 按给定的分隔符对 sds 进行切分
sds *sdssplitlen(const char *s, ssize_t len, const char *sep, int seplen, int *count);
1)SDS 暴露给上层的是指向数组 buf 的指针。
2)读操作的复杂度多为 O(1),直接读取成员变量;涉及修改的写操作,则可能会触发扩容。
Redis通过 sdsnewlen 函数创建 SDS。在函数中会根据字符串长度选择合适的类型,初始化完相应的统计值后,返回指向字符串内容的指针,根据字符串长度选择不同的类型:
sds sdsnewlen(const void *init, size_t initlen) {
void *sh;
sds s;
char type = sdsReqType(initlen);
/* Empty strings are usually created in order to append. Use type 8
* since type 5 is not good at this. */
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8;
int hdrlen = sdsHdrSize(type);
unsigned char *fp; /* flags 指针,并以此给flags赋值 */
sh = s_malloc(hdrlen+initlen+1);
if (init==SDS_NOINIT)
init = NULL;
else if (!init)
memset(sh, 0, hdrlen+initlen+1);
if (sh == NULL) return NULL;
// 这里实际指向 buf[]
s = (char*)sh+hdrlen;
// buf[] 往前一个字节就是 flags 地址
fp = ((unsigned char*)s)-1;
// 给 flags, len, alloc 赋值
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);
s[initlen] = '\0';
return s;
}
整体上比较简单,先通过 malloc
分配空间,再进行相关的初始化赋值
。
值得注意的是
:对于 sdshdr5 类型,在创建空字符串
时会强制转换为 sdshdr8。原因是创建空字符串后,其内容可能会频繁更新而引发扩容,故创建时直接创建为 sdshdr8。
结合我们实际的操作场景就是,先创建空字符串,然后不断的 append 字符串,这种提前扩容,就是一种预判行为。
另外,方法的返回结构为 sds
,其定义为:
typedef char *sds;
也就是说,sds
是指向字符的指针(即首地址,在 C++
这种就表示一个字符串);在这里,指向我们 sdshdr
结构中的 buf[]。
这样设计的好处在于直接对上层提供了字符串内容指针,兼容了部分 C 函数,且通过偏移能迅速定位到 SDS 结构体的各处成员变量。
SDS 提供了直接释放内存的方法——sdsfree,该方法通过对 s 的偏移,可定位到 SDS 结构体的首部,然后调用 s_free 释放内存:
void sdsfree(sds s) {
if (s == NULL) return;
s_free((char*)s-sdsHdrSize(s[-1]));
}
为了优化性能(减少申请内存的开销), SDS 提供了不直接释放内存,而是通过重置统计值达到清空目的的方法——sdsclear。该方法仅将 SDS 的 len 归零,此处已存在的 buf 并没有真正被清除,新的数据可以覆盖写,而不用重新申请内存
。
void sdsclear(sds s) {
sdssetlen(s, 0);
s[0] = '\0';
}
1)sdscatsds
sds sdscatsds(sds s, const sds t) {
return sdscatlen(s, t, sdslen(t));
}
sdscatsds
是暴露给上层的方法,其最终调用的是 sdscatlen
。由于其中可能涉及 SDS 的扩容,sdscatlen 中调用 sdsMakeRoomFor
对带拼接的字符串 s
容量做检查,若无须扩容则直接返回 s;若需要扩容,则返回扩容好的新字符串 s。
函数中的 len、curlen 等长度值是不含结束符的,而拼接时用 memcpy 将两个字符串拼接在一起,指定了相关长度,故该过程保证了二进制安全,最后需要加上结束符。
2)sdscatlen
sds sdscatlen(sds s, const void *t, size_t len) {
size_t curlen = sdslen(s);
s = sdsMakeRoomFor(s,len);
if (s == NULL) return NULL;
memcpy(s+curlen, t, len);
sdssetlen(s, curlen+len);
s[curlen+len] = '\0';
return s;
}
3)sdsMakeRoomFor
如果有必要的话,会将空间进一步扩容
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
// 拿到可用长度,即 sh->alloc - sh->len
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* 空间足够就直接返回 */
if (avail >= addlen) return s;
len = sdslen(s);
// 定位到 sdshdr 首地址
sh = (char*)s-sdsHdrSize(oldtype);
// 拼接后的新长度
newlen = (len+addlen);
// #define SDS_MAX_PREALLOC (1024*1024)
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
type = sdsReqType(newlen);
/* 不使用 sdshdr5(不支持扩容), 因为用户持续的 apend 操作可能引起频繁的扩容*/
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
hdrlen = sdsHdrSize(type);
if (oldtype==type) {
// 注意,这里用的是 s_realloc,仅将 buf[] 空间扩大了
newsh = s_realloc(sh, hdrlen+newlen+1);
if (newsh == NULL) return NULL;
s = (char*)newsh+hdrlen;
} else {
/* 因为 header 大小已经改变, 字符串需要移动,不能使用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);
}
sdssetalloc(s, newlen);
return s;
}
Redis 的 sds 中有如下扩容策略
。
Redis 只会使用 C 字符串作为字面量,在大多数情况下,Redis 使用SDS(Simple Dynamic String,简单动态字符串)作为字符串表示。
比起 C 字符串,SDS 具有以下优点:
相关参考:
Redis 5设计与源码分析 【陈雷】
Redis 设计与实现 【黄健宏】