跳跃列表
redis的zset是一个复合结构,其需要一个hash结构存储value和score的对应关系,
还需提供按照score排序的功能,以及能够根据指定score范围获取value列表的功能,这就需要用到另一个结构,skiplist(跳跃列表),
zset的内部实现就是一个hash+skiplist。
基本结构
跳表就是将元素分层,最下面的是所有的元素,然后在一段元素之间向上提升一层,以此推,最终达到一个类似二分查找效果,如下图:
skiplist结构:
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个元素,每一个kv块对应的结构就是zslnode,kv header也是这个结构,
不过kv header的value是NULL,score是Double.MIN_VALUE,用作垫底,指向下一个kv。
kv之间使用指针串联起来,形成双向链表结构,其顺序有序排列,从小到大,每个kv的层高可能不同,层数越高,kv越少,
同一层的kv使用指针串起来,每一层的遍历都是由kv header出发,即kv header保存了每一层的第一个kv。
查找过程
插入、删除,均需要找到最后一个比操作元素小的元素,以及第一个比操作元素大的元素,如果跳表只有一层,
那么定位的效率将会比较差,复杂度是O(n),由于是链表,因此需要挨个遍历,二分查找只能针对于有序数组,
因此跳表的多层结构,可以把复杂度降低到O(log n)。
如果需要定位到上图中紫色的kv,从header最高层开始遍历找到最后一个比紫色KV小的(第一个)元素,然后从该节点下降一层,再找到该层最后一个比紫色KV小的(第二个)元素,依此类推,直到找到紫色KV,
在查找过程中经过的节点被称为 搜索路径 ,它是从最高层一直到最低层的过程中,每个最后一个比紫色KV小的元素集合,有了搜索路径,就可以插入新节点了。
随机层数
对于新插入的节点,需要调用一个随机算法为其分配一个合理的层数,redis中的跳表,每个新节点的层次晋升概率是25%,因此skiplist相对来说比较扁平化,层高相对较低,在单个层上遍历的节点数量会稍多一些,
因为层数不高,遍历的时候如果从最顶层(64层)往下遍历会比较浪费资源,因此跳表会记录一下当前的最高层数maxLevel,遍历时直接从该层进行遍历,提高性能。
随机算法代码:
int zslRandomLevel(void) {
int level = 1;
while((random() & 0xFFFF) < (ZSKIPLIST_P * 0xFFFF))
level += 1;
return (level < ZSKIPLIST_MAXLEVEL) ? level : ZSKIPLIST_MAXLEVEL;
}
插入过程
查找合适的插入点时,先进行遍历,把搜索路径找出来,然后开始创建新节点,创建时需要给新节点分配一个层数,再将搜索路径上的节点和这个新节点通过前后指针串起来,
如果分配的新节点的高度高于当前跳跃列表的最大高度,那么则需要更新一下跳跃列表的最大高度。
// 跳表插入新节点
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已经存在,但是score值变了,也就是更新score值,就需要走更新流程,
redis在发现score值发生变化时,会直接将该元素删除,然后重新插入,并不会判断该score值是否会导致顺序的调整,简单粗暴。
score值相同
如果在极端情况下,zset中的score值都是一样的,那么zset将会继续比对value值之间的大小,以此作为排序依据,这样防止score值相同,算法复杂度退化为O(n)。
元素排名
zset还可以获取到元素在集合中的rank,其是通过skiplist上的forward指针上增加了span属性,
该属性记录了从前一个节点沿着当前层的forward指针跳到当前这个节点中会跳过多少节点,
这样计算一个元素的排名时,将搜索路径的节点span值求和即可算出rank,
redis在插入、删除时会维护span值的更新。
struct zslforward {
zslnode* item;
long span; // 跨度
}
struct zsl {
String value;
double score;
zslforward*[] forwards; // 多层连接指针
zslnode* backward; // 回溯指针
}