Skip to content

B+ 树索引:MySQL 的「字典查找术」

你有没有想过这个问题:

MySQL 的 InnoDB 引擎,为什么选择 B+ 树作为默认索引结构,而不是二叉树、红黑树、B 树?

有人说:「因为 B+ 树查询快啊。」

这话对,但太浅了。

今天,我们从磁盘 I/O 的视角出发,彻底理解 MySQL 为什么选 B+ 树。


从一个故事开始

想象你要在一本《新华字典》中查「 MySQL 」这个词。

你会怎么做?

方法一:从第一页开始,一页一页翻,直到找到目标。运气好翻几页,运气差翻几万页。

方法二:先查拼音索引(部首索引),锁定大概范围,然后再翻到具体页码。

显然,方法二是人类的最优选择。

MySQL 选择 B+ 树,就是给数据造了一本「字典」。


为什么不用二叉树或红黑树?

二叉树的特点是每个节点最多有两个子节点,树高 = log₂(n)。

听起来不错,但问题出在哪里?

假设我们有 100 万条数据,二叉树的树高大约是 20。这意味着一次查询最多要 20 次磁盘 I/O。

100 万条数据:

  • 二叉搜索树高度:约 20
  • 红黑树高度:约 27(因为是平衡二叉树,变种)
  • B+ 树高度:约 3-4

100 万条数据用二叉树,树高 20,意味着最坏情况要 20 次磁盘 I/O。

每次磁盘 I/O 的耗时大约是 5-10 毫秒(机械硬盘),20 次就是 100-200 毫秒。这还没算上 CPU 计算的时间。

而 B+ 树高只有 3-4,同样的查询只需要 3-4 次 I/O,耗时不到 50 毫秒。


B+ 树的秘密结构

先回忆一下 B 树的特点:

  • 所有节点都存储数据
  • 每个节点的子节点数有一个范围(通常是 [m/2, m])

B+ 树在 B 树的基础上做了两个关键改进:

  1. 非叶子节点不存储数据,只存储索引
  2. 叶子节点存储所有数据,且用双向链表连接
B+ 树结构示意(假设每个节点最多 4 个指针):

                    [P1| |P2| |P3| |P4]  ← 非叶子节点(只存索引)
                    /      |      \
            [P1| |P2] [P1| |P2] [P1| |P2]  ← 非叶子节点
           / | \      / | \      / | \
         数据页  数据页  数据页  数据页  数据页  ← 叶子节点(双向链表)

非叶子节点为什么只存索引?

因为这样可以让每个节点「更胖」。

假设一个非叶子节点存 1000 个索引,每个索引占 8 字节,那这个节点最多指向 1000 个子节点。

根节点如果也存 1000 个索引,那么:

  • 2 层 B+ 树:1000 × 1000 = 100 万数据
  • 3 层 B+ 树:1000 × 1000 × 1000 = 10 亿数据

大多数业务场景,3 层 B+ 树就够用了。

这意味着:查询任何一条数据,最多只需要 3 次磁盘 I/O。


B+ 树的查询过程

假设我们要查找 id=15 的记录:

java
public class BTreeSearch {

    /**
     * B+ 树的查询流程
     * @param rootPage 根节点
     * @param targetId 目标 ID
     * @return 查询结果
     */
    public BTreeResult search(Page rootPage, long targetId) {
        // 从根节点开始
        Page current = rootPage;

        // 逐层向下查找,直到叶子节点
        while (!current.isLeaf()) {
            // 在当前节点的有序索引中,二分查找第一个大于 targetId 的位置
            int index = binarySearch(current, targetId);
            // 沿着对应指针进入子节点
            current = current.getChildPage(index);
        }

        // 到达叶子节点后,在数据页中二分查找目标记录
        int index = binarySearch(current, targetId);
        if (index >= 0 && current.getRecord(index).getId() == targetId) {
            return new BTreeResult(current.getRecord(index), true);
        }

        return new BTreeResult(null, false);
    }
}

查询过程图解

查找 id=15 的记录:

Step 1: 读取根节点页(磁盘 I/O 1)
┌────────────────────────────────────┐
│ P1(1) | P2(10) | P3(20) | P4(30)  │
└────────────────────────────────────┘

     10 < 15 < 20,走 P3 指针

Step 2: 读取中间节点页(磁盘 I/O 2)
┌────────────────────────────────────┐
│ P1(11) | P2(13) | P3(15) | P4(17) │
└────────────────────────────────────┘

     查找到 15,就是这个叶子页的指针

Step 3: 读取叶子节点页(磁盘 I/O 3)
┌────────────────────────────────────────────┐
│ 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 │
└────────────────────────────────────────────┘

     找到 id=15 的记录!

B+ 树 vs 红黑树:为什么 MySQL 不选红黑树?

对比项B+ 树红黑树
树高3-4 层20-30 层
单次查询 I/O 次数3-4 次20-30 次
范围查询链表遍历,O(n)需要中序遍历
插入/删除只需修改局部节点可能触发多次旋转
磁盘友好性节点大小=页大小(16KB)节点大小=指针+数据

红黑树的节点大小是一个索引 + 两个指针 + 颜色位,通常只有几十字节。

而 MySQL InnoDB 的页大小是 16KB,一个 B+ 树节点可以容纳数千个索引。

这就是本质区别:B+ 树是针对磁盘 I/O 优化的树结构,红黑树是针对内存操作优化的树结构。


页面(Page):MySQL 磁盘读写的最小单位

InnoDB 中,磁盘和内存交互的基本单位是页,默认为 16KB。

一个 B+ 树节点(无论是索引节点还是叶子节点)都对应一个页。

java
// InnoDB Page 结构伪代码
public class Page {
    // 页头信息(38 字节)
    private PageHeader header;      // 页的基本信息
    private PageBody body;          // 页的实际数据

    // 页的默认大小
    public static final int SIZE = 16 * 1024; // 16KB

    // 一个页能存多少索引?
    // 假设主键是 bigint(8字节),指针是 6 字节,每个索引 = 14 字节
    // (16KB - 页头38字节) / 14 ≈ 1170 个索引
}

这就解释了为什么 B+ 树可以做到很「矮」——每个节点能存 1000+ 个索引,3 层就能存 10 亿级别数据。


思考题

B+ 树的插入和删除操作如何保持平衡?

A: 和 B 树一样,通过分裂和合并节点来维持平衡 B: 通过旋转来调整结构 C: 删除操作直接删除节点

提示:MySQL 的 B+ 树索引是自平衡的,但和红黑树的旋转不同,B+ 树主要通过节点的分裂合并来保持平衡。

基于 VitePress 构建