Redis6系列5-底层数据结构(跳跃表)

669 阅读14分钟

欢迎大家关注 github.com/hsfxuebao ,希望对大家有所帮助,要是觉得可以的话麻烦给点一下Star哈

跳跃表是一种有序数据结构,它通过在每个节点中维持多个指向其他节点的指针,从而达到快速访问节点的目的。

在大部分情况下,跳跃表的效率可以和平衡树相媲美,并且因为跳跃表的实现比平衡树要来得更简单,所以不少程序都使用跳跃表来代替平衡树。

Redis 使用跳跃表作为有序集合键的底层实现之一,如果一个有序集合包含的元素数量比较多,又或者有序集合中元素的成员是比较长的字符串时,Redis 就会使用跳跃表来作为有序集合键的底层实现。

有序集合使用两种数据结构来实现,从而可以使插入和删除操作达到O(log(N))的时间复杂度。这两种数据结构是哈希表跳跃表。向哈希表添加元素,用于将成员对象映射到分数;同时将该元素添加到跳跃表,以分数进行排序。

和链表、字典等数据结构被广泛地应用在Redis内部不同,Redis只在两个地方用到了跳跃表,一个是实现有序集合键,另一个是在集群结点中用作内部数据结构。除此之外,跳跃表在Redis里面没有其他用途。

1. 引入跳表原因

数组和链表痛点: image.png

解决办法: 升维,也叫空间换时间。如下图: image.png

2. 数据结构

skiplist是一种 以空间换取时间的结构。由于链表,无法进行二分查找,因此借鉴数据库索引的思想,提取出链表中关键节点(索引),先在关键节点上查找,再进入下层链表查找。 提取多层关键节点,就形成了跳跃表。

2.1 跳跃表节点

Redis 中跳跃表节点定义在 src/server.h 文件中

typedef struct zskiplistNode {
    sds ele;
    double score;
    struct zskiplistNode *backward;
    struct zskiplistLevel {
        struct zskiplistNode *forward;
        unsigned long span;
    } level[];
} zskiplistNode;
  • ele:用于记录跳跃表节点的值,类型是 SDS
  • score:保存跳跃表节点的分值,在跳跃表中,节点按各自所保存的分值从小到大排列。
  • backward:后退指针,它指向当前节点的前一个节点,在程序从表尾向表头遍历时使用。因为一个跳跃表节点只有一个后退指针,所以每次只能后退至前一个节点。
  • level:跳跃表节点的层,每个层都带有两个属性:前进指针 forward 和跨度 span前进指针用于访问位于表尾方向的其他节点,而跨度则记录了前进指针所指向节点和当前节点的距离。每个跳跃表节点最多有 32 层。

2.2 跳跃表

Redis 中跳跃表定义在 src/server.h 文件中

typedef struct zskiplist {
    struct zskiplistNode *header, *tail;
    unsigned long length;
    int level;
} zskiplist;
  • header:指向跳跃表的表头节点。
  • tail:指向跳跃表的表尾节点。
  • length:记录跳跃表的长度,即除去表头节点之外的节点数量之和。
  • level:记录跳跃表内层数最大的那个节点的层数(表头节点的层数不计算在内)。

依靠多个跳跃表节点就可以组成一个跳跃表,但通过使用一个 zskiplist 结构来持有这些节点,程序可以更方便地对整个跳跃表进行处理,比如快速访问跳跃表地表头节点和表尾节点,或者快速地获取跳跃表节点地数量信息。

Redis 的跳跃表实现和 WillianmPugh 在 “ Skip Lists: A Probabilistic Alternative to Balanced Trees ” 中描述的跳跃表算法类似,但又有有些区别:

  • Redis 的跳跃表允许分值重复;
  • Redis 的跳跃表排序不止根据分值,在分值相同的时候会对比成员对象;
  • Redis 的跳跃表有一个后退指针,形成了一个双向链表,可以从表尾向表头遍历,用于 ZREVRANGE 命令的实现。

具体结构图如下: image.png
headertail 指针分别指向跳跃表的表头和表尾节点,通过这两个指针,程序定位表头和表尾节点时间复杂度就是O(1)

通过使用length 属性来记录节点的数量,程序可以在O(1)复杂度返回跳跃表的长度。

level 属性则用于在O(1)复杂度内获取跳跃表中层高最大的那个节点的层数量,注意表头和表尾的层高并不计算在内。

注意:

  • Redis的跳跃表实现由zskiplist和zskiplistNode两个结构组成,其中zskiplist用于保存跳跃表信息(表头节点、表尾节点、长度),而zskiplistNode用于表示跳跃节点

  • 每个跳跃节点的层高都是1-32之间的随机数

  • 在同一个跳跃表中,多个节点可以包含相同的分支,但每个节点的成员对象必须是唯一的

  • 跳跃表中的节点按照分值大小排序,当分值相同时,节点按照成员对象的大小进行排序

3. 增删查操作

跳跃表基于有序单链表,在链表的基础上,每个结点不只包含一个指针,还可能包含多个指向后继结点的指针,这样就可以跳过一些不必要的结点,从而加快查找、删除等操作。如下图就是一个跳跃表:

传统的单链表是一个线性结构,向有序的链表中插入、查找一个结点需要O(n)的时间。如果使用上图的跳跃表,就可以减少查找所需的时间。

跳跃表的插入和删除操作都基于查找操作,理解了查找操作,也就理解了跳跃表的本质。查找就是给定一个key,查找这个key是否出现在跳跃表中。

结合上图,如果想查找19是否存在,从最高层开始,首先和头结点的最高层的后继结点9进行比较,19大于9,因此接着和9在该层上的后继结点21进行比较,小于21,那这个值肯定在9结点和21结点之间。

因此,下移一层,接着和9在该层上的后继结点17进行比较,19大于17,然后和21进行比较,小于21,此时肯定在17结点和21结点之间。

接着下移一层,和17在该层上的后继结点19进行比较,这样就最终找到了。

参考文档:redis中跳跃表

4. 时间和空间复杂度

时间复杂度

  1. 首先每一级索引我们提升了2倍的跨度,那就是减少了2倍的步数,所以是n/2n/4n/8以此类推; 第 k 级索引结点的个数就是 n/(2^k); 

  2. 假设索引有 h 级, 最高的索引有2个结点;n/(2^h) = 2, 从这个公式我们可以求得 h = log2(N)-1; 所以最后得出跳表的时间复杂度是O(logN) 推导详见:从redis跳表实现理解查找时间复杂度

空间复杂度

  1. 首先原始链表长度为n ,如果索引是每2个结点有一个索引结点,每层索引的结点数:n/2, n/4, n/8 ... , 8, 4, 2 以此类推; 
  2. 或者所以是每3个结点有一个索引结点,每层索引的结点数:n/3, n/9, n/27 ... , 9, 3, 1 以此类推; 所以空间复杂度是O(n)

5. 优缺点

  • 跳表是一个最典型的空间换时间解决方案,而且只有在 数据量较大的情况下 才能体现出来优势。而且应该是 读多写少的情况下 才能使用,所以它的适用范围应该还是比较有限的 

  • 维护成本相对要高: 新增或者删除时需要把所有索引都更新一遍; 

  • 最后在新增和删除的过程中的更新,时间复杂度也是O(log n)

6. 跳跃表API

函数作用时间复杂度
zslCreate创建一个新地跳跃表O(1)
zslFree释放跳跃表及其包含地所以节点O(N)
zslInsert将包含给定成员和分值得新节点添加到跳跃表中平均O(log^N),最坏O(N)
zslDelete删除跳跃表中包含给定成员和分值得节点平均O(log^N),最坏O(N)
zslGetRank返回包含给定成员和分值得节点在跳跃表中的排位平均O(log^N),最坏O(N)
zslGetElementByRank返回跳跃表在给定排位上的节点平均O(log^N),最坏O(N)
zslIsInRange判断跳跃表中是否有节点的分值在某个给定分值范围内O(1)
zslFirstInRange返回跳跃表中第一个符个某个分值范围的节点平均O(log^N),最坏O(N)
zslLastInRange返回跳跃表中最后一个符个某个分值范围的节点平均O(log^N),最坏O(N)
zslDeleteRangeByScore删除跳跃表中所有符个某个分值范围的节点O(N)
zslDeleteRangeByRank删除跳跃表中所有符个某个排位范围的节点O(N)

6.1 创建跳跃表

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */

/* Create a skiplist node with the specified number of levels.
 * The SDS string 'ele' is referenced by the node after the call. */
//创建跳跃表节点
zskiplistNode *zslCreateNode(int level, double score, sds ele) {
    zskiplistNode *zn =
        zmalloc(sizeof(*zn)+level*sizeof(struct zskiplistLevel));
    zn->score = score;
    zn->ele = ele;
    return zn;
}

/* Create a new skiplist. */
//创建跳跃表
zskiplist *zslCreate(void) {
    int j;
    zskiplist *zsl;

    zsl = zmalloc(sizeof(*zsl));
    zsl->level = 1;
    zsl->length = 0;
    //一个跳跃表包含一个空的头节点,头节点最多32层
    zsl->header = zslCreateNode(ZSKIPLIST_MAXLEVEL,0,NULL);
    for (j = 0; j < ZSKIPLIST_MAXLEVEL; j++) {
        zsl->header->level[j].forward = NULL;
        zsl->header->level[j].span = 0;
    }
    zsl->header->backward = NULL;
    zsl->tail = NULL;
    return zsl;
}

6.2 跳跃表节点层高随机算法

#define ZSKIPLIST_MAXLEVEL 32 /* Should be enough for 2^64 elements */
#define ZSKIPLIST_P 0.25      /* Skiplist P = 1/4 */

/* Returns a random level for the new skiplist node we are going to create.
 * The return value of this function is between 1 and ZSKIPLIST_MAXLEVEL
 * (both inclusive), with a powerlaw-alike distribution where higher
 * levels are less likely to be returned. */
int zslRandomLevel(void) {
    int level = 1;
    while ((random()&0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level<ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

在 Redis 的跳跃表中,如果新增一个节点,节点的层数是通过函数 zslRandomLevel 随机出来的,节点层数的范围为 1 到 320xFFFF 为 65535,即每次随机都是生成一个 0 到 65535之间的数,总结下来有以下规律:

  1. 因为 level 初始化为 1,所以最终层数为 1 的概率为 1-ZSKIPLIST_P,即 0.75
  2. 当最终层数大于 1 时,每次层数增加的概率都是 ZSKIPLIST_P,那么 n 层的概率是  (1-ZSKIPLIST_P ) * ZSKIPLIST_P ^ (n - 1) ,即 [公式] 。
  3. 因为层数增加的概率是 ZSKIPLIST_P,可以看成是第 k 层的节点数是第 k+1 层节点数的 1/ZSKIPLIST_P 倍,所以 Redis 的跳跃表相当于是一颗四叉树

6.3 跳跃表插入节点

/* Insert a new node in the skiplist. Assumes the element does not already
 * exist (up to the caller to enforce that). The skiplist takes ownership
 * of the passed SDS string 'ele'. */
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    //update数组记录新增节点在每一层的前驱节点
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    //rank数组用于记录新增节点在跳跃表中的排名
    //表头节点排名为0,所以查找到插入节点位置时,经过的节点数量之和就是其排名
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    //x指向跳跃表的头节点
    x = zsl->header;
    //从最高层依次向下遍历
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        //最高层对应的rank数组的元素初始化为0,其他层初始化为上一层对应的数组元素的值
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        //当前层下一个节点存在
        //当前层下一个节点的分值小于插入节点的分值
        //当分值相等时,插入节点的元素大于下一节点元素
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                    sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            //统计当前层查找过的节点跨度之和
            rank[i] += x->level[i].span;
            //节点指针游标后移,继续往后查找插入位置
            x = x->level[i].forward;
        }
        //记录新增节点在当前层的前驱节点
        update[i] = x;
    }
    /* we assume the element is not already inside, since we allow duplicated
     * scores, reinserting the same element should never happen since the
     * caller of zslInsert() should test in the hash table if the element is
     * already inside or not. */
    //我们假定元素还没有插入到跳跃表,尽管我们允许跳跃表存在重复的节点分值,重复插入相同的元素是不允许
    //的,所以在调用 zslInsert 函数之前我们需要测试一下在哈希表中是否已经存在相同的元素。
    //随机出新节点的层数
    level = zslRandomLevel();
    //新节点的层高大于当前跳跃表节点的最大层高
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            //高出的层数只有当前一个节点,所以该层节点跨度之和为0
            rank[i] = 0;
            //高出的层插入节点的前驱节点是头节点
            update[i] = zsl->header;
            //初始化该层头节点的跨度,实际是头节点到尾节点的距离
            update[i]->level[i].span = zsl->length;
        }
        //更新跳跃表的最高层数
        zsl->level = level;
    }
    //创建跳跃表节点
    x = zslCreateNode(level,score,ele);
    for (i = 0; i < level; i++) {
        //在当前层插入新节点(这里和链表插入节点的做法类似)
        x->level[i].forward = update[i]->level[i].forward;
        update[i]->level[i].forward = x;

        /* update span covered by update[i] as x is inserted here */
        //需要计算新增节点的每一层跨度
        //每层的跨度为其后继节点的排名减去新增节点的排名
        //后继节点的排名为:update[i]->level[i].span + rank[i] + 1
        //新增节点的排名为:rank[0] + 1
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        //需要计算新增节点每一层的前驱节点的跨度
        //前驱节点的跨度为:新增节点的排名减去前驱节点的排名
        //新增节点的排名:rank[0] + 1
        //前驱节点的排名为:rank[i]
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    for (i = level; i < zsl->level; i++) {
        //高出的层,新增节点的前驱是头节点,头节点的跨度是距离尾部的长度,因为新增节点,所以跨度加1
        update[i]->level[i].span++;
    }

    //设置后退节点
    //如果新增节点的前驱为头节点,则后退节点为空
    x->backward = (update[0] == zsl->header) ? NULL : update[0];
    //如何新增节点不是尾节点,则设置其下一个节点的后退指针指向新增节点
    //后退节点只能使得逆序遍历时后退一位,所以只判断第一层
    if (x->level[0].forward)
        x->level[0].forward->backward = x;
    else
        zsl->tail = x;
    //跳跃表节点数量增加
    zsl->length++;
    return x;
}

6.4 跳跃表删除节点

/* Internal function used by zslDelete, zslDeleteRangeByScore and
 * zslDeleteRangeByRank. */
void zslDeleteNode(zskiplist *zsl, zskiplistNode *x, zskiplistNode **update) {
    int i;
    for (i = 0; i < zsl->level; i++) {
        if (update[i]->level[i].forward == x) {
            //删除节点x后,需要修改当前层x节点前驱的跨度
            //前驱的跨度:前驱的跨度+x节点的跨度-1
            update[i]->level[i].span += x->level[i].span - 1;
            update[i]->level[i].forward = x->level[i].forward;
        } else {
            update[i]->level[i].span -= 1;
        }
    }
    if (x->level[0].forward) {
        x->level[0].forward->backward = x->backward;
    } else {
        zsl->tail = x->backward;
    }
    while(zsl->level > 1 && zsl->header->level[zsl->level-1].forward == NULL)
        zsl->level--;
    zsl->length--;
}

/* Delete an element with matching score/element from the skiplist.
 * The function returns 1 if the node was found and deleted, otherwise
 * 0 is returned.
 *
 * If 'node' is NULL the deleted node is freed by zslFreeNode(), otherwise
 * it is not freed (but just unlinked) and *node is set to the node pointer,
 * so that it is possible for the caller to reuse the node (including the
 * referenced SDS string at node->ele). */
int zslDelete(zskiplist *zsl, double score, sds ele, zskiplistNode **node) {
    //update数组记录要删除的节点在每一层的前驱
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    int i;

    x = zsl->header;
    for (i = zsl->level-1; i >= 0; i--) {
        while (x->level[i].forward &&
                (x->level[i].forward->score < score ||
                    (x->level[i].forward->score == score &&
                     sdscmp(x->level[i].forward->ele,ele) < 0)))
        {
            x = x->level[i].forward;
        }
        update[i] = x;
    }
    /* We may have multiple elements with the same score, what we need
     * is to find the element with both the right score and object. */
    //同样的分值可能会对应不用的元素,只有分值和元素值都相当的才是要删除的节点
    x = x->level[0].forward;
    if (x && score == x->score && sdscmp(x->ele,ele) == 0) {
        //x 为要删除的节点
        zslDeleteNode(zsl, x, update);
        if (!node)
            zslFreeNode(x);
        else
            *node = x;
        return 1;
    }
    return 0; /* not found */
}

面试:为啥 redis 使用跳表(skiplist)而不是使用 red-black?

  1. skiplist的复杂度和红黑树一样,而且实现起来更简单。
  2. 在并发环境下skiplist有另外一个优势,红黑树在插入和删除的时候可能需要做一些rebalance的操作,这样的操作可能会涉及到整个树的其他部分,而skiplist的操作显然更加局部性一些,锁需要盯住的节点更少,因此在这样的情况下性能好一些。

具体可以参考Herb Sutter写的Choose Concurrency-Friendly Data Structures.

另外这篇论文里有更详细的说明和对比,page50~53: www.cl.cam.ac.uk/research/sr…

附:开发者说的为什么选用skiplist The Skip list

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.

About the Append Only durability & speed, I don't think it is a good idea to optimize Redis at cost of more code and more complexity for a use case that IMHO should be rare for the Redis target (fsync() at every command). Almost no one is using this feature even with ACID SQL databases, as the performance hint is big anyway.

About threads: our experience shows that Redis is mostly I/O bound. I'm using threads to serve things from Virtual Memory. The long term solution to exploit all the cores, assuming your link is so fast that you can saturate a single core, is running multiple instances of Redis (no locks, almost fully scalable linearly with number of cores) , and using the "Redis Cluster" solution that I plan to develop in the future.

参考文档:
redis-6.06 底层数据结构系列
SkipList 浅析
Redis源码解析:跳跃表 深入理解Redis跳跃表的基本实现和特性