Redis之跳跃列表

189 阅读5分钟

跳跃列表

redis的zset是一个复合结构,其需要一个hash结构存储value和score的对应关系,

还需提供按照score排序的功能,以及能够根据指定score范围获取value列表的功能,这就需要用到另一个结构,skiplist(跳跃列表),

zset的内部实现就是一个hash+skiplist。

image.png

基本结构

跳表就是将元素分层,最下面的是所有的元素,然后在一段元素之间向上提升一层,以此推,最终达到一个类似二分查找效果,如下图:

image.png

skiplist结构:

struct zslnode {
    string value;
    double score;
    zslnode*[] forwards; // 多层连接指针
    zslnode* backward;   // 回溯指针
}

struct zsl {
    zslnode* header;          // 跳跃列表头指针
    int maxLevel;             // 跳跃列表当前的最高层
    map<string, zslnode*> ht; // hash结构的所有键值对
}

redis中的跳表共有64层,可以容纳2^64个元素,每一个kv块对应的结构就是zslnode,kv header也是这个结构,

不过kv header的value是NULL,score是Double.MIN_VALUE,用作垫底,指向下一个kv。

kv之间使用指针串联起来,形成双向链表结构,其顺序有序排列,从小到大,每个kv的层高可能不同,层数越高,kv越少,

同一层的kv使用指针串起来,每一层的遍历都是由kv header出发,即kv header保存了每一层的第一个kv。

查找过程

插入、删除,均需要找到最后一个比操作元素小的元素,以及第一个比操作元素大的元素,如果跳表只有一层,

那么定位的效率将会比较差,复杂度是O(n),由于是链表,因此需要挨个遍历,二分查找只能针对于有序数组,

因此跳表的多层结构,可以把复杂度降低到O(log n)。

image.png

如果需要定位到上图中紫色的kv,从header最高层开始遍历找到最后一个比紫色KV小的(第一个)元素,然后从该节点下降一层,再找到该层最后一个比紫色KV小的(第二个)元素,依此类推,直到找到紫色KV,

在查找过程中经过的节点被称为 搜索路径 ,它是从最高层一直到最低层的过程中,每个最后一个比紫色KV小的元素集合,有了搜索路径,就可以插入新节点了。

随机层数

对于新插入的节点,需要调用一个随机算法为其分配一个合理的层数,redis中的跳表,每个新节点的层次晋升概率是25%,因此skiplist相对来说比较扁平化,层高相对较低,在单个层上遍历的节点数量会稍多一些,

因为层数不高,遍历的时候如果从最顶层(64层)往下遍历会比较浪费资源,因此跳表会记录一下当前的最高层数maxLevel,遍历时直接从该层进行遍历,提高性能。

随机算法代码:

int zslRandomLevel(void) {
    int level = 1;
    while((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
        level += 1;
    return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}

插入过程

查找合适的插入点时,先进行遍历,把搜索路径找出来,然后开始创建新节点,创建时需要给新节点分配一个层数,再将搜索路径上的节点和这个新节点通过前后指针串起来,

如果分配的新节点的高度高于当前跳跃列表的最大高度,那么则需要更新一下跳跃列表的最大高度。

// 跳表插入新节点
zskiplistNode *zslInsert(zskiplist *zsl, double score, sds ele) {
    // 存储搜索路径
    zskiplistNode *update[ZSKIPLIST_MAXLEVEL], *x;
    // 存储经过的节点跨度
    unsigned int rank[ZSKIPLIST_MAXLEVEL];
    int i, level;

    serverAssert(!isnan(score));
    x = zsl->header;
    // 逐步降级寻找目标节点,得到搜索路径
    for (i = zsl->level-1; i >= 0; i--) {
        /* store rank that is crossed to reach the insert position */
        rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
        // 如果score相等,还需要比较value
        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. */
    // 进入插入过程,获取层数
    level = zslRandomLevel();
    // 填充跨度
    if (level > zsl->level) {
        for (i = zsl->level; i < level; i++) {
            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 */
        x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
        update[i]->level[i].span = (rank[0] - rank[i]) + 1;
    }

    /* increment span for untouched levels */
    for (i = level; i < zsl->level; i++) {
        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;
}

删除过程

删除过程与插入过程类似,也需要将搜索路径找出来,然后对于每个层的相关节点重排前后指针,最后更新最高层数maxLevel。

更新过程

如果zadd时,这个value已经存在,但是score值变了,也就是更新score值,就需要走更新流程,

redis在发现score值发生变化时,会直接将该元素删除,然后重新插入,并不会判断该score值是否会导致顺序的调整,简单粗暴。

score值相同

如果在极端情况下,zset中的score值都是一样的,那么zset将会继续比对value值之间的大小,以此作为排序依据,这样防止score值相同,算法复杂度退化为O(n)。

元素排名

zset还可以获取到元素在集合中的rank,其是通过skiplist上的forward指针上增加了span属性,

该属性记录了从前一个节点沿着当前层的forward指针跳到当前这个节点中会跳过多少节点,

这样计算一个元素的排名时,将搜索路径的节点span值求和即可算出rank,

redis在插入、删除时会维护span值的更新。

struct zslforward {
    zslnode* item;
    long span; // 跨度
}

struct zsl {
    String value;
    double score;
    zslforward*[] forwards; // 多层连接指针
    zslnode* backward;      // 回溯指针
}