Redis 只有 Zset 对象的底层实现用到了跳表,跳表的优势是能支持平均 O(logN) 复杂度的节点查找
Zset 结构体中有两个数据结构:一个是跳表,一个是哈希表。这样的好处是既能进行高效的范围查询,也能进行高效单点查询
// Zset
typedef struct zset {
dict *dict;
zskiplist *zsl;
} zset;
Zset 在执行增删改时,会依次在跳表和哈希表中进行修改,从而保证跳表和哈希表中记录的信息一致
跳表结构设计
链表在查找元素时,需要逐一查找,时间复杂度是 O(N)。跳表是在链表基础上改进的,实现了一种「多层」的有序链表,能够快速定位数据
// 跳表节点
typedef struct zskiplistNode {
sds ele; // 元素值
double score; // 分值(权重值)
struct zskiplistNode *backward; // 后向指针(指向前一个节点)
// 「多层」数组
struct zskiplistLevel {
struct zskiplistNode *forward; // 前向指针(指向后一个节点)
unsigned long span; // 跨度
} level[];
} zskiplistNode;
- 后向指针:指向前一个节点,目的是方便倒序查找
level
数组:实现「多层」的有序链表- 前向指针:指向后一个节点
- 跨度:记录两个节点之间的距离
遍历操作只需要用前向指针就可以完成,与跨度无关
跨度实际上是为了计算节点在跳表中的排位,排位(Rank)是指从跳表的头节点到目标节点之间跨越的节点总数,即该节点在有序集合中的位置索引。通过累加每一层的 span
,可以快速计算出当前节点的排位
图中的头节点也是 zskiplistNode
跳表节点,只不过后向指针、权重、元素值都没有用到,所以图中省略了这部分
// 跳表
typedef struct zskiplist {
struct zskiplistNode *header, *tail; // 头尾节点
unsigned long length; // 跳表长度
int level; // 跳表最大层数
} zskiplist;
跳表节点查询过程
从头节点的最高层开始,比较当前层下一个节点和要查找的元素:
- 下一个节点为空
- 下一个节点的权重「大于」要查找元素的权重
- 下一个节点的权重「等于」要查找元素的权重,但下一个节点的 SDS 类型的元素值字典序「大于」要查找元素
上述条件只要有一个成立,就会跳到下一层,否则跳到当前层的下一个元素
即:从头节点的最高层开始向右比较,比较成功就向右跳,比较失败就向下跳
跳表节点层数设置
跳表相邻两层的节点数量比例会影响跳表的查询性能,最理想的比例是 2:1,查找复杂度可以降低到 O(logN)
怎样维持相邻两层节点数量的比例为 2:1?
如果在增删节点时,来调整跳表节点以维持比例的话,会带来额外的开销
Redis 采用一种随机的方法(创建节点时随机生成层数),并没有严格维持相邻两层的节点数量比例为 2:1
具体的做法是,跳表在创建节点时,生成一个范围
[0-1)
的随机数,如果小于 0.25,那么层数增加 1 层,然后继续生成下一个随机数,直到大于 0.25相当于每增加一层的概率不超过 25%,层数越高,概率越低,层高最大限制是 32。在创建跳表「头节点」时,会直接创建 32 层高的头节点
层高最大限制:Redis 3.0 定义为 32,Redis 5.0 定义为 64,Redis 7.0 定义为 32
为什么用跳表而不用平衡树?
一个常见的面试题:为什么 Zset 的实现用跳表而不用平衡树(如 AVL 树、红黑树等)?
- 内存占用上,跳表比平衡树更灵活:
- 平衡树每个节点包含 2 个指针(分别指向左右子树)
- 跳表每个节点包含的指针数目平均为
1/(1-p)
,Redis 的实现取p = 0.25
,平均每个节点包含 1.33 个指针,比平衡树更有优势
- 范围查找时,跳表比平衡树操作要简单:
- 在平衡树上,找到指定范围的小值之后,还需要以中序遍历继续寻找其它不超过大值的节点。如果不对平衡树进行一定的改造,这里的中序遍历并不容易实现
- 而在跳表上进行范围查找就非常简单,只需要在找到小值之后,对第 1 层链表进行若干步的遍历就可以
- 实现难度上,跳表比平衡树简单得多:
- 平衡树的插入和删除操作可能引发子树的调整,逻辑复杂
- 而跳表的插入和删除只需要修改相邻节点的指针,操作简单又快速