Redis源码分析-底层数据结构盘点

因为项目中经常使用到Redis,所以楼主一直以来对redis的源码很感兴趣。前段时间忽然心血来潮,抽了点时间将Redis的源码过了一遍,主要包括多路复用和常用数据结构的底层实现部分,看的是C语言版本的Redis(虽然楼主是JAVA程序猿)。

应该说收益颇丰,尤其是redis对各种数据结构的实现,它的每个数据结构为各种不同的应用场景,做了特定的优化,譬如数据量大的时候结构怎么定义、数据量小的时候结构怎么定义、由于redis是单线程,在hash表rehash时还采用了渐进式的hash,诸如此类。

Redis为存储做的这些优化,充分解答了redis为何如此高效,以下是我对Redis常见数据结构的一些总结。

一、SDS(Simple Dynamic String) 简单动态字符串。

SDS是redis最简单的数据结构

sds(简单动态字符串)特点,预先分配内存,记录字符串长度,在原字符串新增加一串字符串,如果SDS剩余空间足够,则什么都不用做。如果剩余空间不够,则会分配新的内存空间,并且采用预分配。

新长度newlen为原len+addlen,若newlen小于1M,则为SDS分配新的内存大小为2*newlen;若newlen大于等于1M,则SDS分配新的内存大小为newlen + 1M

SDS是以len字段来判断是否到达字符串末尾,而不是以'\0'判断结尾。所以sds存储的字符串中间可以出现'\0',即sds字符串是二进制安全的。

当要清空一个SDS时,并不真正释放其内存,而是设置len字段为0即可,这样当之后再次使用到该SDS时,可避免重新分配内存,从而提高效率。

SDS的好处就是通过预分配内存和维护字符串长度,实现动态字符串。

二、ADList(A generic doubly linked list) 双向链表

ADList就是个具有头尾指针的双向链表,没有什么可以多说的,看一下结构体的定义吧

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;

typedef struct listNode {
struct listNode *prev;
struct listNode *next;
void *value;
} listNode;

三、skipList 跳表

先看一下比较抽象的描述吧~

1.跳表是一种有序数据结构, 它通过在每个节点中维持多个指向其他节点的指针, 从而达到快速访问节点的目的。
2.跳跃表支持平均 O(log N) 复杂度的节点查找, 还可以通过顺序性操作来批量处理节点。
3.在大部分情况下, 跳跃表的效率可以和平衡树相媲美, 并且因为跳跃表的实现比平衡树要来得更为简单, 所以有不少程序都使用跳跃表来代替平衡树。
4.Redis 使用跳跃表作为有序集合键的底层实现之一: 如果一个有序集合包含的元素数量比较多, 又或者有序集合中元素的成员(member)是比较长的字符串时, Redis 就会使用跳跃表来作为有序集合键的底层实现。

OK,楼主估计这样的描述对于数据结构理解不够深刻的同学难以理解,别着急,下面看一下楼主对跳表给出解释。

1.跳表本质是一个有序链表。(此刻的你应想一下一个有序链表是怎样的)

2.跳表的查询、插入、删除的时间复杂度均为O(logn),跟平衡二叉树的时间复杂度是一样的!(此刻你应想,纳尼!为什么这么效率强的数据结构我以前不知道!为什么老师没有教。。)

3.跳表是一种通过空间冗余来换取时间效率的数据结构。(此刻你应想,怎么样的空间冗余的有序链表能换取更高的查询效率呢?)

那么下来看一下跳表的数据结构的示意图

Redis源码分析-底层数据结构盘点

以及该跳表c语言结构体的定义

struct skipListNode{

skipListNode* levelnext[3];   

int value;

}

struct skipList{

int lenth;

skipListNode* head;

}

 

注意观察示意图最下的一条链表,它是有序的链表,存入跳表的数据,就存在这张链表中。那么上面额外的两条链表又是什么呢,他们有什么作用呢?

是的,上面的两条链表就是所谓的冗余空间,他们被称为跳表的索引,通过这样的冗余空间就可以在有序链表的基础上实现更高的查询效率。具体怎么提高查询效率的呢?

举个例子

譬如查询59这个元素,如果只有原始链表,你需要依次遍历14-23-34-43-50-59,总共6个节点,而通过上面2条链表,你将依次遍历14-50-59,总共3个节点。

 

这个例子已经说明了跳表通过冗余空间对查询效率的优化,但是我们还需要理论证明它带来的查询效率的优化对于所有case存在而不是仅仅某些特定的case。

跳表查询优化证明:

跳表查询优化实际上利用了二分查找的思想,基于有序链表的二分查找。

观察跳表结构,从最底层开始,每隔一个或者两个节点向上抽取一个节点作为索引链表,当抽取到最顶层时,最终只剩两个元素。

查询时,从顶层链表开始将查询关键字与链表节点进行对比,逐层向下进行查找。

譬如查询59这个元素的过程如下:

对比59、14,发现59>14。

对比59、50,发现59>50.

50在顶层没有下一个元素,从50节点下降一层。

对比59、72,发现59<72.

即59>50且59<72,从50节点下降一层。

对比59、59,查找成功返回节点

通过这样的查找方式,优化了时间效率。

一般来说,跳表存储的关键字越多,跳表的冗余数据也会越多,跳表的层数也越高,并且,

实际上,到底隔多少个节点向上抽取一个节点并不是固定的。

若抽取节点的间距越大,则使用冗余空间越少,跳表总层数越小,查询效率越低 。

若抽取节点的间距越小(最小为1),则使用冗余空间越多,跳表总层数越大,查询效率越高。

这便是以空间换取时间。

跳表的示意图看起来很复杂,那么怎么用c语言实现跳表呢。其实,跳表的实现非常简单,看一下跳表结构体的基本定义。

struct skipList{

int lenth;

skipListNode* head;

}skiplist;

跳表结构体记录了存储的元素个数和跳表头节点。

struct skipListNode{

skipListNode* levelnext[3];   

int currentlevel;

int totallevel;

int value;

}

跳表节点结构体除了维护保存的关键字外还保存下一个链表节点指针levelnext和当前层数currentlevel。可以看见,下一个链表节点指针是一个大小为3的数组.

数组中的元素指向该节点在某一层的下一个节点。

譬如示意图中的14节点,它的level[0]指向23,level[1]指向34,level[2]指向50。当需要降层查询时,只需要将clevel-1即可。这样便实现了跳表的基本查询逻辑。

而跳表的插入删除逻辑,在经过O(logn)复杂度查找到待删除节点或插入位置后,经过O(1)的时间复杂度即可完成链表的插入删除操作。

 

下面考虑这样的问题

如果我往例子中的跳表插入24、25、26、27,那么在14-34之间元素就会新增加4个,那么如果我在这之间继续插入更多元素,但又不更新索引,随着插入元素的增加跳表的查询效率将会退化成O(n)。

其实,这就像二叉排序树的失衡问题,平衡二叉树通过额外的翻转操作来维护树的左右平衡来确保它的效率,在跳表中,这个额外的操作就是更新索引,那么,跳表是怎么更新索引的呢?

在redis 中是通过一个随机函数,来决定将这个结点插入到哪几层索引中,比如随机函数生成了值K,那么我就将这个结点添加到第一级的到第K级的索引中,以此来避免复杂度的退化。

最后补充一点,redis中的 跳表实际上是双向的,并且保存头尾指针,支持双向遍历。

四、ziplist(压缩链表)

ziplist是经过特殊编码的方式压缩的集合

redis中,当list和hash元素较少并且数值较小时,使用ziplist实现,因为在数据量小的时候ziplist的查询效率接近于O(1),与hash效率相似,ziplist是一整块连续内存,实质是个数组,不利于插入删除和查找。删除节点时,将节点之后的所有节点前移。由于节点保存前一个节点的长度(可能一个字节,可能4个字节),如果删除某节点后导致之后的节点长度发生变化,需要级联更新之后的各个节点长度,直到不用更新长度的节点为止。

ziplist的唯一的优势:以字节为单位,通过压缩变长编码的方式节省大量存储空间,当需要使用时,数据可以从磁盘中快速导入内存中处理,而数据在内存中的操作速度是极快的,通过节省存储空间的方式节省了时间。

ziplist与普通的数组对比,我们先定义一个数组arr[100]

struct Node{

 int value1;

    long value2;

}arr[100];

arr数组内存2个变量value1、value2,分别是int及long类型,分别占用4个字节和8个字节,咋我们通常存储小值时,就会导致内存的浪费,如arr[0].value1=100,实际上100仅使用了1个字节,但是却占用了4个字节。

而ziplisl就是redis为充分利用存储空间所设计的数据结构,实际上就是一个字节数组。

char ziplist[];

所有类型的数据,如long、int、指针类型、字符串类型,在存入ziplist时都会先被压缩编码。

由于保存进ziplist的元素都会被压缩编码,ziplist中每个节点所占的字节数并不是固定的

那么ziplist能否用链表来存储呢?

答案是,如果用链表的方式来保存节点,会占用离散空间,而用数组的方式,则可以使用连续空间。比起离散空间的链表,连续空间的数组更有利于将ziplist导入内存,所以使用数组实现。

那么这样的话由于每个节点的字节数不固定,ziplist又该如何区分两个节点呢?

为了区分两个节点,ziplist中的节点需要保存自身节点的长度,通过自身节点的长度,从而可以定位到该节点下一个节点的首字节,相当于是下一个节点的指针。

另外,ziplist中的节点还保存了前一个节点的长度,通过它,可以定位到该节点的前一个节点的首字节,相当于是前一个节点的指针。从这个角度上来讲,ziplist又是一个双向链表。

下面给出ziplist数组zpilist数组保存内容的格式

<zlbytes> <zltail> <zllen> <entry> <entry> ... <entry> <zlend>

zlbytes:ziplist占总字节数

zltail:最后一个元素的偏移量,相当于ziplist的尾指针。

zllen:entry元素个数

zlend :ziplist结束标志位

entry:ziplist的各个节点

ziplist的entry 的格式如下:

<prevlen> <encoding> <entry-data>

prevlen :前一个元素的长度,相当于节点保存前一个元素的指针。

encoding: 记录了当前节点保存的数据的类型以及当前长度,相当于节点保存后一个元素的指针。

entry-data :经过压缩后的数据

分析可得,ziplist就是用一个字节数组,保存了双向链表,既压缩了数据,又保证了存储空间的连续性,从而极大方便了将数据从硬盘导入内存进行快速处理。

五、quicklist(快速链表)

Redis对外暴露的list数据结构,其底层实现所依赖的内部数据结构就是quicklist。quicklist是一个同时结合ziplist和双向链表优势的数据结构。

quicklist就是一个块状的双向压缩链表。

双向链表在表的两端进行push和pop操作十分的便捷 vc,但是它的内存开销比较大。

首先,它在每个节点上除了要保存数据之外,还要额外保存两个指针;

其次,双向链表的各个节点是单独的内存块,地址不连续,节点多了容易产生内存碎片。

ziplist由于是一整块连续内存,并且对数据进行了压缩所以存储效率很高但是首先,它不利于修改操作,每次数据变动都会引发一次内存的realloc(重新分配)。

其次,当ziplist长度很长的时候,一次realloc可能会导致大批量的数据拷贝,进一步降低性能。

Redis基于空间和时间的考虑,于是quicklist结合双向链表和ziplist的优点。

(待更新。。。。)

上一篇:Redis专题(2):Redis数据结构底层探秘


下一篇:Redis 概念以及底层数据结构