redis中 zset 底层采用散列表+跳跃列表(skiplist)来存储数据。

redis zset有界 redis zset结构_redis

散列表不用多说,set 底层采用散列表来存储,value都为null,通过散列表key的唯一性保证set中元素的不重复。

跳跃列表的结构:

redis zset有界 redis zset结构_redis_02

上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 64 层,意味着最 多可以容纳 2^64 次方个元素。

每一个 kv 块对应的结构如下面的代码中的 zslnode 结构,kv header 也是这个结构,只不过 value 字段是 null 值,score为Double.MIN_VALUE,用来垫底的。

底层的 kv 之间使用指针串起来形成了双向链表结构,它们是有序排列的,从小到大。

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

不同的 kv 层高可能不一样,层数越高的 kv 越少。同一层的 kv 会使用指针串起来。每一个层元素的遍历都是从 kv header 出发。

随机层数

对于新插入的节点,需要调用一个随机算法分配合理的层数。

直观上期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层 2^-63,因为这里每一层的晋升概率是 50%。

不过 Redis 标准源码中的晋升概率只有 25%,也就是代码中的 ZSKIPLIST_P 的值。所以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一点。

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

因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表会记录一下当前的最高层数 maxLevel,遍历时从这个 maxLevel 开始遍历性能就会提高很多。

查找过程

通过逐层查找的方式来查找数据,时间复杂度为 O(logn)

redis zset有界 redis zset结构_redis zset有界_03

如图所示,我们要定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个节点 (最后一个比「我」小的元素)。

然后从这个节点开始降一层再遍历找到第二个节点 (最后一个比「我」小的元素)。

然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最后一个比「我」小的元素)。

插入过程

查找过程中已经找到 最底层的最后一个比“我”小的元素,插入时,只要在这个元素后插入元素即可。

插入时,需修改前后元素的指针。如果新节点的高度大于当前的最大高度,需更新当前的最大高度。

如果所有元素的score值都相同呢?

在一个极端的情况下,zset 中所有的 score 值都是一样的,zset 的查找性能会退化为O(n) 么?Redis 作者自然考虑到了这一点,所以 zset 的排序元素不只看 score 值,如果score 值相同还需要再比较 value 值 (字符串比较)。