kerikoの行星观察笔记
返回文章列表
2445 字13 分钟

从磁盘 I/O 到 B+树索引:一次被面试问深的学习之旅

技术分享#MySQL / 数据库 / B+树 / 索引

最近面试多次被问到 B+树相关的问题,一开始还觉得挺自信——毕竟八股文背得挺熟。但随着面试官一层层往下挖,从“为什么用 B+树”问到“磁盘 I/O 原理”,再到“聚簇索引和二级索引的区别”,甚至“回表和覆盖索引的优化”,我才发现:光背概念真的不行,必须理解底层的运作机制。

这篇文章就是我这段时间深度学习的总结,从磁盘 I/O 的物理原理出发,一步步推导出 B+树索引的设计逻辑。

一切的起点:磁盘 I/O

要理解索引,首先要理解数据是怎么存储的。

数据库的数据最终都落在磁盘上,而磁盘的读写是以扇区为最小单位的。一个扇区通常是 512 字节。这意味着即使你只想读 1 个字节,磁盘也得把整个扇区的数据都读出来。

那一次 I/O 到底要花多久?拆开来看:

一次 I/O 时间 = 寻道时间 + 旋转延迟 + 数据读取时间
  • 寻道时间:磁头移动到目标磁道,通常 5-10ms
  • 旋转延迟:等待目标扇区转到磁头下方,平均约 4ms(7200转/分钟)
  • 数据读取时间:真正读取数据的时间,相对很短

一次 I/O 通常只读写一个扇区,总耗时大约 10ms 左右。这个数字看起来不大,但对于计算机来说,已经是“漫长的等待”了——CPU 在这期间能执行上百万条指令。

所以,减少 I/O 次数,就是数据库性能优化的核心目标。

为什么需要索引:一个具体的例子

假设我们有一张表,每条记录 64 字节。

一个扇区 512 字节,能装下多少条记录?

512 / 64 = 8 条记录

如果有 800 条记录,需要多少个扇区?

800 / 8 = 100 个扇区

如果没有索引,查找第 800 条记录,最坏情况下需要逐个扫描,那就是 100 次 I/O。每次 I/O 10ms,总耗时约 1 秒。这在现代数据库中简直是灾难。

现在引入主键索引。假设索引结构是:主键 ID(8 字节)+ 数据地址指针(8 字节)= 16 字节。

一个扇区能装多少条索引记录?

512 / 16 = 32 条索引记录

800 条数据对应 800 条索引记录,需要多少个扇区?

800 / 32 = 25 个扇区

有了索引后,查找第 800 条记录:

  1. 先在索引中定位(最多扫描 25 个扇区)
  2. 找到地址后,读取数据扇区(1 次 I/O)

总 I/O 次数从 100 次降到了 最多 26 次。这就是索引的价值——用空间换时间,用额外的存储换取更少的 I/O。

但这还不够好。如果数据量更大呢?

数据量增长:二级索引的需求

假设数据增长到 80000 条。

一级索引需要的扇区数:

80000 / 32 = 2500 个扇区

查找第 80000 条记录,最坏情况需要扫描 2500 个索引扇区,再加 1 次数据读取 = 2501 次 I/O。这比没有索引时的 10000 次 I/O 好很多,但仍然太慢。

怎么办?再建一层索引——二级索引

二级索引指向一级索引的位置。假设二级索引结构同样是 16 字节,一个扇区装 32 条:

2500 / 32 = 79 个扇区

查找过程变成:

  1. 在二级索引中定位(最多 79 次 I/O)
  2. 在一级索引中定位(最多 1 次 I/O,因为二级索引直接指向一级索引的扇区)
  3. 读取数据扇区(1 次 I/O)

总 I/O 次数:最多 81 次,从 2501 次降到了 81 次。

看到规律了吗?每加一层索引,I/O 次数就大幅下降。这就是多级索引的思路,而 B+树正是这种多级索引结构的完美实现。

B+树的结构设计

B+树是专门为磁盘存储设计的索引结构,核心特点有三个:

1. 非叶子节点只存索引,叶子节点存数据

为什么这样设计?

因为非叶子节点不需要存储完整数据,就能在一个扇区里装更多索引项。刚才算过:索引项 16 字节,一个扇区能装 32 条。如果节点存储完整记录(64 字节),一个扇区只能装 8 条。

装得越多,树的高度就越低,I/O 次数就越少。

以刚才的例子:

  • 一级索引(叶子层):每个节点装 8 条记录
  • 二级索引(非叶子层):每个节点装 32 条索引指针

3 层的 B+树就能支持 32 × 32 × 8 = 8192 条记录,查找只需最多 3 次 I/O。

如果数据量更大,4 层 B+树能支持 32 × 32 × 32 × 8 = 26 万条记录,查找最多 4 次 I/O。

这就是 B+树“矮胖”设计的精髓——树的高度很低,每次查找的 I/O 次数极少。

2. 叶子节点形成链表

B+树的所有叶子节点通过指针串联成一个双向链表

为什么需要链表?为了范围查询

假设查询 WHERE id BETWEEN 100 AND 200

  • 先定位到 id=100 的叶子节点
  • 然后沿着链表顺序扫描,直到 id=200

整个过程只需要:

  1. 定位起点(树查找,几次 I/O)
  2. 沿着链表顺序读取(不需要再走树)

如果没有链表,每条记录都要单独走树查找,I/O 次数会爆炸。

3. 所有查询都要走到叶子层

B+树的非叶子节点只存索引指针,不存数据。所以无论查哪条记录,都必须走到叶子节点。

这看起来“效率低”,但实际上很关键:

  • 保证查询时间稳定(都要走完整路径)
  • 非叶子节点能装更多索引(降低树高)

MySQL 中的聚簇索引和二级索引

InnoDB 存储引擎中,索引分为两类:

聚簇索引(Clustered Index)

聚簇索引就是主键索引。它的叶子节点存储的是完整的行记录,不是地址指针。

换句话说,聚簇索引和数据是合在一起的。表数据就存储在聚簇索引的叶子节点中。

二级索引(Secondary Index)

二级索引(如普通索引、唯一索引)的叶子节点存储的是主键值,不是地址指针,也不是完整记录。

为什么存主键值而不是地址指针?

因为地址指针在数据移动(如页分裂)时会变化,维护成本高。主键值是稳定的,不会变化。

但这也带来一个问题:回表

回表:查询的“双重查找”

假设表结构:

CREATE TABLE user (
id INT PRIMARY KEY,
name VARCHAR(50),
age INT,
INDEX idx_name(name)
);

执行查询:

SELECT * FROM user WHERE name = '张三';

查找过程:

  1. idx_name 二级索引中找到 name='张三' 的叶子节点
  2. 叶子节点存储的是主键值 id=123
  3. id=123 去聚簇索引中查找完整记录

第 3 步就是回表——从二级索引回到聚簇索引,查了两个 B+树。

覆盖索引:避免回表的优化

如果查询只需要二级索引叶子节点中已有的字段,就不需要回表。

SELECT id, name FROM user WHERE name = '张三';

这个查询只需要 idname

  • name 在二级索引的叶子节点直接有
  • id 是二级索引叶子节点存储的主键值

所有需要的字段都在二级索引中找到了,不需要回表,只查了一个 B+树。

这就是覆盖索引(Covering Index)。

覆盖索引能显著提升查询性能。在设计索引时,可以考虑把常用查询的字段组合进联合索引,形成覆盖索引。

联合索引与最左匹配原则

联合索引是多个字段组合的索引,如:

INDEX idx_name_age(name, age)

联合索引遵循最左匹配原则

  • WHERE name = '张三' — 能用索引(匹配第一列)
  • WHERE name = '张三' AND age = 25 — 能用索引(匹配前两列)
  • WHERE age = 25 — 不能用索引(没匹配第一列)
  • WHERE name LIKE '张%' — 能用索引(匹配第一列前缀)

最左匹配的本质是索引的排列顺序。联合索引按照定义顺序排列:

  • 先按 name 排序
  • name 相同的再按 age 排序

就像字典排序:先按字母顺序,同字母再按笔画。你不能跳过字母直接按笔画查。

总结

从面试的一连串追问开始,我重新梳理了 B+树索引的底层逻辑:

  1. 磁盘 I/O 是瓶颈:每次 I/O 约 10ms,优化核心是减少 I/O 次数
  2. 索引用空间换时间:额外存储换取更少的 I/O
  3. B+树设计精髓:非叶子只存索引(装更多项)、叶子存数据+链表(支持范围查询)、树高很低(I/O 次数少)
  4. 聚簇索引:叶子存完整记录,数据就在索引中
  5. 二级索引:叶子存主键值,需要回表
  6. 覆盖索引:查询字段都在二级索引中,避免回表
  7. 最左匹配:联合索引按字段顺序排列,查询必须从左匹配

理解了这些底层原理,再看 SQL 执行计划、索引优化,就不是背八股文了——每个优化都能找到根源,每条 SQL 的性能瓶颈都能追溯到磁盘 I/O 的物理限制。

这次面试的“被问深”,反而让我真正理解了索引的本质。

评论