前言
最近看了相关的Redis设计核心相关的书籍,对Redis有了一些小的认识,然后自己也做一些产出加深映象,我会从几个方面去总结Redis设计的核心内容:Redis底层数据结构总结、Redis高性能由哪些基础支撑、Redis应用场景、那些有趣的功能。
概述
本篇主要内容是Redis底层数据结构总结。Redis供用户直接使用的数据结构有String、List、Set、Zset、Hash等结构,而这些结构下层又基于一些数据结构,这些数据结构被设计的非常优美,来提供了Redis高效的性能、低内存占用、多样的功能等,他们有Dict、RedisObject、SDS、ZipList、QuickList、Listpak、Skiplist,我将从是什么、为什么使用、设计思想、特点、实现结构几个方面去总结。续上次说到了Ziplist,本章将继续介绍剩余的底层数据结构。
目录
Redis是如何在ziplist和skiplist中选择的:
QuickList
是什么
quicklist是List的底层数据结构,他是ziplist和linkedlist的混合体,将双向链表按段划分,每一段使用ziplist来紧凑存储,多个ziplist之间使用双向指针串联
为什么使用
- 1.因为双向链表结构每个元素都需要维护前后向指针,当元素较小时,本身的结构会比元素占用的空间多,浪费内存资源。
- 2.双链链表的每个元素内存是独立的,加剧了内存碎片率。
- 3.ziplist使用紧凑字节数组存储,但对于大长度的修改操作可能触发空间重分配
- 4.ziplist元素之间不独立,可能导致级联更新。
所以quciklist采用了双方的优点,避免了双方的缺点,采用分段的方式将一个长链表划分成多段node节点 ,每一段node节点使用ziplist存储:1.采用ziplist节省内存的优点 2.采用ziplist连续内存,降低内存碎片率 3.避免生成大的ziplist减少内存空间重分配的影响,4.减少node节点数量,使维护的指针数量变少节省内存空间;5.提供数据压缩功能,因为一般链表操作首尾更多,对中间元素来提高存储效率
设计思想
Quicklist采用了Ziplist高效存储的特点,并且避免了将ziplist分段存储,避免单个Ziplist过大导致的操作缓存,将Ziplist当成一个Node使用,多个Node串联形成一条双向链表。
特点
- 1.结合ziplist和linkedlist的特点;
- 2.降低内存碎片率;
- 3.使用ziplist降低内存占用;
- 4.访问头尾的时间复杂度为O(1);
- 5.可以快速跳过ziplist;
- 6.快速删除;
- 7.提供压缩功能
- 8.Ziplist的一些缺点仍存储,比如元素之间不独立、不适合存储大量元素、每次修改都会触发realloc,但是Quicklist限制了Ziplist的最大长度,即使发生以上情况对性能的影响也较小
- 9..ziplist的拆分和合并。Quicklist模式Ziplist节点最大为8kb,当超出时则生成新ziplist节点,所以对中间的元素修改时可能导致ziplist的拆分和合并。
- 10.存储有序、可重复的小字节元素,充分利用ziplist的特性,一般list适用在访问最新数据的场景或者提供顺序访问
实现结构
图来源于<Redis核心原理与设计>
typedef struct quicklist {
quicklistNode *head; //指向头节点的指针
quicklistNode *tail; //指向尾节点的指针
unsigned long count; //总的元素数量
unsigned long len; //node的数量
int fill : 16; //ziplist中entry能保存的数量,由list-max-ziplist-size配置项
控制
unsigned int compress : 16; //压缩深度,由list-compress-depth配置项控制
}
typedef struct quicklistNode {
struct quicklistNode *prev; 8byte //前节点指针
struct quicklistNode *next; 8byte //后节点指针
unsigned char *zl; 8byte //数据指针。当前节点的数据没有压缩,那么它
指向一个ziplist结构;否则,它指向一个quicklistLZF结构。
unsigned int sz; //zl指向的ziplist实际占用内存大小。需要注意的是:如果
ziplist被压缩了,那么这个sz的值仍然是压缩前的ziplist大小。
unsigned int count : 16; //ziplist里面包含的数据项个数
unsigned int encoding : 2; //ziplist是否压缩。取值:1--ziplist,2--quicklistLZF
unsigned int container : 2; //存储类型,目前使用固定值2 表示使用ziplist存储
unsigned int recompress : 1; //当我们使用类似lindex这样的命令查看了某一项本来压缩的数
据时,需要把数据暂时解压,这时就设置recompress=1做一个标记,等有机会再把数据重新压缩
unsigned int attempted_compress : 1; //Redis自动化测试程序有用
unsigned int extra : 10; //它扩展字段,目前没有使用
}
typedef struct quicklistLZF {
unsigned int sz; //压缩后的ziplist大小
char compressed[]; //柔性数组,存放压缩后的ziplist字节数组
} quicklistLZF;
使用ziplist+linkedlist的实现,其中ziplist用来存储元素,而上层提供node节点来减少单个ziplist的数量,从而即高效,又占用内存少,内存碎片率低,并且quicklist还提供数据压缩功能。quicklist内部包含quicklistNode,quicklistNode使用ziplist存储。还可以使用quicklistLZF压缩元素。
更多内容请参考Redis专栏。
Listpak
是什么
Redis 5.0 又引入了一个新的数据结构 listpack,它是对 ziplist 结构的改进,在存储空间上会更加节省,而且结构上也比 ziplist 要精简,占用内存更少。
为什么使用
Ziplist有以下缺点:
- 1.ziplist元素相互不独立,所以存在级联更新影响
- 2.ziplist每次都需要维护tail变量
- 3.前向新增元素时,还需要修改后向元素
而listpack:
- 1.无级联更新影响。首先消除了ziplist中级联更新的影响,每个元素相互独立,没有去记录对方的信息
- 2.简单的反向遍历方式。listpack列表通过其他方式实现了反向遍历,但需要依赖于元素的特性。而ziplist需要一直维护tail的偏移量
- 3.占用空间少。listpack的基础结构的占用空间比ziplist小(少了4byte,ziplist为11byte),重点在entry内部的length是可变长度,之前pre_e_l固定为1|5个字节,length为1-5个字节,根据自身长度范围变化。
设计思想
listpack结合了ziplist的优点:紧凑字节数组+压缩编码+边长结构+双向链表,但对其内部做了优化,比如元素之间相互独立,无级联更新影响、提供了新的方式双向遍历、占用内存比ziplist更少,ziplist基础结构为11byte,lisypack基础结构为7byte、去除了pre_e_l而使用length边长结构,pre固定为1|5字节,而length可以为1、2、3、4、5byte,更加节省内存。
特点
- 1.无级联更新影响,解除了元素之间的关系;
- 2.占用空间少,结构更加精简;
- 3.与ziplist相比少了尾部偏移量、前一个元素占用空间;
- 4.新增了length来获取元素占用空间,也是通过他实现反向遍历;
- 5.仍有ziplist相同的缺点。不适合经常修改的场景,每次操作都会触发realloc,可能导致内存重分配;
- 6.只能存储少量元素和小字节元素;
- 7.因为len占用两个字节,最多标识2^16-1个元素;
- 8.首尾操作时间复杂度为O(1)
- 9.双向链表特性
实现结构
图来源于<Redis核心思想与设计>
struct lpentry {
int<var> encoding; 结构编码类型
optional byte[] content;
int<var> length; length放在尾部,是一个固定长度编码的元素。可以通过这个长度和total_bytes计
算出尾部元素的偏移量。
}
length:
长度字段使用 varint 进行编码,不同于 skiplist 元素长度的编码为 1 个字节或者 5 个字节,listpack 元素长度的编码可以是 1、2、3、4、5 个字节。同 UTF8 编码一样,它通过字节的最高位是否为 1 来决定编码的长度。
encoding:
Redis 为了让 listpack 元素支持很多类型,它对 encoding 字段也进行了较为复杂的设计--压缩编码。支持多种长度的数据类型:3种字符串结构、6种整形结构。
如何获取尾部元素偏移量?
首先listpack中存储了total_bytes 和每个元素的length,首先total_bytes-lend(固定1字节)指向了最后一个元素的末尾地址,因为length存储在entry尾部并且没有做特殊编码,可以很容易的解析最后length字段,拿到最后一个元素的占用空间,然后total_bytes-lend-tail_length既尾元素的首地址。
Skiplist
是什么
skiplist本质上也是一种查找结构,用于解决算法中的查找问题(Searching),即根据给定的key,快速查到它所在的位置(或者对应的value)。实际上,它是在有序链表的基础上发展起来的。除了最下面第1层链表之外,它会产生若干层稀疏的链表,这些链表里面的指针故意跳过了一些节点(而且越高层的链表跳过的节点越多)。这就使得我们在查找数据的时候能够先在高层的链表中进行查找,然后逐层降低,最终降到第1层链表来精确地确定数据位置。在这个过程中,我们跳过了一些节点,从而也就加快了查找速度。
为什么使用
Skiplist是一种查找结构,主要解决范围查询和索引查询,传统双向链表无法提供这样的功能,而平衡树结构的实现复杂,占用内存多。Skiplist使用一种跳层的思想,有O(logn)的时间复杂度,内部维护了多条有序链表。
设计思想
Skiplist内部维护了多条双向链表,除第一层的有序排列的全部元素外,还维护了多层稀疏的有序链表,稀疏链表每一层都会底层的一些节点,在查找时会从高到低逐层查找,类似于二分法思想,从而实现快速查找元素。
特点
- 1.有序链表。内部维护的是有序链表。
- 2.内部分为多层有序稀疏链表。使用多层有序稀疏链表,可以提供跳跃功能,无需遍历查询,通过跳跃底层链表的方式提高效率。
- 3.随机层数。如果采用比例的方式设置元素所在的层数,则每修改一个元素都可能对本身结构做较大的变动,而采用随机层数时,对元素的改变只需要改变相邻元素的关系,而不需要改动整个结构。随机层数降低了元素插入、删除的复杂度,这是skiplist的一个重要特性,也是优于平衡树的原因。随机分层利用了概率算法。
- 4.提供的核心功能,范围查询、比较查询、索引查询
- 5.key元素必须为整形结构,因为Skiplist会对其排序
- 6.占用内存。内部维护了多层链表,多维护了较多的指针,元素需要记录后向多层的引用和前向同层引用,但仍比平衡树占用内存少,因为平衡树需维护更多的指针
- 7.实现相比平衡树简单。但内部仍需要构建复杂的业务。
- 8.Redis实现的Skiplist还提供了span字段,表示高层链表跨域的节点时,通过这个属性快速获取元素的排名
- 9.当元素变动时,需要维护该元素的前后向引用指针。后向引用指针维护较复杂,因为需要找到该元素同层到底层离其最近的后向节点。
- 10.因为内部采用了随机层数,所以在极端情况下可能生产一条同层链表,时间复杂度为O(logn),Redis默认到晋升概率为25%^K,K为层数 ,既层数越高概率越低。
为什么使用随机层数?
如果使用固定关系的层数,比如每X个节点之间建立一个高层,当对链表做调整时,可能导致链表结构层级变动,因为固定关系的限制会导致大部分节点的引用关系或者层级关系变动,所以采用了随机层数。随机层数是使用随机算法为元素随机分配一个层级,只需要记录前向和后向元素即可,修改时也只改动相邻元素的引用关系,而不会导致整个结构做修改。随机层数降低了元素插入、删除的复杂度,这是skiplist的一个重要特性,也是优于平衡树的原因。
skiplist与平衡树、哈希表的比较:
- 1.有序性。 skiplist和各种平衡树(如AVL、红黑树等)的元素是有序排列的,而哈希表不是有序的。因此,在哈希表上只能做单个key的查找,不适宜做范围查找。所谓范围查找,指的是查找那些大小在指定的两个值之间的所有节点。
- 2.范围查找。 在做范围查找的时候,平衡树比skiplist操作要复杂。在平衡树上,我们找到指定范围的小值之后,还需要以中序遍历的顺序继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现。而在skiplist上进行范围查找就非常简单,只需要在找到小值之后,对第1层链表进行若干步的遍历就可以实现。
- 3.修改时间复杂度。 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂,而skiplist的插入和删除只需要修改相邻节点的指针,操作简单又快速。
- 4.内存占用。从内存占用上来说,skiplist比平衡树更灵活一些。一般来说,平衡树每个节点包含2个指针(分别指向左右子树),而skiplist每个节点包含的指针数目平均为1/(1-p),具体取决于参数p的大小。如果像Redis里的实现一样,取p=1/4,那么平均每个节点包含1.33个指针,比平衡树更有优势。
- 5.查询时间复杂度。 查找单个key,skiplist和平衡树的时间复杂度都为O(log n),大体相当;而哈希表在保持较低的哈希值冲突概率的前提下,查找时间复杂度接近O(1),性能更高一些。所以我们平常使用的各种Map或dictionary结构,大都是基于哈希表实现的。
- 6.实现难度。从算法实现难度上来比较,skiplist比平衡树要简单得多。
Redis是如何在ziplist和skiplist中选择的:
Redis中的Zset是在skiplist, dict和ziplist基础上构建起来的:
1.当数据较少时,sorted set是由一个ziplist来实现的。
ziplist就是由很多数据项组成的一大块连续内存。由于sorted set的每一项元素都由数据和score组成,因此,当使用zadd命令插入一个(数据, score)对的时候,底层在相应的ziplist上就插入两个数据项:数据在前,score在后。
ziplist的主要优点是节省内存,但它上面的查找操作只能按顺序查找(可以正序也可以倒序)。因此,sorted set的各个查询操作,就是在ziplist上从前向后(或从后向前)一步步查找,每一步前进两个数据项,跨域一个(数据, score)对。
随着数据的插入或元素太大,Redis会将zset的ziplist结构转为skiplist结构,临界点为:
zset-max-ziplist-entries 128 :最多存储128个元素,既256个数据项(因为score和value占用两个位置)
zset-max-ziplist-value 64 :任意一个数据长度超过64k
2.当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)。
实现结构
Zset的实现:
当数据较少时,sorted set是由一个ziplist来实现的。
当数据多的时候,sorted set是由一个dict + 一个skiplist来实现的。简单来讲,dict用来查询数据到分数的对应关系,而skiplist用来根据分数查询数据(可能是范围查找)
zset的skiplist内部包含两种结构,虽然同时使用两种结构,但它们会通过指针来共享相同元素的member和score,因此不会浪费额外的内存,如下:
1.dict 通过数据来找到分数,时间复杂度O(1)
2.skiplist 通过分数|排名来找数据,时间复杂度O(logn)
Redis的跳跃链表一共64层,容纳元素数量为极大,Header头结点,key-score 为最低值,value为null,每一次的遍历都是从header开始的元素与元素之间使用双向链表连接,他们是有序排列的,从小到大排列,不同的kv元素层高不一样,层数越高的kv元素越少。结构如下:
zset数据结构:
typedef struct zskiplistNode {
robj *obj; key的RedisObject引用,和dict中存储的元素共享引用
double score; 数据对应的分数
struct zskiplistNode *backward; 指向链表前一个节点的指针(前向指针)。节点只有1个前向
指针,所以只有第1层链表是一个双向链表。
struct zskiplistLevel {
struct zskiplistNode *forward; 每层对应1个后向指针,用forward字段表示
unsigned int span; 每个后向指针还对应了一个span值,它表示当前的指针跨越了多少个
节点。span用于计算元素排名(rank),这正是前面我们提到的Redis对于skiplist所做的一个扩展
} level[]; level[]存放指向各层链表后一个节点的指针(后向指针)。每层对应1个后向指针,
用forward字段表示
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail; 头指针header和尾指针tail。
unsigned long length; 链表长度length,即链表包含的节点总数。注意,新创建的skiplist包
含一个空的头指针,这个头指针不包含在length计数中。
int level; 表示skiplist的总层数,即所有节点层数的最大值。
} zskiplist; zskiplist定义了真正的skiplist结构
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Skiplist的内容和实现还有更多内容,欢迎阅读Redis专栏。
参考:Redis内部数据结构详解(6)——skiplist: http://zhangtielei.com/posts/blog-redis-skiplist.html