Redis提供了5中数据结构:string、list、hash、set、sorted set
这里先讨论string
没有直接使用 C 语言传统的字符串表示(以空字符结尾的字符数组,以下简称 C 字符串), 而是自己构建了一种名为简单动态字符串(simple dynamic string,sds)的抽象类型,并将 sds 用作 redis 的默认字符串表示。根据传统,C 语言使用长度为 N+1 的字符数组来表示长度为 N 的字符串, 并且字符数组的最后一个元素总是空字符 ‘\0’ 。如下图:
[外链图片转存失败(img-13fNb7zs-1568810556268)(en-resource://database/9844:0)]
因为 C 字符串并不记录自身的长度信息,所以为了获取一个 C 字符串的长度,程序必须遍历整个字符串, 对遇到的每个字符进行计数,直到遇到代表字符串结尾的空字符为止,这个操作的复杂度为 O(N) 。
和 C 字符串不同,因为 sds 在 len 属性中记录了 sds 本身的长度,所以获取一个 sds 长度的复杂度仅为 O(1) 。与此同时,它还通过 alloc 属性记录了自己的总分配空间。下图为 sds 的数据结构:
sds结构中,用len属性记录sds本身的长度,用alloc属性记录分配空间的大小
[外链图片转存失败(img-gTdua2GX-1568810556270)(en-resource://database/9846:0)]
区别于 C 字符串,sds 有自己独特的 header,而且多达 5 种,结构如下:
typedef char *sds;
/* 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 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__ ((__packed__)) sdshdr8 {
uint8_t len; /* used */
uint8_t alloc; /* excluding the header and null terminator */
unsigned char flags; /* 3 lsb of type, 5 unused bits */
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[];
};
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[];
};
之所以有 5 种,是为了能让不同长度的字符串可以使用不同大小的 header。这样,短字符串就能使用较小的 header,从而节省内存。
通过使用 sds 而不是 C 字符串,redis 将获取字符串长度所需的复杂度从 O(N) 降低到了 O(1) ,这是一种以空间换时间的策略,确保了获取字符串长度的工作不会成为 redis 的性能瓶颈。
再来看 sds 的定义,它是简单动态字符串。可动态扩展内存也是它的特性之一。sds 表示的字符串其内容可以修改,也可以追加。在很多语言中字符串会分为 mutable 和 immutable 两种,显然 sds 属于 mutable 类型的。当 sds API 需要对 sds 进行修改时, API 会先检查 sds 的空间是否满足修改所需的要求, 如果不满足的话,API 会自动将 sds 的空间扩展至足以执行修改所需的大小,然后才执行实际的修改操作,所以使用 sds 既不需要手动修改 sds 的空间大小, 也不会出现 C 语言中可能面临的缓冲区溢出问题。
提到字符串变化就不得不提到内存重分配这个问题,对于一个 C 字符串,每次发生变更,程序都总要对保存个 C 字符串的数组进行一次内存重分配操作:
因为内存重分配涉及复杂的算法,并且可能需要执行系统调用,所以它通常是一个比较耗时的操作:
为了避免 C 字符串的这种缺陷,sds 通过未使用空间解除了字符串长度和底层数组长度之间的关联:在 sds 中,buf 数组的长度不一定就是字符数量加一,数组里面可以包含未使用的字节,而这些未使用字节的数量可以由 sds 的 alloc 属性减去len属性得到。通过未使用空间,sds 实现了空间预分配和惰性空间释放两种优化策略。
空间预分配用于优化 sds 的字符串增长操作:当 sds 的 API 对一个 sds 进行修改,并且需要对 sds 进行空间扩展的时候,程序不仅会为 sds 分配修改所必须要的空间,还会为 sds 分配额外的未使用空间,并根据新分配的空间重新定义 sds 的 header。此部分的代码逻辑如下:
/* 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 里面的未使用空间,所以不用担心惰性空间释放策略会造成内存浪费。源码如下:
/* 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]));
}
细想一下,惰性空间释放策略也是空间换时间策略的实现之一,作者对于性能的追求是非常执着的。当然也不是说为了性能,就不在乎内存的使用了,且看下一部分。