前言
Redis(Remote Dictionary Server ),即远程字典服务,是一种支持Key-Value等多种数据结构的存储系统。可用于缓存、事件发布或订阅、高速队列等场景。
当它被用作缓存,其功能相当于CPU中的一级缓存或者二级缓存,一定程度上可以避免web服务请求与数据库的响应速度较慢的情况。
Redis为什么那么快这一问题,网上已经有相当多的博主进行解答该问题,解释的角度大体相同,不过笔者在阅读该文献时,对其理解有新的角度,便与各位读者分享本篇文章。
Redis为什么那么快,这一问题笔者将使用时空观的思维来解释;众所周知,计算机业内经常都可以听到使用空间换时间或者时间换空间的角度,来提高某一软件的特性,Redis之所以快,其底层数据结构的设计也是使用这种框架思维发展而成的;为此,笔者阅读Redis相关文献后,总结出以下两点:
1、 使用空间换时间的方式,消耗一部分的内存空间,来减少数据响应时间;
2、 尽可能节约内存空间的消耗,来提高数据的检索效率。
以上两点的总结,即可解释Redis宏观与微观结构的设计。
宏观层面
宏观层面上分析,先封装Redis底层结构的设计,其运行速度快,主要是有以下两点原因:
1、 内存数据库,所有操作都在内存上进行;
2、 实现的数据结构可以高效的增删改查。
上面的第一点,Redis 是使用了⼀个哈希表保存所有键值对,哈希表的最大好处就是让我们可以用O(1) 的时间复杂度来快速查找到键值对,为此其解释就是使用空间换时间的方式,来提高数据响应时间。
上面的第二点,Redis 数据结构并不是指 String(字符串)对象、List(列表)对象、Hash(哈希)对象、Set(集合)对象和 Zset(有序集合)对象,因为这些是 Redis 键值对中值的数据类型,也就是数据的保存形式,这些对象的底层实现的方式就用到了数据结构,各读者阅读下文微观层面数据结构的剖析后,便可发现底层数据结构高效的增删改查,需要空间换时间和尽可能节约内存空间的消耗的两种方式才可完成。
微观层面
宏观层面中,已经提到了Redis键值对中值的五种常用对象,其底层对应的数据结构,可以从下图阅读得知:
接下来,将逐一解释其底层数据结构的实现。
SDS
Redis是使用c语言编写而成,Redis不直接使用c语言的字符串类型,而重新定义一个,主要是c语言原生的字符串类型以下三个缺点:
1、 获取字符串⻓度的时间复杂度为 O(N);
2、 字符串的结尾是以 “\0” 字符标识,字符串里面不能包含有 “\0” 字符,因此不能保存二进制数据;
3、 字符串操作函数不高效且不安全,比如有缓冲区溢出的风险,有可能会造成程序运行终止;
为此,定义了简单动态字符串(simple dynamic string,SDS)来解决上述提到的三个问题,下面先来看一下Redis5.0的SDS的结构定义:
// 五种SDS类型分别对应五种不同的初始长度 其中sdshdr5没有投入过使用
struct __attribute__((__packed__)) sdshdr5 {
unsigned char flags; /* 3 lsb of type, and 5 msb of string length */
char buf[];
};
struct __attribute__((__packed__)) sdshdr8 {
//buf数组中已被使用的长度
uint8_t len; /* used */
//buf数组的总长度
uint8_t alloc; /* excluding the header and null terminator */
// sds类型标识
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[];
};
结构中的每个成员变量分别介绍下:
len,记录了字符串⻓度。这样获取字符串⻓度的时候,只需要返回这个成员变量值就行,时间复杂度只需要 O(1)。
alloc,分配给字符数组的空间长度。这样在修改字符串的时候,可以通过 alloc - len 计算出剩余的空间大小,可以用来判断空间是否满足修改需求,如果不满足的话,就会自动将 SDS 的空间扩展至执行修改所需的大小,然后才执行实际的修改操作,所以使用 SDS 既不需要手动修改 SDS 的空间大小,也不会出现前面所说的缓冲区溢出的问题。
flags,⽤来表示不同类型的 SDS。⼀共设计了 5 种类型,分别是 sdshdr5、sdshdr8、sdshdr16、sdshdr32 和 sdshdr64,后面在说明区别之处。
buf[],字符数组,用来保存实际数据。不仅可以保存字符串,也可以保存二进制数据。
其中,len和alloc变量的定义,使用空间换时间的方式,来提高数据的响应时间,flags变量的定义主要是尽可能的少使用内存空间的方式,来提高数据的检索效率。
quicklist
quicklist 就是双向链表和压缩列表组合,因为⼀个 quicklist 就是⼀个链表,而链表中的每个元素又是⼀个压缩列表。双向链表在学习链表这种数据结构时,应该都了解过;压缩列表,它也是一种底层的数据结构,但它在修改数据的时候,总有连锁更新的问题。
quicklist 的解决办法,通过控制每个链表节点中的压缩列表的大小或者元素个数,来规避连锁更新的问题。因为压缩列表元素越少或越小,连锁更新带来的影响就越小,从而提供了更好的访问性能。
quicklist 的结构体跟链表的结构体类似,都包含了表头和表尾。
typedef struct quicklist {
//quicklist的链表头
quicklistNode *head;
//quicklist的链表尾
quicklistNode *tail;
//所有压缩列表中的总元素个数
unsigned long count;
//quicklistNodes的个数
unsigned long len;
...
} quicklist
区别在于 quicklist 的节点是quicklistNode,接下来看看,quicklistNode 的结构定义:
typedef struct quicklistNode {
//前一个quicklistNode
struct quicklistNode*prev;
//下一个quicklistNode
struct quicklistNode *next;
//quicklistNode指向的压缩列表
unsigned char *zl;
//压缩列表的的字节大小
unsigned int sz;
//压缩列表的元素个数
unsigned int count : 16;
}quicklistNode;
在向 quicklist 添加⼀个元素的时候,不会像普通的链表那样,直接新建⼀个链表节点。而是会检查插⼊位置的压缩列表是否能容纳该元素,如果能容纳就直接保存到 quicklistNode 结构⾥的压缩列表,如果不能容纳,才会新建⼀个新的 quicklistNode 结构。
quicklist 会控制 quicklistNode 结构里的压缩列表的大小或者元素个数,来规避潜在的连锁更新的风险,但是这并没有完全解决连锁更新的问题。
其中,quicklistNode的定义,更好的规避潜在的连锁更新风险,该做法就是空间换时间的方式,一定程度上,能够更好的维护quicklist。
listpack
Redis 在 5.0 新设计⼀个数据结构叫 listpack,目的是替代压缩列表,它最大特点是 listpack 中每个节点不再包含前⼀个节点的长度了,压缩列表每个节点正因为需要保存前⼀个节点的⻓度字段,就会有连锁更新的隐患。
下面介绍一下listpack的数据结构:
struct lpentry {
int<var> encoding;//编码方式
optional byte[] content; //content的存储了当前元素的内容
int<var> length; //length存储了当前字元素的长度
}lpentry;
主要包含三个方面内容:
encoding,定义该元素的编码类型,会对不同长度的整数和字符串进行编码;content,实际存放的数据;
length,encoding+data的总长度;
可以看到,listpack 没有压缩列表中记录前⼀个节点长度的字段了,listpack 只记录当前节点的⻓度,当我们向 listpack 加⼊⼀个新元素的时候,不会影响其他节点的长度字段的变化,从而避免了压缩列表的连锁更新问题。
其中,listpack源码规定listpack只进行新增和修改操作,不进行删除操作,该种方式内存利用率不高,这就是典型的空间换时间的方式。
哈希表
哈希表优点在于,它能以 O(1) 的复杂度快速查询数据。将 key 通过 Hash 函数的计算,就能定位数据在表中的位置,因为哈希表实际上是数组,所以可以通过索引值快速查询到数据。
下面介绍一下哈希的数据结构:
typedef struct dictht { // 哈希表定义
dictEntry **table; //二维数组
unsigned long size; //表大小
unsigned long sizemask; //hash取模的用的
unsigned long used; //元素个数
} dictht;
typedef struct dictEntry { // 哈希实体
void *key; //key键
union { //value值
void *val;
uint64_t u64;// 无符合
int64_t s64; //有符号
double d;
} v; // 联合体V
struct dictEntry *next; //下一个哈希实体
} dictEntry;
使用哈希的数据结构,不可避免的是存在哈希冲突的问题,Redis 采用了链式哈希来解决哈希冲突。在不扩容哈希表的前提下,将具有相同哈希值的数据串起来,形成链接起,以便这些数据在表中仍然可以被查询到。
随着hash结构的使用,hash表的大小不能满足需求,造成过多hash碰撞后需要进行的扩容hash表的操作,即是rehash,其实通常的做法确实是建立一个额外的hash表,将原来的hash表中的数据在新的数据中进行重新输入,从而生成新的hash表。可能有的读者有疑问,为啥不一步到位,创建的时候就创建一个大容量的hash结构?对于这个问题,笔者在阅读的时候也产生疑惑,经过一番思考,笔者得出如下结论:
1、 hash结构未来被使用多大的空间无法预知,提前申请大空间会造成内存空间的浪费;
2、 用多少内存,在合理范围内扩容,可以尽可能的减少内存空间的浪费。
其中,哈希结构本身的特点即是空间换时间的方式,源码中rehash的使用,也在一定程度上减少内存空间浪费的方式;为此,哈希结构快,是其设计下了很大功夫。
整数集合
下面介绍一下整数集合的数据结构:
typedef struct intset{
//编码方式
uint32_t encoding;
//集合包含的元素数量
uint32_t length;
//保存元素的数组
int8_t contents[];
}intset;
介绍了前面几种数据结构,整数集合的数据结构特点不明显,其最大的特点是整数集合的升级操作。
整数集合会有⼀个升级规则,就是当我们将⼀个新元素加⼊到整数集合里面,如果新元素的类型(int32_t)⽐整数集合现有所有元素的类型(int16_t)都要长时,整数集合需要先进⾏升级,也就是按新元素的类型(int32_t)扩展 contents 数组的空间大小,然后才能将新元素加⼊到整数集合里,当然升级的过程中,也要维持整数集合的有序性。
其中,整数集合升级操作规则的制定,在一定程度上,也是尽可能减少内存空间浪费的方式。
跳表
链表在查找元素的时候,因为需要逐⼀查找,所以查询效率非常低,时间复杂度是O(N),于是就出现了跳表。跳表是在链表基础上改进过来的,实现了⼀种多层的有序链表,这样的好处是能快读定位数据。下图大概是跳表存储数据的示意图:
如果我们要在链表中查找节点 5这个元素,只能从头开始遍历链表,需要查找 5次,而使用了跳表后,只需要查找 2 次就能定位到节点 5,因为可以在头节点直接从 L2 层级跳到节点 3,然后再从L1层级跳到节点5。可以看到,这个查找过程就是在多个层级上跳来跳去,最后定位到元素。当数据量很大时,跳表的查找复杂度就是O(logN)。
下面介绍一下跳表的数据结构:
typedef struct zskiplistNode{
//zset 对象的元素值
sds ele;
//元素权重值
double score;
//后向指针
struct zskiplistNode *backward;
//节点的leve1数组,保存每层上的前向指针和跨度
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned long span;
}level[];
}zskiplistNode
跳表是⼀个带有层级关系的链表,而且每⼀层级可以包含多个节点,每⼀个节点通过指针连接起来,实现这⼀特性就是靠跳表节点结构体中的zskiplistLevel 结构体类型的 level 数组。
跳表结构里包含了:
1、 跳表的头尾节点,便于在O(1)时间复杂度内访问跳表的头节点和尾节点;
2、 跳表的长度,便于在O(1)时间复杂度获取跳表节点的数量;
3、 跳表的最大层数,便于在O(1)时间复杂度获取跳表中层高最大的那个节点的层数量。
其中,跳表定义了一系列变量,无非是在完成跳表的功能,即是在维护它可以在更短时间找到想要的元素,这也是空间换时间的方式,大大提高了查找效率。
总结
上述,笔者使用时空观角度带大家剖析Redis底层数据结构的大体设计,想了解更多其底层数据结构的读者可以去阅读Redis源码。读者若发现文中有不足之处或者有更好的解释,欢迎在评论区讨论。