2021-07-04 10:26  阅读(308)
文章分类:死磕 Redis 文章标签:死磕 Java死磕 Redis
© 版权声明:本文为博主原创文章,遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

关于跳跃表其实在 JUC 里面有一个并发容器就是利用跳跃表来实现的:ConcurrentSkipListMap(【死磕Java并发】—–J.U.C之Java并发容器:ConcurrentSkipListMap)。这篇博客我们来分析 Redis 里面的跳跃表。

skiplist

什么是跳跃表?

百度百科是这么定义的:跳表是一个随机化的数据结构,实质就是一种可以进行二分查找的有序链表。跳表在原有的有序链表上面增加了多级索引,通过索引来实现快速查找。跳表不仅能提高搜索性能,同时也可以提高插入和删除操作的性能。从这里我们我们了解到,跳跃表是一种有序的数据结构,通过在节点上面增加索引(指针)从而达到快速检索的目的。

跳跃表有着不低于红黑树、AVL 数的效率,但是其原理和实现的复杂度要比他们简单多了。他的特性如下:

  • 由很多层结构组成,level是通过一定的概率随机产生的
  • 每一层都是一个有序的链表,默认是升序,也可以根据创建映射时所提供的Comparator进行排序,具体取决于使用的构造方法
  • 最底层(Level 1)的链表包含所有元素
  • 如果一个元素出现在Level i 的链表中,则它在Level i 之下的链表也都会出现
  • 每个节点包含两个指针,一个指向同一链表中的下一个元素,一个指向下面一层的元素

其结构如下:

skiplist的查找

SkipListd的查找算法较为简单,对于上面我们我们要查找元素21,其过程如下:

  1. 比较3,大于,往后找(9),
  2. 比9大,继续往后找(25),但是比25小,则从9的下一层开始找(16)
  3. 16的后面节点依然为25,则继续从16的下一层找
  4. 找到21

如图(绿线所示)

skiplist的增加

SkipList的插入操作主要包括:

  1. 查找合适的位置。这里需要明确一点就是在确认新节点要占据的层次K时,采用丢硬币的方式,完全随机。如果占据的层次K大于链表的层次,则重新申请新的层,否则插入指定层次
  2. 申请新的节点
  3. 调整指针

假定我们要插入的元素为23,经过查找可以确认她是位于25后,9、16、21前。当然需要考虑申请的层次K。 如果层次K > 3 需要申请新层次(Level 4)

如果层次 K = 2 直接在Level 2 层插入即可。

skiplist的删除

删除节点和插入节点思路基本一致:找到节点,删除节点,调整指针。 比如删除节点9,如下:

Redis 中的 skiplist

Redis 使用跳跃表作为 sorted set 的底层实现之一,为了支持 sorted set 本身的一些要求,在经典的 skiplist 基础上,Redis 里面的相应实现做了若干改动。

Redis 中的 skiplist 由 zskiplistNode 和 zskiplist 两个结构定义,其中 zskiplistNode 结构用于表示跳跃表节点,而 zskiplist 结构则用于保存跳跃表节点的相关信息。

zskiplistNode

zskiplistNode结构定义如下:

typedef struct zskiplistNode {

    // 成员对象
    robj *obj;
    
    // 分值
    double score;
    
    // 后退指针,用于指向上一个节点
    struct zskiplistNode *backward;
    
    // 层
    struct zskiplistLevel {
    
         //前进指针指向下一个节点
        struct zskiplistNode *forward;
        //到达后一个节点的跨度(两个相邻节点span为1)
        unsigned int span;
    } level[];//该节点在各层的信息,柔性数组成员
} zskiplistNode;

skiplist 节点的 level 数组可以包含多个元素,每一个元素都包含一个指向其他节点的指针,程序可以通过这些层来加快访问其他节点的速度。我们每次创建一个新的是 skiplist 节点的时候,都会生成一个介于 1 到 32 之间的值来作为 level 数组的大小,这个大小就是 skiplist 的 ”高度“。下图表示带有不同层高的节点:

leve[x].forward 表示该 x + 1 层 的下一个节点。跳跃表节点都会出现在最底层的链表里,所以所有节点都会拥有 level[0],通过 level[0].forward 可以实现跳跃表的正向遍历。

level[x].span 表示该节点到第 x + 1 的下一个节点跳跃了多少个节点。该值越大,说明他们相距越远。指向 NULL 的所有前进指针的跨度都为 0 ,因为他们没有指向任何节点。

score,表示节点的分值,是一个 double 类型的浮点数,跳跃表中的所有节点都按分值从小到大排序。

obj,一个成员指针,他指向一个字符串对象,而字符串对象则保存着一个 SDS。

zskiplist

typedef struct zskiplist {
    // 跳跃表头尾节点
    struct zskiplistNode *header, *tail;
    // 节点个数
    unsigned long length;
    // 除头结点外最大的层数
    int level;
} zskiplist;

header 、tail 分别表示指向跳跃表的表头和表尾;length 属性用来记录跳跃表节点的数量;level 属性用来表示除头节点外的最大层数。

下图是一个完整的 skiplist。

关于 skiplist 的 API 就不多介绍了,有兴趣的同学可以自己去翻源代码。

为什么不用红黑树作为zset底层实现?

对于这个问题,Redis的作者 @antirez 已经说明了:

There are a few reasons:

  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.

重点是这句话:They are not very memory intensive. It's up to you basically. 既然取决于自己,skiplist 实现简单就选它了。至于可能的好处和坏处大概整理了一下有这些:

  • 缺点:
    • 比红黑树占用更多的内存,每个节点的大小取决于该节点的层数
    • 空间局部性较差导致缓存命中率低,感觉上会比红黑树更慢
  • 优点:
    • 实现比红黑树简单
    • 比红黑树更容易扩展,作者之后实现 zrank 指令时没怎么改动代码。
    • 红黑树插入删除时为了平衡高度需要旋转附近节点,高并发时需要锁。skiplist 不需要考虑。
    • 一般用 zset的 操作都是执行 zrange 之类的操作,取出一片连续的节点。这些操作的缓存命中率不会比红黑树低。

参考

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 【死磕 Redis】----- Redis 数据结构: skiplist
上一篇
【死磕 Redis】----- Redis 数据结构:ziplist
下一篇
【死磕 Redis】----- Redis 数据结构: intset