跳到主要内容

B+ 树索引

问题

为什么数据库普遍使用 B+ 树作为索引结构?B+ 树和 B 树、红黑树、跳表有什么区别?什么是聚簇索引和非聚簇索引?

答案

B+ 树结构

B+ 树是一种多路平衡搜索树,所有数据存储在叶子节点,非叶子节点只存储索引键值。

核心特点

特点说明
多路平衡每个节点可以有多个子节点(通常数百个)
数据在叶子所有实际数据/指针都存在叶子节点
叶子有链表叶子节点通过双向链表相连,支持范围查询
矮胖结构树的高度通常只有 2-4 层
非叶仅索引非叶子节点只存 key,不存数据

B+ 树的高度与数据量

以 InnoDB 为例,假设一行数据 1KB、索引 key + 指针占 12 字节、页大小 16KB:

树高度非叶子节点扇出可存储数据量
2 层~1365~1365 × 16 ≈ 2 万行
3 层~1365~1365 × 1365 × 16 ≈ 3000 万行
4 层~1365~1365³ × 16 ≈ 百亿行
关键面试点

InnoDB 中 3 层 B+ 树就能存储 约 2000-3000 万行数据,每次查询只需 2-3 次磁盘 IO(根节点通常缓存在内存中)。这是 B+ 树被数据库广泛使用的核心原因。

为什么用 B+ 树

B+ 树 vs B 树

对比项B 树B+ 树
数据存储所有节点都存数据只有叶子节点存数据
非叶子节点存 key + data只存 key + 指针
扇出能力较小(data 占空间)更大(非叶只存 key)
范围查询需要中序遍历整棵树叶子链表直接扫描
查询性能不稳定(有的在根就找到)稳定(一定到叶子)

B+ 树的核心优势

  1. 扇出更大 → 树更矮 → 磁盘 IO 更少
  2. 范围查询高效 → 叶子链表顺序扫描
  3. 查询性能稳定 → 每次都查到叶子,时间可预测

B+ 树 vs 红黑树

对比项红黑树B+ 树
每个节点子节点数2数百到数千
100万数据树高度~20 层2-3 层
磁盘 IO 次数~20 次2-3 次
适用场景内存数据结构磁盘数据结构

红黑树是二叉树,树太高了,每层都要读一次磁盘 —— 磁盘 IO 是数据库性能的最大瓶颈。

B+ 树 vs 跳表

对比项跳表B+ 树
磁盘友好❌ 随机指针✅ 顺序页
范围查询✅ 底层链表✅ 叶子链表
实现复杂度简单复杂
使用场景Redis(内存)MySQL(磁盘)
为什么 Redis 用跳表而不用 B+ 树

Redis 是纯内存数据库,不需要考虑磁盘 IO。跳表实现简单、范围查询方便、内存友好,是更好的选择。B+ 树的优势在于磁盘 IO 优化,对纯内存场景不适用。

B+ 树 vs 哈希

详见 哈希索引。简单概括:哈希 O(1)O(1) 定点查找快,但不支持范围查询和排序。

聚簇索引 vs 非聚簇索引

这是数据库索引中最核心的概念之一。

聚簇索引(Clustered Index)

聚簇索引 = 索引结构的叶子节点直接存储完整的数据行

InnoDB 中,主键索引就是聚簇索引。数据按主键顺序物理存储。

特点

  • 每个表只能有 一个 聚簇索引(因为数据只能按一种顺序物理存储)
  • InnoDB 自动选择主键作为聚簇索引;没有主键则选唯一非空索引;都没有则生成隐藏的 ROW_ID
  • 查主键 → 一次 B+ 树查找即可获取完整数据

非聚簇索引(Secondary Index / 二级索引)

非聚簇索引 = 叶子节点存储索引键值 + 主键值,不存完整数据。

回表(Table Lookup)

通过二级索引查找数据时,先在二级索引中找到主键值,再回到聚簇索引中查找完整行数据。这个过程叫 回表

-- 假设 name 字段有索引
SELECT * FROM users WHERE name = '张三';

执行过程

  1. 在 name 的二级索引 B+ 树中查找 '张三' → 得到 pk = 10
  2. 拿着 pk = 10 到聚簇索引 B+ 树中查找 → 得到完整行数据
  3. 两次 B+ 树查找 = 两次索引查找

回表的性能影响

  • 少量回表(几行):影响可忽略
  • 大量回表(上千行):可能比全表扫描还慢,优化器可能选择不用索引
  • 避免回表:使用 覆盖索引

InnoDB vs MyISAM 索引实现

特性InnoDBMyISAM
聚簇索引✅ 主键即聚簇索引❌ 没有聚簇索引
二级索引叶子存主键值存行指针(物理地址)
回表代价需要查一次主键索引直接读物理地址(更快)
主键查询极快(直接定位)需要查索引 + 读地址

主键设计的影响

InnoDB 中主键直接影响数据物理存储顺序,主键设计至关重要。

主键类型优势劣势
自增 ID顺序插入,页分裂少ID 可预测,分布式不友好
UUID全局唯一随机插入,页分裂多,索引膨胀
雪花 ID有序 + 全局唯一时钟回拨问题
为什么不推荐用 UUID 做主键

UUID 是随机的,导致新数据随机插入 B+ 树的各个位置:

  1. 频繁页分裂:需要调整已有页的数据,成本高
  2. 索引膨胀:UUID 36 字节 vs 自增 ID 4/8 字节,每个二级索引的叶子都存主键
  3. 缓存命中率低:数据不连续,Buffer Pool 缓存效果差

常见面试问题

Q1: 为什么 MySQL 索引用 B+ 树而不用 B 树?

答案

三个核心原因:

  1. 扇出更大:B+ 树非叶子节点不存数据,同样大小的页能容纳更多 key,树更矮,磁盘 IO 更少
  2. 范围查询高效:B+ 树叶子节点有双向链表,范围查询直接顺序扫描;B 树需要中序遍历
  3. 性能稳定:B+ 树每次查询都到叶子,查询路径等长;B 树有时在中间节点就命中,性能不稳定

Q2: 什么是回表?如何减少回表?

答案

回表是指通过二级索引查到主键后,再到聚簇索引查完整数据的过程。每次回表是一次额外的 B+ 树查找。

减少回表的方法:

  1. 覆盖索引:让查询需要的字段都在索引中,SELECT a, b FROM t WHERE a = 1 时建 (a, b) 联合索引
  2. 减少 SELECT *:只查需要的字段
  3. 索引下推(ICP):MySQL 5.6+ 自动优化,在索引层面过滤更多数据

Q3: InnoDB 为什么推荐用自增 ID 做主键?

答案

  1. 自增 ID 保证顺序插入,新数据总是追加在 B+ 树的最右边,不会引起页分裂
  2. 自增 ID 占 4/8 字节,所有二级索引叶子节点存的主键值更小,索引更紧凑
  3. 连续插入保证了数据局部性,Buffer Pool 缓存命中率高

对比 UUID:随机插入导致频繁页分裂(50% 的概率)、36 字节导致索引膨胀、缓存命中率低。

Q4: 一个表可以有多少个索引?

答案

理论上没有硬限制,但实际中:

  • 建议不超过 5-6 个索引
  • 每个索引都会增加写入成本(INSERT/UPDATE/DELETE 需要维护所有索引)
  • 每个索引占用额外存储空间
  • 索引太多,优化器选择成本也增加

Q5: B+ 树索引中,什么是页分裂和页合并?

答案

页分裂:当一个页(16KB)写满后,新插入的数据无法放下,InnoDB 会:

  1. 申请新页
  2. 将原页约 50% 的数据移到新页
  3. 更新父节点指针

页合并:当删除数据导致一个页的数据量低于 50%(MERGE_THRESHOLD),InnoDB 会尝试将相邻页合并。

自增主键几乎不会触发页分裂(总是在最后追加)。随机主键(如 UUID)会频繁触发页分裂。

相关链接