Redis -SortedSet

880 阅读10分钟

在需要使用排行榜的场景下,相信大家一定会考虑使用redis的sorted set来实现;但是redis的sortedset是怎么实现有序集合的呢?如何保证高效的实现的呢?本文将从底层原理向大家介绍redis的sortedset

简介

Redis 有序集合和集合一样也是 string 类型元素的集合,且不允许重复的成员。

不同的是每个元素都会关联一个 double 类型的分数。redis 正是通过分数来为集合中的成员进行从小到大的排序。

有序集合的成员是唯一的,但分数(score)却可以重复。

集合是通过哈希表实现的,所以添加,删除,查找的复杂度都是 O(1)。 集合中最大的成员数为 232 - 1 (4294967295, 每个集合可存储40多亿个成员)。

使用实例

redis 127.0.0.1:6379> ZADD test 1 redis
(integer) 1
redis 127.0.0.1:6379> ZADD test 2 wwwyyt
(integer) 1
redis 127.0.0.1:6379> ZADD test 3 wwwwyyt
(integer) 1
redis 127.0.0.1:6379> ZADD test 3 wwwwwyyt
(integer) 0
redis 127.0.0.1:6379> ZADD test 4 wwwwwwyyt
(integer) 0
redis 127.0.0.1:6379> ZRANGE test 0 10 WITHSCORES

1) "redis"
2) "1"
3) "wwwyyt"
4) "2"
5) "wwwwyyt"
6) "3"

使用指令

序号命令描述
1ZADD key score1 member1 [score2 member2]向有序集合添加一个或多个成员,或者更新已存在成员的分数
2ZCARD key获取有序集合的成员数
3ZCOUNT key min max计算在有序集合中指定区间分数的成员数
4ZINCRBY key increment member有序集合中对指定成员的分数加上增量 increment
5ZINTERSTORE destination numkeys key [key ...]计算给定的一个或多个有序集的交集并将结果集存储在新的有序集合 destination 中
6ZLEXCOUNT key min max在有序集合中计算指定字典区间内成员数量
7ZRANGE key start stop [WITHSCORES]通过索引区间返回有序集合指定区间内的成员
8ZRANGEBYLEX key min max [LIMIT offset count]通过字典区间返回有序集合的成员
9ZRANGEBYSCORE key min max [WITHSCORES] [LIMIT]通过分数返回有序集合指定区间内的成员
10ZRANK key member返回有序集合中指定成员的索引
11ZREM key member [member ...]移除有序集合中的一个或多个成员
12ZREMRANGEBYLEX key min max移除有序集合中给定的字典区间的所有成员
13ZREMRANGEBYRANK key start stop移除有序集合中给定的排名区间的所有成员
14ZREMRANGEBYSCORE key min max移除有序集合中给定的分数区间的所有成员
15ZREVRANGE key start stop [WITHSCORES]返回有序集中指定区间内的成员,通过索引,分数从高到低
16ZREVRANGEBYSCORE key max min [WITHSCORES]返回有序集中指定分数区间内的成员,分数从高到低排序
17ZREVRANK key member返回有序集合中指定成员的排名,有序集成员按分数值递减(从大到小)排序
18ZSCORE key member返回有序集中,成员的分数值
19ZUNIONSTORE destination numkeys key [key ...]计算给定的一个或多个有序集的并集,并存储在新的 key 中
20ZSCAN key cursor [MATCH pattern] [COUNT count]迭代有序集合中的元素(包括元素成员和元素分值)

redis stored set的原理

Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的 对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获 取 value 列表的功能,这就需要另外一个结构「跳跃列表」。

pic1.png

zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。hash 结构在讲字典结构很类似于 Java 语言中的 HashMap 结构。

深入skiplist的原理

skiplist的基本结构

pic2.png


上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 64 层,意味着最 多可以容纳 2^64 次方个元素。每一个 kv 块对应的结构如下面的代码中的 zslnode 结构,kv header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是 Double.MIN_VALUE,用来垫底的。kv 之间使用指针串起来形成了双向链表结构,它们是 有序 排列的,从小到大。不同的 kv 层高可能不一样,层数越高的 kv 越少。同一层的 kv 会使用指针串起来。每一个层元素的遍历都是从 kv header 出发。

struct zslnode {
string value;
double score;
zslnode*[] forwards; // 多层连接指针
zslnode* backward; // 回溯指针
}
struct zsl {
zslnode* header; // 跳跃列表头指针
int maxLevel; // 跳跃列表当前的最高层
map<string, zslnode*> ht; // hash 结构的所有键值对
}

查找元素过程

设想如果跳跃列表只有一层会怎样?插入删除操作需要定位到相应的位置节点 (定位到 最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比 较差,复杂度将会是 O(n),因为需要挨个遍历。也许你会想到二分查找,但是二分查找的结 构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。

pic3.png

如图所示,我们要定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个 节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最 后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最 后一个比我「小」的元素)。

我们将中间经过的一系列节点称之为「搜索路径」,它是从最高层一直到最底层的每一 层最后一个比「我」小的元素节点列表。 有了这个搜索路径,我们就可以插入这个新节点了。不过这个插入过程也不是特别简 单。因为新插入的节点到底有多少层,得有个算法来分配一下,跳跃列表使用的是随机算 法。

随机层数

对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。直观上 期望的目标是 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层 2^-63, 因为这里每一层的晋升概率是 50%。

/* 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 标准源码中的晋升概率只有 25%,也就是代码中的 ZSKIPLIST_P 的值。所 以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一 点。 也正是因为层数一般不高,所以遍历的时候从顶层开始往下遍历会非常浪费。跳跃列表 会记录一下当前的最高层数 maxLevel,遍历时从这个 maxLevel 开始遍历性能就会提高很 多。

插入过程

下面是插入过程的源码,它稍微有点长,不过整体的过程还是比较清晰的。

/* 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) {
    // 存储搜索路径
    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 不存在,那就是插入过程。如果这个 value 已经存在了,只是调整一下 score 的值,那就需要走一个更新的流程。假设这个新的 score 值不会带来排序位置上的改变,那么就不需要调整位置,直接修改元素的 score 值就 可以了。但是如果排序位置改变了,那就要调整位置。

如果 score 值都一样呢?

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

元素排名是怎么算出来的 ?

前面我们啰嗦了一堆,但是有一个重要的属性没有提到,那就是 zset 可以获取元素的排 名 rank。那这个 rank 是如何算出来的?如果仅仅使用上面的结构,rank 是不能算出来的。 Redis 在 skiplist 的 forward 指针上进行了优化,给每一个 forward 指针都增加了 span 属 性,span 是「跨度」的意思,表示从前一个节点沿着当前层的 forward 指针跳到当前这个节 点中间会跳过多少个节点。Redis 在插入删除操作时会小心翼翼地更新 span 值的大小。

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

这样当我们要计算一个元素的排名时,只需要将「搜索路径」上的经过的所有节点的跨 度 span 值进行叠加就可以算出元素的最终 rank 值。

ziplist解析

Redis 为了节约内存空间使用,zset 和 hash 容器对象在元素个数较少的时候,采用压 缩列表 (ziplist) 进行存储。压缩列表是一块连续的内存空间,元素之间紧挨着存储,没有任何冗余空隙

struct ziplist<T> {
    int32 zlbytes; // 整个压缩列表占用字节数
    int32 zltail_offset; //最后一个元素距离压缩列表起始位置的偏移量,用于快速定位到最后一个节点
    int16 zllength; // 元素个数
    T[] entries; // 元素内容列表,挨个挨个紧凑存储
    int8 zlend; // 标志压缩列表的结束,值恒为 0xFF
}

pic4.png

压缩列表为了支持双向遍历,所以才会有 ztail_offset 这个字段,用来快速定位到最后一 个元素,然后倒着遍历。 entry 块随着容纳的元素类型不同,也会有不一样的结构。

struct entry {
    int<var> prevlen; // 前一个 entry 的字节长度
    int<var> encoding; // 元素类型编码
    optional byte[] content; // 元素内容
}

它的 prevlen 字段表示前一个 entry 的字节长度,当压缩列表倒着遍历时,需要通过这 个字段来快速定位到下一个元素的位置。它是一个变长的整数,当字符串长度小于 254(0xFE) 时,使用一个字节表示;如果达到或超出 254(0xFE) 那就使用 5 个字节来表 示。第一个字节是 0xFE(254),剩余四个字节表示字符串长度。你可能会觉得用 5 个字节来 表示字符串长度,是不是太浪费了。我们可以算一下,当字符串长度比较长的时候,其实 5 个字节也只占用了不到(5/(254+5))<2%的空间。

pic5.png

encoding 字段存储了元素内容的编码类型信息,ziplist 通过这个字段来决定后面的 content 内容的形式。 Redis 为了节约存储空间,对 encoding 字段进行了相当复杂的设计。Redis 通过这个字 段的前缀位来识别具体存储的数据形式。下面我们来看看 Redis 是如何根据 encoding 的前缀 位来区分内容的:

1、00xxxxxx 最大长度位 63 的短字符串,后面的 6 个位存储字符串的位数,剩余的字 节就是字符串的内容。

2、01xxxxxx xxxxxxxx 中等长度的字符串,后面 14 个位来表示字符串的长度,剩余的 字节就是字符串的内容。

3、10000000 aaaaaaaa bbbbbbbb cccccccc dddddddd 特大字符串,需要使用额外 4 个字节 来表示长度。第一个字节前缀是 10,剩余 6 位没有使用,统一置为零。后面跟着字符串内 容。不过这样的大字符串是没有机会使用的,压缩列表通常只是用来存储小数据的。

4、11000000 表示 int16,后跟两个字节表示整数。

5、11010000 表示 int32,后跟四个字节表示整数。

6、11100000 表示 int64,后跟八个字节表示整数。

7、11110000 表示 int24,后跟三个字节表示整数。

8、11111110 表示 int8,后跟一个字节表示整数。

9、11111111 表示 ziplist 的结束,也就是 zlend 的值 0xFF。

10、1111xxxx 表示极小整数,xxxx 的范围只能是 (00011101), 也就是 113,因为 0000、1110、1111 都被占用了。读取到的 value 需要将 xxxx 减 1,也就是整数 0~12 就是最终的 value。

注意到 content 字段在结构体中定义为 optional 类型,表示这个字段是可选的,对于很 小的整数而言,它的内容已经内联到 encoding 字段的尾部了。

增加元素

因为 ziplist 都是紧凑存储,没有冗余空间 (对比一下 Redis 的字符串结构)。意味着插 入一个新的元素就需要调用 realloc 扩展内存。取决于内存分配器算法和当前的 ziplist 内存大小,realloc 可能会重新分配新的内存空间,并将之前的内容一次性拷贝到新的地址,也可能在原有的地址上进行扩展,这时就不需要进行旧内容的内存拷贝。

如果 ziplist 占据内存太大,重新分配内存和拷贝内存就会有很大的消耗。所以 ziplist 不适合存储大型字符串,存储的元素也不宜过多。

级联更新

前面提到每个 entry 都会有一个 prevlen 字段存储前一个 entry 的长度。如果内容小于 254 字节,prevlen 用 1 字节存储,否则就是 5 字节。这意味着如果某个 entry 经过了修改操作从 253 字节变成了 254 字节,那么它的下一个 entry 的 prevlen 字段就要更新,从 1个字节扩展到 5 个字节;如果这个 entry 的长度本来也是 253 字节,那么后面 entry 的prevlen 字段还得继续更新。

如果 ziplist 里面每个 entry 恰好都存储了 253 字节的内容,那么第一个 entry 内容的 修改就会导致后续所有 entry 的级联更新,这就是一个比较耗费计算资源的操作。

IntSet 小整数集合

当 set 集合容纳的元素都是整数并且元素个数较小时,Redis 会使用 intset 来存储结合 元素。intset 是紧凑的数组结构,同时支持 16 位、32 位和 64 位整数

struct intset<T> {
    int32 encoding; // 决定整数位宽是 16 位、32 位还是 64 位
    int32 length; // 元素个数
    int<T> contents; // 整数数组,可以是 16 位、32 位和 64 位
}

pic6.png