redis的有序集合zset类似于Java的SoretedSet和HashMap的结合体,一方面它是一个set,可以保证内部value的唯一性,另一方面它可以给每个value赋予一个score,代表这个score的排序权重。
zset可以用来存储学生的成绩,value值是学生的ID,score是学生的考试成绩,可以通过对成绩按分数进行排名得到学生名词。还可以用来存储粉丝列表,value值是粉丝的用户ID,score是关注时间,可以对粉丝列表按关注时间进行排序。
1.redis有序集合的基本操作
zadd key [NX|XX] [CH] [INCR] score member [score member …] #向有zset添加成员及其分数,若已存在,则进行更改
zcard key #获取zset中的成员数量
zcount key min max #获取分数范围内的成员数量
zrange key start stop [WITHSCORES] #返回zset中坐标start到stop之间的成员,注意zset中顺序是按score排列的,不是插入顺序
zrevrange key start stop [WITHSCORES] #返回zset中坐标start到stop之间的成员,此时zset中顺序是按score逆序排列的
zscore key member #获取指定成员的分数
zrank key member #获取成员在zset中的排名
zrem key member [member …] #删除成员
redis的zset是一个复合结构,一方面它需要一个hash结构来存储value和score的对应关系,另一方面需要提供按照score排序的功能,还需要能够指定score的范围来获取value列表的功能,需求不单一必然导致它的结构是一个复杂结构。
zset的内部实现是一个字典hash+跳跃列表skiplist,字典在之前的字典结构已经分析过了Redis基础数据结构——字典,现在重点看跳跃列表的结构。
3.跳跃列表的内部实现
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个元素。每一个块对应结构体代码中的zslnode,haed块的结构也是一样的,只是value值为NULL,score为Double.MIN_VALUE,用以垫底。
zslnode之间使用指针串起来形成双向链表结构,它们是从小到大有序排列的。不同的zslnode的层高可能不同,层数越高的zslnode越少,同一层zslnode使用指针串起来,每层元素遍历都是从header出发的。
显然,跳跃列表是一种层级结构,一个元素可以处于多个层级中,在操作过程中可以快速在不同层级之间进行跳跃,因而被称为跳跃列表。
4.跳跃列表的操作
(1)查找过程
图所示为查找value3,对应score为3的元素,也就是查找蓝色块的元素的步骤。
1.首先从此时header的最高层L3开始遍历,试图找到这一层最后一个比目标元素分数小的元素,遍历到的第一个元素为6,比3大,说明L3这一层无法找到3,停止遍历L3层,搜索路径中将3号位置的节点置为header。转入下一层开始遍历。
2.从header的L2层开始遍历,遍历到的第一个元素为4,比3大,说明L2这一层无法找到3,停止遍历L2层,搜索路径中将2号位置的节点置为header,转入下一层开始遍历。
3.从header的L1层开始遍历,遍历到的第一个元素为2,比3小,标记它。之后继续往前走,遍历到的第二个元素为4,比3大,说明这一层找不到3了,停止遍历L1层。搜索路径中将1号位置的节点置为刚才标记的value2,标记的元素为2,则从2的下一层开始找。
4.从2的L0层开始遍历,遇到的第一个元素即为3,找到了value3。搜索路径中将0号位置的节点置为value3。
将中间经过的一系列节点称为“搜索路径”,它是一个元素节点列表,存放了从最高层到最底层每一层最后一个小于目标元素的元素。上述过程的搜索路径为[value3, value2, header, header]
有了搜索路径之后,就可以插入新节点了。对于每一个新插入的节点,都需要调用一个随机算法来给它分配一个合理的层数。
具体的查找过程代码见后面的插入过程。
//新节点随机层数
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的值,也就是被分配到上一层的概率是这一层的50%。跳跃列表会记录下当前的最高层数ZSKIPLIST_MAXLEVEL,遍历时从这个maxlevel开始遍历。
(2)插入过程
//跳跃列表插入节点
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--){
//存储到达插入位置经过的层号
rank[i] = i == (zsl->level-1) ? 0 : rank[i+1];
//找到这一层中score小于目标元素的最后一个元素
while (x->level[i].forward &&
(x->level[i].forward->score < score ||
//如果score相等,需要比较value
(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;
}
/*将每一层遇到的小于目标元素的最后一个元素节点置入搜索路径
*对应层的位置,若是该层未遇到这样的结点,则将header填入*/
update[i] = x;
}
/*插入过程*/
//随机分配一个层数
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[i]覆盖更新跨度
x->level[i].span = update[i]->level[i].span - (rank[0] - rank[i]);
update[i]->level[i].span = (rank[0] - rank[i]) + 1;
}
//还没有到达的层数的跨度也需要增加
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;
//跳跃列表的元素个数+1
zsl->length++;
return x;
}
由代码可见,插入过程分为如下四步:
1.从当前最高层逐逐步降层寻找目标节点,得到搜索路径,搜索路径由后向前填写。
2.分配随机层数,若是得到的随机层数高于目前的最高层数,需要将搜索路径的内容扩充至该随机层数,并将当前最高层数更新为该随机层数
3.创建新节点
4.将新节点插入,插入过程类似于双向链表的插入,只不过跳跃列表的插入要对每一层的前向指针都要重置。
注意上面的代码中涉及很多span的修改,span又是什么呢?
redis的zset为了支持获取元素的排名rank,在skiplist的forward指针上进行了优化,给每个forward指针增加了span属性,span是“跨度”的意思,表示从前一个节点沿着当前层的forward指针跳到当前这个节点中间会跳过多少个节点。
struct zslforward {
zslnode* item;
long span; //跨度
}
struct zsl {
String value;
double score;
zslforward*[] forwards; //多层前向指针
zslnode* backward; //后向指针
}
这样当要计算一个元素的排名时,只需要将搜索路径列表中存放的所有节点的跨度值进行叠加就可以算出元素的最终rank值。
(2)删除过程
删除过程与插入过程类型,都是先得到搜索路径,然后处理下指针进行删除即可。
(3)更新过程
//当score改变时,删除这个元素,然后将它重新插入
if (score != curscore) {
zskiplistNode *node;
serverAssert(zslDetete(zs->zsl, curscore, ele, &node));
znode = zslInsert(zs->zsl, score, node->ele);
node->ele = NULL;
zslFreeNode(node);
dictGetVal(de) = &znode->score;
}
return 1;
进行更新时,若是更新后的score不会改变排序的话,则只需要进行一次查找即可。若是更新后的score会改变排序的话,需要对位置进行重排,很麻烦。
由代码可见,redis使用的更新策略是先删除这个元素,然后再将这个元素插入进来即可。