Redis 是一个开源(BSD许可)的,内存中的数据结构存储系统,它能够用做数据库、缓存和消息中间件。可能几乎全部的线上项目都会使用到 Redis,不管你是作缓存、或是用做消息中间件,用起来很简单方便,但可能大多数人并无去深刻底层的看看 Redis 的一些策略实现等等细节。java
正好最近也在项目开发中遇到一些 Redis 相关的 Bug,因为不熟悉底层的一些实现,较为费劲的解决了,因此打算开这么一个系列,记录一下对于 Redis 底层的一些结构、策略的学习笔记。git
第一部分咱们打算从 Redis 的五种数据结构以及对象类型的实现开始,主要涉及内容以下,你也能够经过文末给出 GitHub 仓库下载对应的思惟导图。程序员
本篇文章打算介绍 SDS 简单动态字符串和双端链表这两种数据结构。github
你们都知道 Redis 是由 C 语言做为底层编程语言实现的,而 C 语言中也是有字符串这种数据结构的,它是一个字符数组而且是一个以空字符结尾的字符数组,这种结构对于 Redis 而言过于简单了,因而 Redis 自行实现了 SDS 这种简单动态字符串结构,它其实和 Java 中 ArrayList 的实现是很相似的。redis
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),也是一个无环并具备首尾节点指针的链表,初次以外,还具备三个多态函数,用于节点间的复制、比较以及内存释放,须要使用者自行实现。