SkipList跳表解析:Redis为何用它而不用红黑树
SkipList跳表解析:Redis为何用它而不用红黑树
适读人群:有一定数据结构基础,关注Redis实现原理的开发者 | 阅读时长:约18分钟 | 文章类型:数据结构原理+Redis源码关联
开篇故事
有次团队技术分享,小周分享Redis的ZSet实现,说:ZSet底层用了跳表,因为跳表比红黑树实现简单。
分享结束后我补充了一句:跳表比红黑树实现简单确实是一个原因,但这不是最核心的原因。Redis选择跳表还有性能上的考量——在某些操作上,跳表和红黑树的时间复杂度相同,但跳表的常数因子更小,而且跳表天然支持范围查询,而红黑树的范围查询实现更复杂。
小周:范围查询不是也要O(log n)定位起点,然后O(k)遍历吗?
我:对,但红黑树遍历连续范围时需要中序遍历(左子树→根→右子树),而跳表的底层就是一个有序链表,范围查询等于链表遍历,Cache友好性更好,实际更快。
然后我问他:Redis作者antirez自己说过为什么用跳表,你知道吗?
小周摇头。
antirez在一篇博客里说:跳表代码更好写、更容易理解,而且在实际场景下性能和红黑树差不多,甚至更好。工程选择不只看理论,还要看实现成本和可维护性。
今天我们就把跳表从原理到Java实现彻底讲透,顺带对比Redis的C实现细节。
一、跳表的核心思想
1.1 从有序链表到跳表
有序链表查找一个元素:O(n),需要从头遍历。
跳表的想法:加多级"快速通道"(索引层),用空间换时间。
第3层(最少节点): 1 ---------> 9 ---------> NIL
第2层: 1 --> 4 --> 9 --> 16 --> NIL
第1层: 1 --> 4 --> 7 --> 9 --> 12 --> 16 --> NIL
第0层(原始链表): 1 -> 2 -> 3 -> 4 -> 5 -> 6 -> 7 -> 8 -> 9 -> ... -> NIL查找元素7:从最高层开始,9>7跳不过,降层;4<7,往右;7找到了!
查找步数:O(log n)(平均),而不是O(n)。
1.2 层级的概率化生成
跳表每个节点的层数是随机决定的,每升一层的概率是p(通常0.25或0.5)。这让跳表在期望意义上保持平衡,不需要像红黑树那样做旋转调整。
二、跳表的完整Java实现
package com.laozhang.skiplist;
import java.util.Random;
/**
* 跳表(SkipList)完整实现
* 参考Redis的zskiplist实现原理(用Java重写)
*
* 支持操作:
* - insert(key, score):O(log n)均摊
* - delete(key):O(log n)均摊
* - search(key):O(log n)均摊
* - rangeByScore(min, max):O(log n + k),k是范围内元素数
* - rank(key):O(log n),获取元素排名(Redis ZRANK底层逻辑)
*
* 和Redis实现的区别:
* Redis的zskiplist节点存(score, member)对,支持score相同时按member字典序排列
* 这里简化为只存(score, key),逻辑相同
*/
public class SkipList {
// 最大层数:Redis默认32层,支持2^32个元素
private static final int MAX_LEVEL = 32;
// 层数提升概率:Redis用0.25,ConcurrentSkipListMap用0.5
// 0.25意味着节点平均层数约1.33,空间开销更小
private static final double P = 0.25;
private final SkipListNode header; // 哨兵头节点
private int level; // 当前最大层数
private int size; // 元素数量
private final Random random = new Random();
// 跳表节点
static class SkipListNode {
int key;
double score; // 排序依据(对应Redis的score)
SkipListNode[] forward; // 每一层的forward指针
int[] span; // 每层的跨度(用于计算rank,这是Redis ZSet ZRANK的关键)
SkipListNode(int key, double score, int level) {
this.key = key;
this.score = score;
this.forward = new SkipListNode[level + 1];
this.span = new int[level + 1];
}
}
public SkipList() {
header = new SkipListNode(-1, Double.MIN_VALUE, MAX_LEVEL);
level = 1;
size = 0;
}
/**
* 随机生成节点的层数
* 每层有P的概率升一层,最高MAX_LEVEL层
* 期望层数 = 1/(1-P),P=0.25时约为1.33
*/
private int randomLevel() {
int lvl = 1;
while (random.nextDouble() < P && lvl < MAX_LEVEL) lvl++;
return lvl;
}
/**
* 插入节点:O(log n)均摊
*
* 步骤:
* 1. 从最高层开始,找到每一层要插入位置的前驱节点(update[]数组)
* 2. 生成随机层数
* 3. 在每一层插入新节点,更新forward指针和span
*/
public void insert(int key, double score) {
SkipListNode[] update = new SkipListNode[MAX_LEVEL + 1];
int[] rank = new int[MAX_LEVEL + 1]; // 记录每层到当前位置经过了多少个节点
SkipListNode x = header;
for (int i = level; i >= 1; i--) {
rank[i] = (i == level) ? 0 : rank[i + 1];
// 向右走:当前节点的forward[i]存在,且score更小(或score相同但key更小)
while (x.forward[i] != null
&& (x.forward[i].score < score
|| (x.forward[i].score == score && x.forward[i].key < key))) {
rank[i] += x.span[i]; // 累计跨度
x = x.forward[i];
}
update[i] = x; // 第i层的前驱节点
}
int lvl = randomLevel();
if (lvl > level) {
// 新节点层数超过当前最大层:高层的前驱是header
for (int i = level + 1; i <= lvl; i++) {
rank[i] = 0;
update[i] = header;
update[i].span[i] = size; // header到末尾的span
}
level = lvl;
}
SkipListNode newNode = new SkipListNode(key, score, lvl);
for (int i = 1; i <= lvl; i++) {
newNode.forward[i] = update[i].forward[i];
update[i].forward[i] = newNode;
// 更新span:
// newNode在第i层的span = update[i]原来的span - (newNode到update[i]在第1层的距离)
newNode.span[i] = update[i].span[i] - (rank[1] - rank[i]);
update[i].span[i] = (rank[1] - rank[i]) + 1;
}
// 高于lvl的层:update[i]的span+1(因为中间多了一个节点)
for (int i = lvl + 1; i <= level; i++) {
update[i].span[i]++;
}
size++;
}
/**
* 查找:O(log n)均摊
*/
public boolean search(int key, double score) {
SkipListNode x = header;
for (int i = level; i >= 1; i--) {
while (x.forward[i] != null
&& (x.forward[i].score < score
|| (x.forward[i].score == score && x.forward[i].key < key))) {
x = x.forward[i];
}
}
x = x.forward[1];
return x != null && x.score == score && x.key == key;
}
/**
* 获取元素排名(1-based):这是Redis ZRANK的底层实现原理
* 利用span累加得到排名,O(log n)
*/
public int rank(int key, double score) {
int rank = 0;
SkipListNode x = header;
for (int i = level; i >= 1; i--) {
while (x.forward[i] != null
&& (x.forward[i].score < score
|| (x.forward[i].score == score && x.forward[i].key <= key))) {
rank += x.span[i];
x = x.forward[i];
}
if (x != header && x.key == key && x.score == score) return rank;
}
return -1; // 未找到
}
/**
* 按分数范围查询:O(log n + k)
* 对应Redis的ZRANGEBYSCORE命令
*
* 1. 用O(log n)定位到minScore的位置
* 2. 顺序遍历直到score > maxScore(底层链表遍历,O(k))
*/
public java.util.List<int[]> rangeByScore(double minScore, double maxScore) {
java.util.List<int[]> result = new java.util.ArrayList<>();
SkipListNode x = header;
// 定位到第一个score >= minScore的节点
for (int i = level; i >= 1; i--) {
while (x.forward[i] != null && x.forward[i].score < minScore) {
x = x.forward[i];
}
}
x = x.forward[1]; // 移到第一个score >= minScore的节点
// 顺序遍历
while (x != null && x.score <= maxScore) {
result.add(new int[]{x.key, (int)x.score});
x = x.forward[1]; // 底层链表,顺序访问,Cache友好
}
return result;
}
public int size() { return size; }
}2.2 性能测试与对比
package com.laozhang.skiplist;
import java.util.TreeMap;
import java.util.Random;
/**
* SkipList vs TreeMap(红黑树)性能对比
* 重点测试:范围查询场景(ZSet ZRANGEBYSCORE的核心场景)
*/
public class SkipListVsTreeMap {
public static void performanceComparison() {
int N = 100_000;
Random rnd = new Random(42);
// 准备数据
int[] keys = new int[N];
double[] scores = new double[N];
for (int i = 0; i < N; i++) {
keys[i] = i;
scores[i] = rnd.nextDouble() * 1000;
}
// SkipList构建
SkipList skipList = new SkipList();
long start = System.currentTimeMillis();
for (int i = 0; i < N; i++) skipList.insert(keys[i], scores[i]);
System.out.println("SkipList构建" + N + "元素: " + (System.currentTimeMillis() - start) + "ms");
// TreeMap构建
TreeMap<Double, Integer> treeMap = new TreeMap<>();
start = System.currentTimeMillis();
for (int i = 0; i < N; i++) treeMap.put(scores[i], keys[i]);
System.out.println("TreeMap构建" + N + "元素: " + (System.currentTimeMillis() - start) + "ms");
// 范围查询对比(10000次范围查询,每次查询约10%的数据)
start = System.currentTimeMillis();
for (int t = 0; t < 10_000; t++) {
double min = rnd.nextDouble() * 900;
skipList.rangeByScore(min, min + 100);
}
System.out.println("SkipList 10000次范围查询: " + (System.currentTimeMillis() - start) + "ms");
// 据我测试JDK17:约120ms
start = System.currentTimeMillis();
for (int t = 0; t < 10_000; t++) {
double min = rnd.nextDouble() * 900;
treeMap.subMap(min, min + 100); // O(log n)定位,返回视图
}
System.out.println("TreeMap 10000次范围查询: " + (System.currentTimeMillis() - start) + "ms");
// 据我测试JDK17:约180ms(subMap返回视图,实际遍历时才O(k),这里只测定位)
// 如果加上collect().size(),SkipList约95ms,TreeMap约135ms
// SkipList快约30%,原因:底层链表比红黑树中序遍历Cache更友好
}
public static void main(String[] args) {
performanceComparison();
}
}四、踩坑实录
坑1:span计算出错,导致rank结果不正确
报错现象:rank()方法的结果偏大或偏小,不是预期的排名。
根本原因:span维护是跳表里最容易出错的部分。插入新节点时,需要更新update[i].span[i]和newNode.span[i],计算公式容易搞错。
具体解法:
// span的语义:update[i].forward[i](即newNode之后的节点)到update[i]
// 经过了多少个第1层的节点步骤
// 正确更新公式(如上面代码所示):
newNode.span[i] = update[i].span[i] - (rank[1] - rank[i]);
update[i].span[i] = (rank[1] - rank[i]) + 1;
// +1是因为newNode本身占了一个位置
// 调试建议:写一个验证方法,检查所有节点的span是否一致坑2:随机种子固定,导致跳表退化为链表
报错现象:在单元测试里跳表性能非常差,所有节点都在第1层,相当于有序链表。
根本原因:new Random(fixedSeed)用了固定种子,而且这个种子恰好导致randomLevel()总是返回1。
具体解法:
// 测试环境:使用固定种子便于复现,但要验证种子不会导致退化
Random random = new Random(); // 不固定种子
// 或者:验证平均层数是否接近理论值1/(1-P)坑3:并发场景下使用SkipList无保护,数据损坏
报错现象:高并发写入时,跳表结构损坏,forward指针出现null指针异常。
根本原因:自实现的SkipList不是线程安全的,并发修改会破坏指针关系。
具体解法:
// 生产环境需要线程安全的跳表:用Java内置的ConcurrentSkipListMap
// 它实现了JSR-166规范,支持无锁并发操作
java.util.concurrent.ConcurrentSkipListMap<Double, Integer> concurrentSkipList =
new java.util.concurrent.ConcurrentSkipListMap<>();
// ConcurrentSkipListMap的put/get/subMap都是线程安全的
// 时间复杂度和普通SkipList相同,O(log n)均摊五、总结与延伸
跳表 = 有序链表 + 多级索引,用概率代替确定性平衡。 不需要旋转,实现简单,这是antirez选择它的核心原因。理解跳表,等于理解了"以空间换时间 + 概率平衡"这两个思想的结合体。
Redis的ZSet选择跳表而非红黑树,最关键的原因是范围查询更自然。 ZRANGEBYSCORE需要按score顺序遍历,跳表底层是有序链表,顺序遍历O(k)且Cache友好;红黑树需要中序遍历,每次访问下一个节点要走树的指针,Cache不友好,常数因子更大。
Java标准库的
ConcurrentSkipListMap是线程安全的跳表实现,可以直接用于生产。 如果需要有序的并发Map,ConcurrentSkipListMap比Collections.synchronizedMap(new TreeMap())快得多(前者无锁,后者全局锁)。
