本篇是redis系列的第四篇。本文主要围绕redis 有序集合 结构展开讨论
Redis中的sorted set,是在skiplist, dict和ziplist基础上构建起来的:
- 当数据较少时,sorted set是由一个ziplist(另起一篇文章单独介绍)来实现的。
- 当数据多的时候,sorted set是由一个叫zset的数据结构来实现的,这个zset包含一个dict + 一个skiplist。dict用来查询数据到分数(score)的对应关系,而skiplist用来根据分数查询数据(可能是范围查找,skiplist根据分数查找数据)。
sorted set ziplist 实现
在这里我们先来讨论一下前一种情况——基于ziplist实现的sorted set。ziplist就是由很多数据项组成的一大块连续内存。由于sorted set的每一项元素都由数据和score组成,因此,当使用zadd命令插入一个(数据,score)对的时候,底层在相应的ziplist上就插入两个数据项:数据在前,score在后。
ziplist的主要优点是节省内存,但它上面的查找操作只能按顺序查找(可以正序也可以倒序)。因此,sorted set 的各个查询操作,就是在ziplist上从前向后(或从后向前)一步步查找,每一步前进两个数据项,跨域一个(数据, score)对。
随着数据的插入,sorted set底层的这个ziplist就可能会转成zset的实现。那么到底插入多少才会转呢?
zset-max-ziplist-entries 128
zset-max-ziplist-value 64
- 当sorted set中的元素个数,即(数据, score)对的数目超过128的时候,也就是ziplist数据项超过256的时候。
- 当sorted set中插入的任意一个数据的长度超过了64的时候。
sorted set ziplist实现
Redis 的 zset 是一个复合结构,一方面它需要一个 hash 结构来存储 value 和 score 的对应关系,另一方面需要提供按照 score 来排序的功能,还需要能够指定 score 的范围来获取 value 列表的功能,这就需要另外一个结构「跳跃列表」。
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
zset 的内部实现是一个 hash 字典加一个跳跃列表 (skiplist)。hash 结构在讲字典结构时已经详细分析过了(见字典),它很类似于 Java 语言中的 HashMap 结构
在读下面内容之前不懂跳表的可以先行阅读 什么是跳表 这篇文章,以对跳表有个大概的了解。
上图就是跳跃列表的示意图,图中只画了四层,Redis 的跳跃表共有 32 层。每一个 kv 块对应的结构如下面的代码中的zslnode结构,header 也是这个结构,只不过 value 字段是 null 值——无效的,score 是 Double.MIN_VALUE,用来垫底的。节点 之间使用指针串起来形成了双向链表结构,它们是 有序 排列的,从小到大。不同的 节点层高可能不一样,层数越高的 节点越少。同一层的 节点会使用指针串起来。每一个层元素的遍历都是从 header 出发。
typedef struct zskiplistNode {
robj *obj;
double score;
struct zskiplistNode *backward;
struct zskiplistLevel {
struct zskiplistNode *forward;
unsigned int span;
} level[];
} zskiplistNode;
typedef struct zskiplist {
struct zskiplistNode *header, *tail;
unsigned long length;
int level;
} zskiplist;
设想如果跳跃列表只有一层会怎样?插入删除操作需要定位到相应的位置节点 (定位到最后一个比「我」小的元素,也就是第一个比「我」大的元素的前一个),定位的效率肯定比较差,复杂度将会是 O(n),因为需要挨个遍历。也许你会想到二分查找,但是二分查找的结构只能是有序数组。跳跃列表有了多层结构之后,这个定位的算法复杂度将会降到 O(lg(n))。
如图所示,我们要定位到那个紫色的 kv,需要从 header 的最高层开始遍历找到第一个节点 (最后一个比「我」小的元素),然后从这个节点开始降一层再遍历找到第二个节点 (最后一个比「我」小的元素),然后一直降到最底层进行遍历就找到了期望的节点 (最底层的最后一个比我「小」的元素)。
我们将中间经过的一系列节点称之为「搜索路径」,它是从最高层一直到最底层的每一层最后一个比「我」小的元素节点列表。
有了这个搜索路径,我们就可以插入这个新节点了。不过这个插入过程也不是特别简单。因为新插入的节点到底有多少层,得有个算法来分配一下,跳跃列表使用的是随机算法。
随机层数
对于每一个新插入的节点,都需要调用一个随机算法给它分配一个合理的层数。我们知道传统的跳跃表每层晋升的概率是50%,所以第一层的概率是: 50% 的 Level1,25% 的 Level2,12.5% 的 Level3,一直到最顶层。。。。 ,
不过 Redis 标准源码中的晋升概率只有 25%。所以官方的跳跃列表更加的扁平化,层高相对较低,在单个层上需要遍历的节点数量会稍多一点。节点最大的层数不允许超过一个最大值,记为MaxLevel(redis里为32)。
直接上代码,这里我是用java实现了下插入的过程。
/**
* 链表从小到大排序
* @param <E>
*/
public class SkipList <E extends Comparable<? super E>> {
//跳表层数32层: 定义成32层理论上对于2^32-1个元素的查询最优。
transient final int MAX_LEVEL = 32;
//层级晋升概率
transient final double p =0.25;
//当前跳表的有效层
transient int validLevel = 0;
transient int size;
//跳表的头部节点
transient ZskiplistNode<E> header ;
//跳表的尾节点
transient ZskiplistNode<E> last ;
public SkipList(){
header = new ZskiplistNode<E>(MAX_LEVEL, null,0);
validLevel=1;
}
public ZskiplistNode<E> add(E val,double score) {
//从头结点开始搜索,遍历结束后 cur节点为要插入节点位置左边最近的一个节点
ZskiplistNode<E> cur = header;
// 记录搜索的路径节点
ZskiplistNode<E>[] update = new ZskiplistNode[MAX_LEVEL];
// 存储搜索路径节点的 排名
int rank [] = new int[MAX_LEVEL];
/**
* 从所有节点中的最高层级开始搜索,效率最好
* 逐步降级寻找目标节点,得到「搜索路径」
* 从header节点开始遍历,横向遍历 找到下一个节点比较,
* 1、若大于则把node.next[i].forward赋给cur当前遍历节点,然后开始遍历cur节点
* 2、若等于则判断node.next[i].forward节点值是否大于val,值大于执行1
* 3、若分值相等 val小于, 则继续向下遍历header(redis里需要先在字典里判断val是否存在,所以redis里跳表里 val不会重复,score可以相等)
* 把搜索的路径存起来,方便后续维护新节点的next数组的指针
*
* 假设插入的val值不在当前链表中
*/
for (int i = validLevel-1; i >= 0; i--){
//rank[i]用来记录 update[i]距离header的距离,搜索路径节点的排名
// rank[i]初始化为上一层所跨越的节点总数,因为上一层已经加过
rank[i] = i == validLevel ? 0 : rank[i+1];
//遍历链表,一直遍历到 要插入的结点位置的最近的一个节点
while (cur.next[i].forward!=null
&& (cur.next[i].forward.score<score ||
// 这里允许score相等,但是val不会重复
(cur.next[i].forward.score == score && cur.next[i].forward.val.compareTo(val)< 0))
){
//横着搜索的时候累加
rank[i] +=cur.next[i].span;
//记录跨越了多少个节点
cur = cur.next[i].forward;
}
update[i]=cur;
}
int level = randomLevel();
//如果随机出的层数 大于当前最高层,更新多出来的那几层
if (level > validLevel){
for (int i = validLevel; i < level; i++){
// 跨度都为0
rank[i] = 0;
update[i] = header;
update[i].next[i].span = size;
}
//更新最高层
validLevel = level;
}
//创建新节点
cur = new ZskiplistNode(level,val,score);
//为新节点更新前置指针(搜索路径执行新节点)和后置指针(搜索路径的forward赋值给新节点的forward)
for (int i = 0; i < level; i++) {
//值得注意的一点 update[i] 到cur.next[i] 之间肯定没有节点了
//跳跃表新节点的每一层,改变其forward指针的指向(搜索路径节点的每一层指针 赋给新节点)
cur.next[i].forward = update[i].next[i].forward;
//搜索路径节点的 指向新节点
update[i].next[i].forward = cur;
//更新跨度
cur.next[i].span = update[i].next[i].span - (rank[0] - rank[i]);
update[i].next[i].span = (rank[0] - rank[i])+1;
}
// 更新高层的span值
for (int i = level;i<validLevel;i++){
update[i].next[i].span++;
}
// 重排一下后向指针
cur.backward =(update[0] == header)?null:update[0];
//如果插入节点的0层存前向节点则前向节点的后向指针为插入节点
if (cur.next[0].forward != null){
cur.next[0].forward.backward =cur;
}else{
//否则该节点为跳跃表的尾节点
last=cur;
}
size++;
return cur;
}
static class ZskiplistNode <E extends Comparable<? super E>> {
/*
* 节点存储的值Val
*/
E val;
double score;
/*
* 节点指向第i层的节点next[i]
*/
ZskiplistLevel [] next;
ZskiplistNode <E> backward;
ZskiplistNode (int MAX_LEVEL, E val,double score) {
this.next = new ZskiplistLevel [MAX_LEVEL];
this.val = val;
this.score =score;
for (int i = 0;i<MAX_LEVEL;i++){
next[i]=new ZskiplistLevel();
}
}
}
@Override
public String toString() {
StringBuilder sb = new StringBuilder();
ZskiplistNode<E> cur = header,next =null;
for (int i = validLevel-1; i >= 0; i--) {
sb.append("第["+i+"]层");
next =cur.next[i].forward;
while (next != null) {
sb.append(next.val + ","+next.score+" ");
next = next.next[i].forward;
}
sb.append("\n");
}
return sb.toString();
}
static class ZskiplistLevel{
ZskiplistNode forward;
/**
* 跨度,用来计算 排名,next[i]到前一节点的距离 不包括开始节点(包括结束节点)的距离
*/
int span;
}
public int size() {
return size;
}
private int randomLevel(){
int level = 1;
while (Math.random()< p && level < this.MAX_LEVEL){
level =level+1;
}
return level;
}
public static void main(String [] args){
SkipList skipList =new SkipList();
Random random = new Random();
for (int i = 0; i< 100 ;i++){
int a = random.nextInt(100);
skipList.add("str"+i,a);
}
System.out.println(skipList.toString());
}
}
首先我们在搜索合适插入点的过程中将「搜索路径」摸出来了,然后就可以开始创建新节点了,创建的时候需要给这个节点随机分配一个层数,再将搜索路径上的节点和这个新节点通过前向后向指针串起来。如果分配的新节点的高度高于当前跳跃列表的最大高度,就需要更新一下跳跃列表的最大高度。
删除过程和插入过程大致相同,这里我只是实现了插入过程。redis里的更新操作是先删除过插入。有兴趣的同学可以接着往下实现。