今日面试之快问快答:Redis篇

40 阅读7分钟

redisblog.png

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 SetMySQL 索引、文件系统

我们可以这样理解:

  • 跳表是「用随机化模拟平衡树
  • 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,但通常不会

为什么呢?

  1. 根节点

    • 根节点一定会被加载到内存(Buffer Pool)中,几乎不会触发磁盘 I/O。
  2. 中间节点(非叶子)

    • 因为一个非叶子节点能存几百甚至上千个键,所以树的高度很低(一般 3~4 层)。
    • 这些中间节点也会常驻内存(缓存命中率非常高)。
    • 所以,通常不会产生额外的磁盘 I/O。
  3. 叶子节点

    • 叶子节点才存真正的数据记录(或主键值)。
    • 如果要查找的记录所在的页不在内存,就需要一次磁盘 I/O。

举个例子:

假设有 1000 万条数据,B+树高度是 4:

  • 根节点(内存缓存)
  • 第二层非叶子节点(缓存里也大概率有)
  • 第三层非叶子节点(可能命中缓存)
  • 第四层叶子节点(如果没在缓存,就要从磁盘读)

👉 所以,一次查询通常只会触发 1 次磁盘 I/O(叶子节点)

总结:

今日面试到此结束,感谢大家的回答,也祝愿大家在接下来的路上云开路远风轻度,一帆顺意到天涯。