Redis 的底层数据结构(SDS和链表)
Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它可以用作数据库、缓存和消息中间件。可能几乎所有的线上项目都会使用到 Redis,无论你是做缓存、或是用作消息中间件,用起来很简单方便,但可能大多数人并没有去深入底层的看看 Redis 的一些策略实现等等细节。
正好最近也在项目开发中遇到一些 Redis 相关的 Bug,由于不熟悉底层的一些实现,较为费劲的解决了,所以打算开这么一个系列,记录一下对于 Redis 底层的一些结构、策略的学习笔记。
第一部分我们打算从 Redis 的五种数据结构以及对象类型的实现开始,主要涉及内容如下,你也可以通过文末给出 GitHub 仓库下载对应的思维导图。
本篇文章打算介绍 SDS 简单动态字符串和双端链表这两种数据结构。
一、SDS 简单动态字符串
大家都知道 Redis 是由 C 语言作为底层编程语言实现的,而 C 语言中也是有字符串这种数据结构的,它是一个字符数组并且是一个以空字符结尾的字符数组,这种结构对于 Redis 而言过于简单了,于是 Redis 自行实现了 SDS 这种简单动态字符串结构,它其实和 Java 中 ArrayList 的实现是很类似的。
Redis 源代码中 sds.h 文件下,有五种 sdshdr,它们分别是:
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[];
};
其中,sdshdr5 的注释表明,sdshdr5 is never used。sdshdr5 这种数据结构一般用于存储长度小于 32 个字符的字符串,但现在也已经不再使用这种结构了,再小长度的字符串也建议使用 sdshdr8 进行存储,因为 sdshdr5 少了两个关键字段,因此不具备动态扩容操作,一旦预分配的内存空间使用完,就需要重新分配内存并完成数据的复制迁移,在实际的生产环境中对于性能的影响还是很大的,所以进行了一个抛弃,但其实有些比较小的键依然会采用这种结构存储。
关于 sdshdr5 我们不再多说,我们看其他四种结构的各个字段,len 字段表示当前字符串总长度,也即当前字符串已使用内存大小,alloc 表示为当前字符串分配的总内存大小(不包括len以及flags字段本身分配的内存),因为每一个结构在预分配的时候都会多分配一段内存空间,主要是为了方便以后的扩容。flags 的低三位表示当前 sds 的类型,高五位无用。低三位取值如下:
#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
实际上,redis 对 sdshdr 内存分配是禁用内存对齐的,也就是说每个字段分配的内存地址是紧紧排列在一起的, 所以 redis 中字符串参数的传递直接使用 char* 指针。
可能有人会疑问,仅仅通过一个 char 指针如何确定当前字符串的类型,其实由于 sdshdr 内存分配禁止内存对齐,所以 sds[-1] 其实指向的就是 flags 字段的内存地址,通过 flags 字段又可以得到当前 sds 属于哪种类型,进而可以读取头部字段确定 sds 的相关属性。
接下来我们讲讲 sdshdr 相对于传统的 C 语言字符串,性能的提升在哪,以及具有哪些便捷的点。
首先,对于传统的 C 字符串,我想要获取字符串的长度,至少需要 O(n) 遍历一遍数组才行,而我们 sds 只需要 O(1) 的取 len 字段的值即可。
其次,也是非常重要的一个设计,如果我们初始分配了一个字符串对象,那么如果我要在这个字符串后面追加内容的话,限制于数组的长度一经初始化是不能修改的,我们至少需要分配一个足够大的数组,然后将原先的字符串进行一个拷贝。
sdshdr 每次为一个 sds 分配内存的时候都会额外分配一部分暂不使用的内存空间,一般额外的内存会等同于当前字符串占用的内存大小,如果超过 1MB,那么额外空间的内存大小就是 1MB。每当执行 sdscat 这种方法的时候,程序会用 alloc-len 比较下剩下的空余内存是否足够分配追加的内容,如果不够自然触发内存重分配,而如果剩余未使用内存空间足够放下,那么将直接进行分配,无需内存重分配。
通过这种预分配策略, SDS 将连续增长 N 次字符串所需的内存重分配次数从必定 N 次降低为最多 N 次。
最后,对于常规的 C 语言字符串,它通过判断当前字符是否是空字符来决定字符串的结尾,所以就要求你的字符串中不能包含甚至一个空字符,否则空字符后面的字符都不能作为有效字符被读取。而对于某些具有特殊格式要求的,需要使用空字符进行分隔作用的,那么传统的 C 字符串就无法存储了,而我们的 sds 不是通过空字符判断字符串结尾,而是通过 len 字段的值判断字符串的结尾,所以说,sds 还具备二进制安全这个特性,即它可以安全的存储具备特殊格式要求的二进制数据。
关于 sds 我们就简单说到这,它是一种改良版的 C 字符串,兼容 C 语言中既有的函数 API,也通过一些手段提升了某些操作的性能,值得大家借鉴。
二、链表
链表这种数据结构相信大家也不陌生,有很多类型,比如单向链表,双向链表,循环链表等,链表相对于数组来说,一是不需要连续的内存块地址,二是删除和插入的时间复杂度是 O(1) 级别的,非常的高效,但比不上数组的随机访问查询方式。
一样的那句话,没有最好的数据结构,只有恰到好处的数据结构,比如我们后面要介绍的更高层次的数据结构,字典,它的底层其实就依赖的链表规避哈希冲突,具体的我们后面再说。
redis 中借助 C 语言实现了一个双向链表结构:
typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;
pre 指针指向前一个节点,next 指针指向后一个节点,value 指向当前节点对应的数据对象。我盗一张图描述整个串联起来的链表结构:
虽然我通过链表的第一个头节点就可以遍历整个链表,但在 redis 向上封装了一层结构,专门用于表示一个链表结构:
typedef struct list {
listNode *head;
listNode *tail;
void *(*dup)(void *ptr);
void (*free)(void *ptr);
int (*match)(void *ptr, void *key);
unsigned long len;
} list;
head 指向链表的头节点,tail 指向链表的尾节点,dup 函数用于链表转移复制时对节点 value 拷贝的一个实现,一般来说用等于号足以,但某些特殊情况下可能会用到节点转移函数,默认可以给这个函数赋值 NULL 即表示使用等于号进行节点转移。free 函数用于释放一个节点所占用的内存空间,默认赋值 NULL 的话,即使用 redis 自带的 zfree 函数进行内存空间释放,我们也可以来看一下这个 zfree 函数。
void zfree(void *ptr) {
#ifndef HAVE_MALLOC_SIZE
void *realptr;
size_t oldsize;
#endif
if (ptr == NULL) return;
#ifdef HAVE_MALLOC_SIZE
update_zmalloc_stat_free(zmalloc_size(ptr));
free(ptr);
#else
realptr = (char*)ptr-PREFIX_SIZE;
oldsize = *((size_t*)realptr);
update_zmalloc_stat_free(oldsize+PREFIX_SIZE);
free(realptr);
#endif
}
这里会涉及到一个内存对齐的概念,就比如一个 64 位的操作系统,一次内存 IO 会固定取出 8 个字节的内存数据出来,如果某个变量横跨了两个八字节段,那么 CPU 需要进行两次的 IO 才能完整取出该变量的数据,引入内存对齐,是为了保证任意变量的内存分配不会出现上述的横跨情况,具体的操作手法就是填充无用的内存位,当然这必然会造成内存碎片,不过这也是一种以空间换时间的策略,你也可以禁用它。
函数的上半部分是做一些判断,如果确定了该指针指向的数据结构占用的总内存,则直接调用 free 函数进行内存的释放,否则需要进行一个计算。redis 中的 zmalloc 在每一次内存数据分配的时候都会追加一个 PREFIX_SIZE 的头部数据块,它的值等于当前系统的最大寻址空间,比如 64 CPU的话,PREFIX_SIZE 就会占用到 8 个字节,并且这 8 个字节内部存储的是当前数据实际占用内存大小。
所以这里的话,ptr 指针向低位移动就是指向头部 PREFIX_SIZE 字段首地址,然后取出里面保存的值,也就是当前数据结构实际占用的内存大小,最后加上它自身传入 update_zmalloc_stat_free 函数中修改 used_memory 内存记录指针的值,并在最后调用 free 函数释放内存,包括头部的部分。
其实我们扯远了,继续看数据结构,这里如果还不是很明白的话,没关系,后面我们还会继续讲的。
match 函数依然是一个多态的实现,只给出了定义,具体实现由你来决定,你也可以选择不实现,它用于比较两个链表节点的 value 值是否相等。返回 0 表示不相等,返回 1 表示相等。
最后一个 len 字段描述的是,整个链表中所包含的节点数量。以上就是 redis 中链表的一个基本的定义,加上 list,最终链表结构在 redis 中呈现的抽象图大概是这样的,依然盗的图:
综上,我们介绍了 redis 中链表的一个基本实现情况,总结一下,它是一个双端链表,也就是查找某个节点的前后节点的时间复杂度都在 O(1),也是一个无环并具有首尾节点指针的链表,初次之外,还具有三个多态函数,用于节点间的复制、比较以及内存释放,需要使用者自行实现。