redis底层数据结构--skiplist

认识跳表

跳表的提出

跳表首先由William Pugh在其1990年的论文《Skip lists: A probabilistic alternative to balanced trees》中提出。由该论文的题目可以知道两点:

  • 跳表是概率型数据结构。
  • 跳表是用来替代平衡树的数据结构。准确来说,是用来替代自平衡二叉查找树(self-balancing BST)的结构。

看官可能会觉得这两点与之前文章中讲过的布隆过滤器有些相似,但实际上它们的原理和用途还是很不相同的。

由二叉树回归链表

考虑在有序序列中查找某个特定元素的情境:

  • 如果该序列用支持随机访问的线性结构(数组)存储,那么我们很容易地用二分查找来做。
  • 但是考虑到增删效率和内存扩展性,很多时候要用不支持随机访问的线性结构(链表)存储,就只能从头遍历、逐个比对。
  • 作为折衷,如果用二叉树结构(BST)存储,就可以不靠随机访问特性进行二分查找了。

我们知道,普通BST插入元素越有序效率越低,最坏情况会退化回链表。因此很多大佬提出了自平衡BST结构,使其在任何情况下的增删查操作都保持O(logn)的时间复杂度。自平衡BST的代表就是AVL树、Splay树、2-3树及其衍生出的红黑树。如果推广之,不限于二叉树的话,我们耳熟能详的B树和B+树也属此类,常用于文件系统和数据库。

自平衡BST显然很香,但是它仍然有一个不那么香的点:树的自平衡过程比较复杂,实现起来麻烦,在高并发的情况下,加锁也会带来可观的overhead。如AVL树需要LL、LR、RL、RR四种旋转操作保持平衡,红黑树则需要左旋、右旋和节点变色三种操作。下面的动图展示的就是AVL树在插入元素时的平衡过程。

 

那么,有没有简单点的、与自平衡BST效率相近的实现方法呢?答案就是跳表,并且它简单很多,下面就来看一看。

设计思想与查找流程

跳表就是如同下图一样的许多链表的集合。

 

时间复杂度 如下 logn

 

跳表具有如下的性质:

  • 由多层组成,最底层为第1层,次底层为第2层,以此类推。层数不会超过一个固定的最大值Lmax。
  • 每层都是一个有头节点的有序链表,第1层的链表包含跳表中的所有元素。
  • 如果某个元素在第k层出现,那么在第1~k-1层也必定都会出现,但会按一定的概率p在第k+1层出现。

很显然这是一种空间换时间的思路,与索引异曲同工。第k层可以视为第k-1级索引,用来加速查找。为了避免占用空间过多,第1层之上都不存储实际数据,只有指针(包含指向同层下一个元素的指针与同一个元素下层的指针)。

当查找元素时,会从最顶层链表的头节点开始遍历。以升序跳表为例,如果当前节点的下一个节点包含的值比目标元素值小,则继续向右查找。如果下一个节点的值比目标值大,就转到当前层的下一层去查找。重复向右和向下的操作,直到找到与目标值相等的元素为止。下图中的蓝色箭头标记出了查找元素21的步骤。

 

通过图示,我们也可以更加明白“跳表”这个名称的含义,因为查找过程确实是跳跃的,比线性查找省时。若要查找在高层存在的元素(如25),步数就会变得更少。当数据量越来越大时,这种结构的优势就更加明显了。

插入元素的概率性

前文已经说过,跳表第k层的元素会按一定的概率p在第k+1层出现,这种概率性就是在插入过程中实现的。

当按照上述查找流程找到新元素的插入位置之后,首先将其插入第1层。至于是否要插入第2,3,4...层,就需要用随机数等方法来确定。最通用的实现方法描述如下。

 

int randomizeLevel(double p, int lmax) {
    int level = 1;
    Random random = new Random();
    while (random.nextDouble() < p && level < lmax) {
        level++;
    }
    return level;
}

得到层数k之后,就将新元素插入跳表的第1~k层。由上面的逻辑可知,随着层数的增加,元素被插入高层的概率会指数级下降。

下面的动图示出以p=1/2概率在跳表中插入元素的过程。这种方法也被叫做“抛钢镚儿”(coin flip),直到抛出正面/反面就停止。

 

相对于插入而言,删除元素没有这么多弯弯绕,基本上就是正常的单链表删除逻辑,因此不再展开。

Redis的跳表实现

跳表在很多框架中都有广泛的应用,除Java并发包及HBase之外,比较著名的是Redis和leveldb。之前一直读Java系的源码,有些腻烦了,并且我最近正好在研究一些Redis调优方面的事情,就干脆拿Redis 4.0.14的源码来讲讲吧。

从zset到zskiplist

跳表在Redis中称为zskiplist,是其提供的有序集合(sorted set/zset)类型底层的数据结构之一。zset的定义如下,位于server.h中。

 

typedef struct zset {
    dict *dict;
    zskiplist *zsl;
} zset;

可见,除了zskiplist之外,zset还使用了KV哈希表dict。Redis中有序集合的默认实现其实是更为普遍的ziplist(压缩双链表),但在redis.conf中有两个参数可以控制它转为zset实现。

 

zset-max-ziplist-entries 128
zset-max-ziplist-value 64

也就是说,当有序集合中的元素数超过zset-max-ziplist-entries时,或其中任意一个元素的数据长度超过zset-max-ziplist-value时,就由ziplist自动转化为zset。具体逻辑参见t_zset.c中的zsetConvert()函数,不再赘述。

扯远了,回来看看zskiplist,它的定义就在zset上面。

即ZSet  为有序的,自动去重的集合数据类型,ZSet 数据结构底层实现为 字典(dict) + 跳表(skiplist) ,当数据比较少时,用ziplist编码结构存储。

typedef struct zskiplistNode {
    robj *obj;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned int span;
    } level[];
} zskiplistNode;

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;

zskiplist的节点定义是结构体zskiplistNode,其中有以下字段。

  • obj:存放该节点的数据。
  • score:数据对应的分数值,zset通过分数对数据升序排列。
  • backward:指向链表上一个节点的指针,即后向指针。
  • level[]:结构体zskiplistLevel的数组,表示跳表中的一层。每层又存放有两个字段:
    • forward是指向链表下一个节点的指针,即前向指针。
    • span表示这个前向指针跳跃过了多少个节点(不包括当前节点)。

zskiplist就是跳表本身,其中有以下字段。

  • header、tail:头指针和尾指针。
  • length:跳表的长度,不包括头指针。
  • level:跳表的层数。

下图示出一个length=3,level=5的zskiplist。

 

可见,zskiplist的第1层是个双向链表,其他层仍然是单向链表,这样做是为了方便可能的逆向获取数据的需求。

另外,节点中还会保存前向指针跳过的节点数span,这是因为zset本身支持基于排名的操作,如zrevrank指令(由数据查询排名)、zrevrange指令(由排名范围查询数据)等。如果有span值的话,就可以方便地在查找过程中累积出排名了。

以上是zskiplist相对于前述的传统跳表的两点不同,并且都给我们带来了便利。

头结点不存储数据 只存储索引

 

L2这种是层高 从层高依次往下遍历寻找数据

 

如果想找 150  左下角的那个level2就是层高 那么就去 图上的L2找 然后发现是120  那么就下沉到l1的层高去找200那边 所以150就在他们中间

 

Redis作者对采用跳表的解释

比起我多费口舌,不如来看看Salvatore Sanfillipo(@antirez)本人说的话。他多年之前在Hacker News的一篇帖子上解释了自己为什么要在Redis中用跳表而不是树,原文如下,浅显易懂,就不翻译了:

  1. They are not very memory intensive. It's up to you basically. Changing parameters about the probability of a node to have a given number of levels will make then less memory intensive than btrees.
  2. A sorted set is often target of many ZRANGE or ZREVRANGE operations, that is, traversing the skip list as a linked list. With this operation the cache locality of skip lists is at least as good as with other kind of balanced trees.
  3. They are simpler to implement, debug, and so forth. For instance thanks to the skip list simplicity I received a patch (already in Redis master) with augmented skip lists implementing ZRANK in O(log(N)). It required little changes to the code.


 

 

 

Redis使用了多种数据结构来存储数据,这些数据结构包括字符串(String)、哈希(Hash)、列表(List)、集合(Set)、有序集合(Sorted Set)等。下面我将为你简单解释每种数据结构底层实现方式。 1. 字符串(String):字符串是Redis最基本的数据结构,它的底层实现使用了简单动态字符串(SDS)。SDS是一种可变长度的字符数组,它允许进行高效的增删改操作,并且支持常数时间复杂度的读取操作。 2. 哈希(Hash):哈希是一种键值对的集合,类似于其他编程语言中的字典或映射。Redis中的哈希底层实现使用了哈希表(Hash Table)。哈希表通过将键映射到一个索引位置,并在该位置存储对应的值来实现高效的插入、查找和删除操作。 3. 列表(List):列表是一个有序的字符串集合,可以进行插入、删除和查找等操作。Redis中的列表底层实现使用了双向链表(Linked List),每个节点都包含一个指向前一个节点和后一个节点的指针。 4. 集合(Set):集合是一个无序且不重复的字符串集合。Redis中的集合底层实现使用了哈希表,每个元素被存储在哈希表的一个桶中,通过哈希函数来计算元素的索引位置。 5. 有序集合(Sorted Set):有序集合是一个有序的字符串集合,每个元素都有一个对应的分数,可以根据分数进行排序。Redis中的有序集合底层实现使用了跳跃表(Skip List)和哈希表。跳跃表通过多层索引来实现快速查找,而哈希表用于存储每个元素的分数和值。 这些底层数据结构的选择和实现方式使得Redis在插入、查找和删除等操作上具有高效性能和低复杂度。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值