核心目标:减少磁盘 I/O
数据库系统(如 MySQL)的主要性能瓶颈通常在于磁盘 I/O(读取和写入数据到物理硬盘的速度远慢于内存访问)。B+ 树的设计核心就是最大限度地减少访问数据时所需的磁盘 I/O 次数。
一、B+ 树的基本结构回顾
在深入存储之前,先快速回顾 B+ 树的关键特性:
- 多路平衡搜索树: 每个节点可以有多个子节点(远多于二叉树的 2 个),并且树始终保持平衡(所有叶子节点位于同一层)。
- 分层结构:
- 根节点 (Root Node): 树的顶端。
- 内部节点/非叶子节点 (Internal/Non-Leaf Nodes): 位于根节点和叶子节点之间。它们只存储索引键 (Key) 和指向其子节点(可以是内部节点或叶子节点)的指针 (Pointer)。
- 叶子节点 (Leaf Nodes): 树的底层。它们存储索引键 (Key) 以及与该键关联的实际数据或指向实际数据的指针。在 MySQL InnoDB 中,对于主键索引(聚簇索引),叶子节点直接存储数据行;对于二级索引,叶子节点存储索引列的值和对应的主键值。
- 节点填充度高: 节点通常会被填充到一定程度(例如 50% 或更多),以优化存储利用率和减少树的高度。
- 叶子节点链表: 所有叶子节点通过双向指针(前驱和后继指针)按顺序连接成一个有序链表。这使得范围查询 (
WHERE col BETWEEN X AND Y
) 变得极其高效,只需定位到起始键所在的叶子节点,然后沿着链表顺序扫描即可,无需回溯到上层节点。 - 所有数据在叶子层: 这是 B+ 树与 B 树的关键区别。非叶子节点不存储实际数据记录,只存储用于导航的键值。实际数据(或指向数据的指针)只存在于叶子节点。这使得非叶子节点可以存储更多的键值,从而显著降低树的高度。
二、B+ 树如何映射到硬盘存储(以 InnoDB 为例)
MySQL InnoDB 存储引擎是使用 B+ 树作为其索引结构的典型代表。硬盘存储的核心单位是 页 (Page)。
-
存储的基本单位:页 (Page)
- InnoDB 中,磁盘和内存之间数据传输的最小单位是 页。
- 默认页大小通常是 16KB (可以通过
innodb_page_size
配置,但 16KB 是最常见和推荐的)。 - B+ 树的每个节点(根节点、内部节点、叶子节点)在物理存储上都对应一个或多个这样的 16KB 页。 大多数情况下,一个节点就存储在一个页里。非常大型的节点(比如叶子节点包含大量行)可能需要多个页存储,但 InnoDB 的设计通常尽量避免这种情况,通过分裂机制维持一个节点对应一个页。
-
页的内部结构
一个 InnoDB 页(16KB)内部包含多个部分,用于高效存储和管理数据:- 页头 (Page Header): 存储页的元信息,如页类型(叶子页?非叶子页?)、页号、前后页指针(用于叶子节点链表或文件段内的链接)、页内记录数、槽信息(Slots)的位置等。
- Infimum + Supremum Records: 两个系统生成的伪记录,分别表示页内“最小”和“最大”记录,用于界定边界。
- 用户记录 (User Records): 这是页的核心区域,存储实际的行记录(在叶子节点)或索引条目(在非叶子节点)。
- 叶子节点页: 存储的是 索引键值 + 完整数据行(对于聚簇索引) 或 索引键值 + 主键值(对于二级索引)。记录按主键(聚簇索引)或索引键(二级索引)顺序物理存储(逻辑上连续,物理上可能因插入/删除而碎片化)。
- 非叶子节点页: 存储的是 索引键值 + 指向子节点页的指针(页号)。这些键值是其子节点中键值范围的“分隔符”。例如,一个键值
K
对应的指针指向的页包含所有键值<= K
的记录(具体规则取决于实现,但思想类似)。
- 页目录 (Page Directory): 这是 InnoDB 在页内实现的小型索引结构,通常位于页尾部。它包含一个槽 (Slot) 数组。每个槽指向页内用户记录区域中的某些记录的位置(通常是每隔几行设一个槽)。槽内的记录是按主键(或索引键)排序的。页目录的作用是加速页内记录的查找。当在页内查找一条记录时,先通过二分查找在页目录中找到记录所在的槽(大致范围),然后再在槽指向的少量记录中进行顺序查找。这避免了在页内进行全表扫描。
- 页尾 (Page Trailer): 包含校验和 (Checksum) 信息,用于检测页在写入磁盘或从磁盘读取时是否发生损坏。
-
B+ 树在硬盘上的组织
- 整个 B+ 树索引存储在
.ibd
表空间文件中(对于独立表空间)或共享的ibdata
文件中(对于系统表空间)。 - 根节点页: 固定存储在某个特定位置(例如,数据字典中会记录每个索引的根页号)。
- 节点关系: 非叶子节点页中的“指针”字段存储的是其子节点页的页号 (Page Number)。这个页号就是在表空间文件中的逻辑偏移量(或物理地址映射)。
- 叶子节点链表: 叶子节点页的页头中存储了前一个页号 (Previous Page Number) 和后一个页号 (Next Page Number),从而将所有的叶子节点串联成一个有序的双向链表。这个链表是按索引键值排序的。
- 数据文件即索引文件 (聚簇索引): 对于 InnoDB 的主键索引(聚簇索引),叶子节点包含了完整的行数据。这意味着数据行本身就按照主键顺序存储在 B+ 树的叶子节点页中。因此,InnoDB 的表数据文件(
.ibd
)本身就是按主键组织的巨大 B+ 树索引文件。 - 二级索引: 二级索引也是 B+ 树结构,但它的叶子节点存储的是索引列的值 + 对应的主键值。当通过二级索引查找数据时,引擎先在二级索引 B+ 树中找到目标记录的主键值,然后回表 (Bookmark Lookup) 到主键索引(聚簇索引)的 B+ 树中查找完整的行数据。二级索引的叶子节点之间也通过双向链表连接。
- 整个 B+ 树索引存储在
三、索引数据如何工作(查询过程示例)
假设我们有一个 users
表,主键是 id
,并在 age
列上建立了一个二级索引。我们执行查询 SELECT * FROM users WHERE age = 30;
。
- 定位索引根: MySQL 知道查询条件在
age
索引上。它从数据字典中找到age
索引的 B+ 树根节点所在的页号。 - 加载根页: 从硬盘读取该根页到内存缓冲区池 (Buffer Pool)。
- 遍历非叶子节点:
- 在根页(非叶子节点)中,使用二分查找(利用页目录加速页内查找)找到第一个大于或等于
30
的键值K
。找到K
前面那个指针指向的子节点页P1
。 - 从硬盘加载页
P1
(如果不在 Buffer Pool 中)到内存。 - 在页
P1
(可能是另一个非叶子节点或叶子节点)中,同样用二分查找定位到键值30
可能存在的子节点页P2
。 - 重复这个过程,沿着树向下,直到定位到包含键值
30
(或30
应该存在的范围)的叶子节点页P_leaf
。由于 B+ 树是平衡的,这个过程通常只需要 3-4 次 I/O(取决于数据量大小和树的高度)。
- 在根页(非叶子节点)中,使用二分查找(利用页目录加速页内查找)找到第一个大于或等于
- 在叶子节点页查找:
- 加载叶子节点页
P_leaf
到内存(如果不在)。 - 在
P_leaf
页内,利用页目录 (Page Directory) 进行二分查找,快速定位到包含age=30
的记录的位置。 - 由于二级索引叶子节点存储的是
(age, id)
,此时引擎找到了所有age=30
的记录的id
值列表。
- 加载叶子节点页
- 回表 (对于二级索引):
- 对于找到的每一个
id
值,引擎需要获取完整的行数据。 - 引擎转向主键索引(聚簇索引) 的 B+ 树。
- 使用相同的 B+ 树查找过程(定位根->遍历非叶子节点->定位叶子节点),根据
id
值在主键索引的叶子节点中找到对应的完整数据行。 - 每次根据一个
id
回表查找,理论上都可能产生一次 I/O(如果目标页不在 Buffer Pool 中)。如果age=30
的记录很多,这可能导致大量的随机 I/O。这就是为什么覆盖索引(索引包含所有查询列)能显著提升性能的原因,因为它避免了回表。
- 对于找到的每一个
- 范围查询: 如果查询是
WHERE age BETWEEN 25 AND 35
:- 在
age
索引 B+ 树中找到age=25
所在的叶子页P_start
。 - 加载
P_start
,读取所有age>=25
的记录(直到该页结束)。 - 利用叶子节点的双向链表指针,加载下一个叶子页
P_next
,继续读取记录,直到遇到age > 35
的记录为止。范围查询利用链表顺序扫描,效率很高。
- 在
四、B+ 树相对于其他结构(如哈希、B 树)的优势
- 极低的树高度: 多路分支特性使得即使存储海量数据(亿级、十亿级),树的高度通常也只有 3-4 层。查找任何记录最多只需要 3-4 次磁盘 I/O。
- 高效的磁盘 I/O:
- 节点大小(页大小 16KB)与磁盘块大小(通常 4KB)对齐或成倍数关系,一次 I/O 能读取大量有用信息。
- 顺序预读:磁盘顺序读取远快于随机读取。B+ 树的局部性原理(相邻节点/记录物理位置较近)和叶子节点链表使得范围扫描和全表扫描可以利用磁盘预读特性,大幅提升 I/O 效率。
- 出色的范围查询: 叶子节点链表使得范围查询 (
BETWEEN
,>
,<
,LIKE 'prefix%'
) 非常高效,只需定位到起点,然后顺序扫描链表即可。 - 稳定的查询性能: 所有查询路径长度相同(树平衡),查找任何记录所需 I/O 次数基本相同,性能可预测。
- 高效的插入和删除 (相对): 虽然插入/删除可能导致节点分裂或合并,但操作仍然集中在局部的少数节点上(通常是叶子节点及其父节点),影响范围有限。分裂/合并逻辑也相对高效。
五、总结
MySQL(特别是 InnoDB)使用 B+ 树作为其核心索引结构,通过以下机制高效地在硬盘上存储和索引数据:
- 页式存储: 以 16KB 页为基本单位在硬盘上组织 B+ 树的节点(根、内部、叶子)。
- 节点映射: 每个 B+ 树节点通常对应一个物理页。非叶子节点存储键和子页指针;叶子节点存储键和数据(聚簇索引)或键和主键(二级索引)。
- 页内优化: 页内使用 页目录 (Slots) 实现快速二分查找,避免页内全扫描。
- 叶子链表: 叶子节点通过双向链表串联,实现高效的范围扫描。
- 聚簇索引: 主键索引的叶子节点包含完整数据行,数据文件即索引文件。
- 二级索引与回表: 二级索引叶子节点存储主键值,查询完整数据需回表到主键索引。
- 减少 I/O: 设计核心在于利用 B+ 树矮胖、多分支、顺序访问的特性,以及页大小与磁盘 I/O 的匹配,最大限度地减少昂贵的磁盘随机 I/O 次数。
理解 B+ 树在硬盘上的存储和工作原理,是理解数据库性能调优(如索引设计、覆盖索引、避免回表、利用顺序 I/O)的基础。它解释了为什么某些查询快,某些查询慢,以及如何通过合理的索引策略来优化数据库访问。