文中引用的源码,Redis3.2之前引用的是Redis3.0,Redis3.2及其之后引用的是Redis5.0
sds 简单动态字符串
1. 应用
保存字符串值
缓冲区:AOF缓冲区、客户端状态的输入缓冲区等
2. sds 结构
redis没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,sds)的抽象类型,并将 sds 结构用作为redis 的默认字符串表示。一般地,C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串, 并且字符数组的最后一个元素总是空字符 ‘\0’ 。
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串, 对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N) 。
和 C 字符串不同,因为 sds 在sdshr结构的 len 属性中记录了 sds 本身的长度,所以获取一个 sds 长度的复杂度仅为 O(1) 。并且,保证了redis中存储的字符串是二进制安全的。
但是Redis为了复用部分C的底层函数,字符串的存储依然会在末尾加上’\0’
Redis3.2之前的sdshdr结构定义如下【src\sds.h】:
/*
* 保存字符串对象的结构
*/
struct sdshdr {
// 字符串在buf中实际占用的字节数(不包括'\0')
int len;
// buf 中剩余可用空间的长度
int free;
// 数据空间
char buf[];
};
可以看到,上面这种结构,如果用来存储一个字符的时候,很浪费内存,有效数据才1一个字,额外信息就占用了至少8个字节。
从Redis5.0之后,对sdshdr结构进行了优化。
首先,5种长度的字符串(1字节、2字节、4字节、8字节、小于1字节)就至少需要3位来存储标记(3^ 3=8),一个字节中的剩余5位用来记录字符串实际的长度,可以记录下长度小于32的短字符串,因此,长度小于32的短字符串就可以用如下结构存储:
struct __attribute__ ((__packed__)) sdshdr5 {
unsigned char flags; /* 低3位用来存储类型,高5位用来存储长度 */
char buf[];
};
buf[-1]的值就是flags的值。
Redis3.2及其之后的sdshdr结构定义如下【src\sds.h】:
/* __attribute__ ((__packed__))的作用是告诉编译器不进行内存对齐,一个sdshdr结构体存储字符串要开辟的空间大小为:sizeof(len)+sizeof(alloc)+sizeof(flags)+alloc的值+1*/
/* 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[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* 字符串在buf中实际占用的字节数(不包括'\o')*/
uint8_t alloc; /* 给buf分配的实际空间大小(见后面的空间预分配)减去一个字节的'\0',所以剩余可用空间的大小就等于alloc-len*/
unsigned char flags; /* 低位的3个bit位用来表示结构类型,其余5个bit位未使用 */
char buf[]; /* 保存字符串值的柔性数组(sizeof结构体大小的时候,该数组不包含在计算大小中) */
};
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[];
};
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[];
};
如下函数是根据字符串长度获取字符串的类型
【Redis5.0 src\sds.c】
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
}
【Redis5.0 src\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
3. 内存分配策略
再来看 sds 的定义,它是简单动态字符串。可动态扩展内存也是它的特性之一。sds 表示的字符串其内容可以修改,也可以追加。在很多语言中字符串会分为 mutable 和 immutable 两种,显然 sds 属于 mutable 类型的。当 sds API 需要对 sds 进行修改时, API 会先检查 sds 的空间是否满足修改所需的要求, 如果不满足的话,API 会自动将 sds 的空间扩展至足以执行修改所需的大小,然后才执行实际的修改操作,所以使用 sds 既不需要手动修改 sds 的空间大小, 也不会出现 C 语言中可能面临的缓冲区溢出问题。
提到字符串变化就不得不提到内存重分配这个问题,对于一个 C 字符串,每次发生变更,程序都总要对保存的 C 字符串的数组进行一次内存重分配操作:
- 如果程序执行的是增长字符串的操作,比如拼接操作(append),那么在执行这个操作之前, 程序需要先通过内存重分配来扩展底层数组的空间大小 —— 如果忘了这一步就会产生缓冲区溢出。
- 如果程序执行的是缩短字符串的操作,比如截断操作(trim),那么在执行这个操作之后, 程序需要通过内存重分配来释放字符串不再使用的那部分空间 —— 如果忘了这一步就会产生内存泄漏。
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:
- 在一般程序中, 如果修改字符串长度的情况不太常出现, 那么每次修改都执行一次内存重分配是可以接受的。
- 但是 redis 作为一个内存数据库, 经常被用于速度要求严苛、数据被频繁修改的场合, 如果每次修改字符串的长度都需要执行一次内存重分配的话, 那么光是执行内存重分配的时间就会占去修改字符串所用时间的一大部分, 如果这种修改频繁地发生的话, 可能还会对性能造成影响。
为了避免 C 字符串的这种缺陷,sds 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 sds 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些未使用字节的数量可以由 sds 的 alloc 属性减去len属性得到。通过未使用空间,sds 实现了空间预分配和惰性空间释放两种优化策略。
空间预分配
空间预分配用于优化 sds 的字符串增长操作:当 sds 的 API 对一个 sds 进行修改,并且需要对 sds 进行空间扩展的时候,程序不仅会为 sds 分配修改所必须要的空间,还会为 sds 分配额外的未使用空间,并根据新分配的空间重新定义 sds 的 header。此部分的代码逻辑如下:
【src\sds.c sdsMakeRoomFor函数(用于重新分配空间)中的部分代码】
/* Return ASAP if there is enough space left. */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
newlen = (len+addlen);
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
type = sdsReqType(newlen);
简单来说就是:
如果对 sds 进行修改之后,sds 的长度(也即是 len 属性的值)将小于 1 MB ,那么程序分配和 len 属性同样大小的未使用空间,这时 SDSsdsalloc 属性的值将正好为 len 属性的值的两倍。举个例子, 如果进行修改之后,sds 的 len 将变成 13 字节,那么程序也会分配 13 字节的未使用空间,alloc 属性将变成 13字节,sds 的 buf 数组的实际长度将变成 13 + 13 + 1 = 27 字节(额外的一字节用于保存空字符)。
如果对 sds 进行修改之后,sds 的长度将大于等于 1 MB ,那么程序会分配 1 MB 的未使用空间。举个例子, 如果进行修改之后,sds 的 len 将变成 30 MB,那么程序会分配 1 MB 的未使用空间,alloc 属性将变成 31 MB ,sds 的 buf 数组的实际长度将为 30 MB + 1 MB + 1 byte。
通过空间预分配策略,Redis 可以减少连续执行字符串增长操作所需的内存重分配次数。通过这种空间换时间的预分配策略,sds 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。内存预分配策略仅在 sds 扩展的时候才触发,而新创建的 sds 长度和 C 字符串一致,是长度 + 1byte。
惰性空间释放
惰性空间释放用于优化 sds 的字符串缩短操作:当 sds 的 API 需要缩短 sds 保存的字符串时, 程序并不立即使用内存重分配来回收缩短后多出来的字节,而是使用 free 属性将这些字节的数量记录起来, 并等待将来使用。
通过惰性空间释放策略,sds 避免了缩短字符串时所需的内存重分配操作, 并为将来可能有的增长操作提供了优化。与此同时,sds 也提供了相应的 API sdsfree,让我们可以在有需要时, 真正地释放 sds 里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。
SDS和C字符串的区别
(1)获取字符串长度的时间复杂度
C字符串不记录字符串本身是长度,因此需要遍历字符串才能得到字符串的长度,时间复杂度为O(N)。SDS字符串用属性len记录了字符串的长度,因此获取字符串长度的时间复杂度为O(1)。
(2)缓冲区溢出
如果对C字符串使用strcat进行拼接,如果没有提前对字符串分配足够的内存,则会导致缓冲区溢出。但是SDS会提前对字符串所需要的内存进行检测。
(3)修改字符串时的内存重分配
当修改C字符串的时候,无论是增长/缩短字符串,都会通过内存重分配来扩展或者释放内存,否则就会导致缓冲区溢出或者内存泄漏。SDS使用了上面提到的内存预分配和惰性空间释放保证了内存不会溢出,并且性能也得到提升。
(4)二进制安全性
如果一个字符串保存的时候是什么样子,输出的时候也是什么样子,则称为二进制安全的,C字符串以’\0’作为字符串的结尾标识,会造成从中间截断字符串的情况。SDS使用len属性记录字符串的长度,因此保存二进制数据是安全的。
通过使用 sds 而不是 C 字符串,redis 将获取字符串长度所需的复杂度从 O(N) 降低到了 O(1) ,这是一种以空间换时间的策略,确保了获取字符串长度的工作不会成为 redis 的性能瓶颈。
部分源码解读1
通过C形式的字符串生成sdshdr结构体类型的变量,也就是Redis底层存储“helloworld!”时的步骤。
【Redis5.0 src\sds.c】
/* Create a new sds string starting from a null terminated C string. */
sds sdsnew(const char *init) {
//如果初始字符串为NULL,则initlen值为0
size_t initlen = (init == NULL) ? 0 : strlen(init);
return sdsnewlen(init, initlen);
}
这里还要注意sds的类型定义如下,是char *类型的重命名。这个类型主要用来指向sdshdr结构体中的buf。
【Redis5.0 src\sds.h】
typedef char *sds;
【Redis5.0 src\sds.c】
/* Create a new sds string with the content specified by the 'init' pointer
* and 'initlen'.
* If NULL is used for 'init' the string is initialized with zero bytes.
* If SDS_NOINIT is used, the buffer is left uninitialized;
*
* The string is always null-termined (all the sds strings are, always) so
* even if you create an sds string with:
*
* mystring = sdsnewlen("abc",3);
*
* You can print the string with printf() as there is an implicit \0 at the
* end of the string. However the string is binary safe and can contain
* \0 characters in the middle, as the length is stored in the sds header. */
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;
//根据初始字符串的类型获取sdshdr结构体的大小
int hdrlen = sdsHdrSize(type);
//指向sdshdr结构体中的flag
unsigned char *fp; /* flags pointer. */
//为结构体分配空间(第一次分配空间不需要预留空间)
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;
//s指向字符串数组的首地址(也就是sdshdr结构体buf变量的首地址)
s = (char*)sh+hdrlen;
//s地址减去一个字节得到存储flag的首地址
fp = ((unsigned char*)s)-1;
//给sdshdr结构体中的len,alloc,flag变量赋值
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;
}
}
//如果字符串长度不为0且初始字符串不为空,则用初始字符串填充buf数组空间
if (initlen && init)
memcpy(s, init, initlen);
s[initlen] = '\0';
return s;
}
这里新建一个sds,返回值的首地址执行buf而不是整个sds的首地址的好处有:
- 我们可以把sds传递给任何使用char *为参数的函数,包括一些库函数(strcmp,strcat等),而不用通过结构体获取地址(sdshdr->buf)再传递。
- 可以直接访问单个字符printf("%c %c\n", sds[0], sds[1]);如果使用mysds则需要每次获取下buf的地址mysds->buf[1]再访问。
- 分配的空间地址连续,对高速缓存命中率更加友好。即一次连续分配Header+String+Null,因此对于一个sds,它的各个部分总是内存连续的,但是上面的mysds通常需要两次malloc,如下所示:
struct mysds *sds = (struct mysds *) malloc(sizeof(struct mysds));
sds->buf = (char *) malloc(SIZE);
//这两次malloc不能保证sds和buf内存地址是连续的
部分源码解读2
【Redis5.0 src\sds.c】
sdscpy()的作用是将给的C字符串复制到原sds里,覆盖原sds里的原字符串
/* Like sdscpylen() but 't' must be a null-termined string so that the length
* of the string is obtained with strlen(). */
sds sdscpy(sds s, const char *t) {
return sdscpylen(s, t, strlen(t));
}
/* Destructively modify the sds string 's' to hold the specified binary
* safe string pointed by 't' of length 'len' bytes. */
sds sdscpylen(sds s, const char *t, size_t len) {
//如果给buf分配的大小小于新字符串的长度,则进行空间重分配
if (sdsalloc(s) < len) {
s = sdsMakeRoomFor(s,len-sdslen(s));
if (s == NULL) return NULL;
}
//如果给buf分配的大小大于或等于新字符串的长度,则直接将新字符串存入buf中
memcpy(s, t, len);
s[len] = '\0';
sdssetlen(s, len);
return s;
}
【Redis5.0 src\sds.c】
/* Enlarge the free space at the end of the sds string so that the caller
* is sure that after calling this function can overwrite up to addlen
* bytes after the end of the string, plus one more byte for nul term.
*
* Note: this does not change the *length* of the sds string as returned
* by sdslen(), but only the free buffer space we have. */
//s为sdshdr结构体变量的首地址,addlen为需要扩展多大字节的空间
sds sdsMakeRoomFor(sds s, size_t addlen) {
void *sh, *newsh;
//获取buf可用空间:avail=alloc-len
size_t avail = sdsavail(s);
size_t len, newlen;
char type, oldtype = s[-1] & SDS_TYPE_MASK;
int hdrlen;
/* (1)如果buf剩余可用空间大于或者等于追加字符串的长度,则不用扩容 */
if (avail >= addlen) return s;
len = sdslen(s);
sh = (char*)s-sdsHdrSize(oldtype);
//(2)新的buf空间等于原字符串的大小加上需要扩展的空间大小(没有计算'\0')
newlen = (len+addlen);
//预分配空间策略
if (newlen < SDS_MAX_PREALLOC)
newlen *= 2;
else
newlen += SDS_MAX_PREALLOC;
// 计算新字符串所需sdshdr结构体的类型,即flags值
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. */
// 如果字符串是SDS_TYPE_5,则转为SDS_TYPE_8,因为SDS_TYPE_5不支持扩容
if (type == SDS_TYPE_5) type = SDS_TYPE_8;
//sdshdr结构体大小:sizeof(len)+sizeof(alloc)+sizeof(flags)
hdrlen = sdsHdrSize(type);
//(3)如果新类型等于就的类型,即字符串长度都在一个范围内,则用realloc在原内存上追加,否则用malloc新开辟空间,并释放旧的空间
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);
}
//设置alloc的值
sdssetalloc(s, newlen);
return s;
}
小结
- 简单动态字符串将字符串按照长度分为5种大类型,不同长度范围的字符串采用不同的结构体存储以节省空间。
- Redis采用空间预分配和惰性空间释放以提高效率