redis高性能数据结构之有序集

时间:2022-07-23
本文章向大家介绍redis高性能数据结构之有序集,主要内容包括其使用实例、应用技巧、基本知识点总结和需要注意事项,具有一定的参考价值,需要的朋友可以参考一下。

背景

已经讲了两个数据结构了,今天我们来讲一下在redis中最具有特色的数据结构zset(有序列表)

ZSET

简介

zset有序列表,显而易见意思就是一个有序且是不重复上的数据结构,它类似于Java中的sortset和hashmap的结合体,但是在redis中是通过两种底层数据结构实现的。一种是ziplist压缩列表,另一种就是redis中最经典的数据结构skipList跳跃表。

底层数据结构的选择

第一次插入数据结构的选择

  1. 在使用ZDD 命令添加第一个元素到空key时,程序通过检查输入的第一个元素来决定该创建什么编码的有序集。
  2. 符合下面的条件就会创建ziplist
    • 服务器属性server.zset_max_ziplist_entries 的值大于 0
    • 元素的member长度小于服务器属性server.zset_max_ziplist_value的值(默认64)
  3. 不符合上面的条件就使用skiplist跳跃表实现编码。

后期编码转换

  1. 当刚开始选择了ziplist,会在下面两种情况下转为skipList。
    • ziplist所保存的元素超过服务器属性server.zset_max_ziplist_entries 的值(默认值为 128 )
    • 新添加元素的 member 的长度大于服务器属性 server.zset_max_ziplist_value 的值(默认值为 64 )、
  2. 那我们是否思考一下为什么需要转换呢?在总结Hash对象的时候我们已经讲到了。ziplist 是一个紧挨着的存储空间,并且是没有预留空间的,随意对于ziplist优势在于节省空间,但是在容量大到一定成度扩容就是影响他的性能的主要原因之一。我们接下来再看看skipList是如何解决这些问题呢?

SKIPLIST(跳跃表)

简介

  1. redis的skipList 因为是有序的,所以需要一个hash结构来存储value和score的对应关系,另一方面需要提供按照score来排序的功能,还能够指定score的范围来获取value列表的功能,上述也就是跳跃表要实现的功能
  2. 跳跃表(skiplist)是一种随机化的数据, 由 William Pugh 在论文《Skip lists: a probabilistic alternative to balanced trees》中提出, 跳跃表以有序的方式在层次化的链表中保存元素, 效率和平衡树媲美 —— 查找、删除、添加等操作都可以在对数期望时间下完成, 并且比起平衡树来说, 跳跃表的实现要简单直观得多。

基本数据结构

  1. 上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 64 层,意味着最 多可以容纳 2^64 次方个元素。每一个 kv 块对应的结构如下面的代码中的 zslnode 结构,kv header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是 Double.MIN_VALUE,用来垫底的。kv 之间使用指针串起来形成了双向链表结构,它们是 有序 排列的,从小到大。不同的 kv 层高可能不一样,层数越高的 kv 越少。同一层的 kv 会使用指针串起来。每一个层元素的遍历都是从 kv header 出发。
  2. 更加清晰的的跳跃表实现文章:https://lotabout.me/2018/skip-list/
  3. 了解了跳表的实现我们和ziplist对比一下,肯定是币ziplist是消耗内寸空间的,但是他的查询效率是很高的。并且他不需要连续的内存空间,所以说他对内存是更友好的,当内存剩余120KB的不连续的内存时你使用链表还可以存储,但是你使用ziplist这样的数据结构存储不了,因为他需要的是连续的120KB。

源码

typedef struct zskiplist {

    // 头节点,尾节点
    struct zskiplistNode *header, *tail;

    // 节点数量
    unsigned long length;

    // 目前表内节点的最大层数
    int level;

} zskiplist;
typedef struct zskiplistNode {

    // member 对象
    robj *obj;

    // 分值
    double score;

    // 后退指针
    struct zskiplistNode *backward;

    // 层
    struct zskiplistLevel {

        // 前进指针
        struct zskiplistNode *forward;

        // 这个层跨越的节点数量
        unsigned int span;

    } level[];

} zskiplistNode;
查找
  • 设想如果跳跃列表只有一层会怎样?插入删除操作需要定位到相应的位置节点 (定位到 最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比 较差,复杂度将会是 O(n),因为需要挨个遍历。也许你会想到二分查找,但是二分查找的结 构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。
  • 看这篇文章可以快速理解:https://lotabout.me/2018/skip-list/
插入源码
/* 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;
第 210 页 共 226 页
Redis 深度历险:核心原理与应用实践 | 钱文品 著
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 值就 可以了。但是如果排序位置改变了,那就要调整位置。那该如何调整位置呢?
/* Remove and re-insert when score changes. */
if (score != curscore) {
    zskiplistNode *node;
    serverAssert(zslDelete(zs->zsl,curscore,ele,&node));
    znode = zslInsert(zs->zsl,score,node->ele);
    /* We reused the node->ele SDS string, free the node now
    * since zslInsert created a new one. */
    node->ele = NULL;
    zslFreeNode(node);
    /* Note that we did not removed the original element from
    * the hash table representing the sorted set, so we just
    * update the score. */
    dictGetVal(de) = &znode->score; /* Update score ptr. */
    *flags |= ZADD_UPDATED;
}
return 1;

一个简单的策略就是先删除这个元素,再插入这个元素,需要经过两次路径搜索。Redis 就是这么干的。 不过 Redis 遇到 score 值改变了就直接删除再插入,不会去判断位置是否 需要调整。

总结

  • redis的ZSET数据结构有两种编码方式:ziplist skiplist
  • ziplist 和 skiplist的切条件触发可以进行自定义设置
  • 跳跃表是一种随机化数据结构,查找、添加、删除操作都可以在对数期望时间下完成。
  • 还有我们大概看了下redis的skip底层数据结构

上面文章也有说道红黑树,博主也有看到很多面试题是这样问的:

  • redis的有序集为什么使用skiplist 而不使用红黑树来实现呢?

答案:

作者原话:

  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. (自行谷歌翻译,没有看不起你的意思!!) 其他答案:
  4. https://www.zhihu.com/question/20202931
  5. https://blog.csdn.net/hebtu666/article/details/102556064