本文大部分内容引自《Redis深度历险:核心原理和应用实践》,感谢作者!!!
ZSET的功能及特性
1、Redis的zset类似于SortedSet和HashMap的结合体,一方面它需要一个hash结构来存储value和score的对应关系,另一方面需要提供按照score来排序的功能,还需要能够指定score的范围来获取value列表的功能,所以需要使用到“跳跃列表”
2、zset内部是一个hash字典加一个跳跃列表(skiplist),hash结构类似于Java中的HashMap
基本结构
上图中的跳跃列表一共有四层,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 结构的所有键值对
}
跳跃列表的构造和描述
跳跃表描述:
- 一个跳跃列表由几层组成
- 底层包含所有元素
- 每一层都是一个有序链表
- 在层 i 中的元素按某个固定的概率 p (通常为0.5或0.25)出现在层 i+1 中(也就是说高层中的元素必然在低层)
- 第i层的某个元素可以向下访问与它有相同值的下层节点
level 4:1
level 3:1-----4---6
level 2:1---3-4---6-----9
level 1:1-2-3-4-5-6-7-8-9-10
查找过程
1、如果要定位到全紫色的kv,需要从header的最高层遍历找到第一个节点(最后一个比我小的元素),然后从这个节点开始降一层再遍历找到第二个节点(最后一个比我小的元素),然后一直降到最底层进行便利就找到了期望的节点(最底层的最后一个比我小的元素)
2、将中间经过的一系列节点称为"搜索路径",他是从最高层一直到最底层的每一层最后一个比我小的元素节点列表
3、有了这个搜索路径就可以插入新节点了,跳跃列表使用随机算法其实是来分配新节点插入的层数
随机层数
/* 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;
}
1、对于每一个新插入的节点,都需要调用一个随机算法分配一个合理的层数;直观上期忘的目标是50%的Level1,25%的Level2,12.5%的Level3,一直到最顶层2^-63,每一层的晋升概率是50%(上一层概率的50%)
2、Redis标准源码中的晋升概率只有25%,也就是代码中的ZSKIPLIST_P的值;所以官方的(Redis官方)的跳跃列表更加扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍微多一些
3、因为层数一般不高,所以在遍历的时候从顶层开始往下遍历会非常浪费;跳跃列表会记录当前的最高层书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值就可以了;如果排序位置改变了,就需要调整位置,算法如下:
/* 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采用的策略是先删除再插入
score值都一样的处理方法
在极端情况下,如果zset中所有的score值都是一样的;zset的排序元素不止看score的值,如果score值相同还需要再比较value的值
元素排名的算法
zset可以获取元素的排名rank,如果仅仅使用skiplist则无法得到rank的值;Redis在skiplist的forward指针上进行了优化,给每一个forward指针都增加了span属性,span是跨度的疑似,表示从前一个节点沿着当前层的forward指针跳到当前这个节点中间会跳过多少个节点,Redis在插入删除操作时会更新span的值;当需要计算一个元素的排名时,只需要将搜索路径上经过的所有结点跨度span值进行叠加就可以算出元素的最终rank值
struct zslforward {
zslnode* item;
long span; // 跨度
}
struct zsl {
String value;
double score;
zslforward*[] forwards; // 多层连接指针
zslnode* backward; // 回溯指针
}
拓展阅读