1.在Redis中,我们知道zset是用压缩列表或跳表来实现的?那么我想问的是跳表又是怎么实现的?
答:
Redis 为了高效支持这两种需求(既要能按分数有序遍历,又要能快速按成员查找),用了 两种数据结构组合:
| 功能 | 数据结构 | 作用 |
|---|---|---|
| 按成员名查找分数 | 哈希表(dict) | 从成员名 → 分数 |
| 按分数排序 | 跳表(skiplist) | 按分数有序存储 |
👉 也就是说:
zset = 哈希表 + 跳表
两者同时保存相同的元素,只是组织方式不同。
添加、删除、修改时,两者都要更新保持一致。
对于数据:
假设我们执行:
ZADD scores 70 "alice" 90 "bob" 85 "carol"
Redis 内部的跳表会像这样(简化示意):
层3: bob
层2: carol —— bob
层1: alice —— carol —— bob
每个节点包含:
class SkipListNode {
String member; // 成员名,比如 "alice"
double score; // 分数,比如 80.0
SkipListNode backward; // 后退指针
Level[] level; // 每一层的信息
public SkipListNode(String member, double score, int levelCount) {
this.member = member;
this.score = score;
this.level = new Level[levelCount];
for (int i = 0; i < levelCount; i++) {
this.level[i] = new Level();
}
}
// 内部类:表示每一层的指针信息
static class Level {
SkipListNode forward; // 指向同一层的下一个节点
int span; // 跨越的节点数
}
}
- score:分数(排序的关键)
- member:成员名(对应 zset 的元素)
- forward 指针数组:每层都有一个 forward 指针,指向下一个节点
- span:每层记录跨越了多少节点,用来快速计算排名
- backward 指针:指向前一个节点,方便反向遍历
如果有个节点表示 "bob", 95,层数是 3:
- level[0].forward → 指向下一个节点(最低层链表)
- level[1].forward → 跳过一些节点,指向更远的节点
- level[2].forward → 跳得更远
- backward → 指向前一个节点
查找操作如:
- 找到分数 85 以上的前两个成员 → 跳着走,O(log n)
- 查第 2~3 名 → 利用 span 快速计算排名。
2.好,你再讲一下跳表和B+树有什么区别和联系?
答:
一、先看「跳表 Skip List」
1️⃣ 基础想法
普通的有序链表查找一个值,要从头一个个找,O(n) 时间。
跳表的想法是:
“给链表加上几层高速公路,让查找时可以跳着走。”
2️⃣ 结构举例
假设你有一条链表:
1 → 3 → 5 → 7 → 9 → 11 → 13 → 15
现在我们在上面再建“索引层”:
层3: 1 ----------- 9 ------------- 15
层2: 1 ---- 5 ---- 9 ---- 13 ---- 15
层1: 1 3 5 7 9 11 13 15
-
最底层(层1)是完整链表。
-
上面每一层都“跳过”一些节点。
-
查找时,从最上层开始往下跳:
例如要找 11:- 从层3:1 → 9(下一个是15,太大了,往下层)
- 层2:9 → 13(太大,往下层)
- 层1:9 → 11 ✅ 找到。
查找复杂度变成:
👉 O(log n) (因为每层减少一半左右元素)
3️⃣ 插入与删除
- 插入时用随机数决定节点“能上几层”。
- 删除时在每层删对应的节点。
- 整体平均复杂度也是 O(log n)。
二、再看「B+树」
1️⃣ 基础想法
跳表是链表+随机索引,而 B+树是多叉平衡树。
它常用于磁盘存储系统(如 MySQL、文件系统),因为它能把查找次数(磁盘IO)降到最少。
2️⃣ 结构举例
假设是 3 阶 B+树(每个节点最多有3个key):
[10 | 20]
/ | \
[1 5] [11 15] [21 25 30]
- 内部节点存索引(10,20)。
- 叶子节点存数据,并且所有叶子节点之间有链表相连(方便范围查询)。
- 查找时类似二分搜索树,但每次能跳过更多数据。
- 查找、插入、删除都保持 O(log n) 。
3️⃣ 特点
- 所有数据都在叶子节点(不同于B树)。
- 非叶子节点只存索引。
- 叶子节点通过链表连接 → 方便范围扫描。
三、核心对比表
| 对比点 | 跳表(Skip List) | B+树 |
|---|---|---|
| 结构 | 多层链表 | 多叉平衡树 |
| 查找复杂度 | O(log n) | O(log n) |
| 插入删除 | 简单,随机层高 | 复杂,需要分裂/合并节点 |
| 是否随机化 | ✅ 是(随机层) | ❌ 否(严格平衡) |
| 顺序遍历 | 方便(链表结构) | 方便(叶子节点链表) |
| 内存使用 | 较高 | 较低(紧凑) |
| 典型应用 | Redis Sorted Set | MySQL 索引、文件系统 |
我们可以这样理解:
- 跳表是「用随机化模拟平衡树」
- B+树是「严格维护平衡的多叉树」
→ 两者的查找效率相近,但一个追求“内存中快速简单”,一个追求“磁盘中高效少IO”。
3.那为什么Redis中选择跳表而不使用B+树存储数据?
答:
我们先从 MySQL 为什么选择 B+树 讲起:
MySQL 的主要目标是 —— 磁盘存储 + 范围查询效率。
👉 想象一下:
- 磁盘 I/O 很慢,每次读取都要尽量少。
- B+树把所有 数据都放在叶子节点,内部节点只存索引。
- 一个节点大小 ≈ 一个磁盘页(一般 16KB)。
- 这样查询时,只需要少量的磁盘 I/O 就能快速定位到数据。
📌 举个例子:
如果有 1000 万行数据:
- 普通二叉树高度可能是 20 左右,要查 20 次磁盘。
- B+树分支因子很高(几百几千),高度一般只有 3-4。
- 所以磁盘访问次数就大大减少了。
再看 Redis 为什么选择跳表:
Redis 的数据都在 内存 中,内存访问速度飞快,不需要考虑磁盘 I/O。
此时,跳表的优势就凸显出来了:
- 插入/删除方便:跳表比 B+树容易维护,不需要复杂的旋转或分裂。
- 范围查询高效:像有序集合
ZRANGE这种操作,跳表可以从某个节点开始往前扫,非常自然。 - 实现简单:Redis 是 C 语言写的,跳表结构比 B+树更容易实现,代码量少,Bug 概率低。
总结对比:
- MySQL (B+树) :磁盘存储 + 减少 I/O + 批量顺序访问 → 范围查询快
- Redis (跳表) :内存存储 + 快速插入/删除 + 简单实现 → 有序集合操作快
4.最后问一个问题,你刚才提到了磁盘 I/O,那么在访问非叶子节点时要不要 I/O?
答:
有可能涉及 I/O,但通常不会。
为什么呢?
-
根节点:
- 根节点一定会被加载到内存(Buffer Pool)中,几乎不会触发磁盘 I/O。
-
中间节点(非叶子) :
- 因为一个非叶子节点能存几百甚至上千个键,所以树的高度很低(一般 3~4 层)。
- 这些中间节点也会常驻内存(缓存命中率非常高)。
- 所以,通常不会产生额外的磁盘 I/O。
-
叶子节点:
- 叶子节点才存真正的数据记录(或主键值)。
- 如果要查找的记录所在的页不在内存,就需要一次磁盘 I/O。
举个例子:
假设有 1000 万条数据,B+树高度是 4:
- 根节点(内存缓存)
- 第二层非叶子节点(缓存里也大概率有)
- 第三层非叶子节点(可能命中缓存)
- 第四层叶子节点(如果没在缓存,就要从磁盘读)
👉 所以,一次查询通常只会触发 1 次磁盘 I/O(叶子节点) 。
总结:
今日面试到此结束,感谢大家的回答,也祝愿大家在接下来的路上云开路远风轻度,一帆顺意到天涯。