大家好!今天我们来聊一个 MySQL 核心知识点:B+树索引的查询过程。可能你每天都在用SELECT语句查询数据,但你想过这背后到底发生了什么吗?MySQL 是怎么从成千上万的数据中快速找到你要的那一条记录的?带着这个问题,我们一起揭开 MySQL B+树索引的神秘面纱!

先说说为什么 MySQL 选择 B+树?

在聊查询过程前,我们得先明白 MySQL 为啥选 B+树做索引结构。想象一下,如果数据库用简单的数组存储数据,要找一个值就得从头遍历到尾,这也太慢了!

B+树有几个超棒的特点:

  1. 它是一个多叉平衡树,高度很低(通常 3-4 层就能存储上百万数据)
  2. 所有数据都存在叶子节点,中间节点只存储分隔键和子节点指针
  3. 叶子节点通过链表相连,方便范围查询
graph TD
    R["Root"] --> |< 20| N1["Node"]
    R --> |< 50| N2["Node"]
    R --> |> 50| N3["Node"]
    N1 --> L1["Leaf (1-5) 数据..."]
    N1 --> L2["Leaf (6-10) 数据..."]
    N1 --> L3["Leaf (11-20) 数据..."]
    N2 --> L4["Leaf (21-30) 数据..."]
    N2 --> L5["Leaf (31-50) 数据..."]
    L1 -->|链表| L2
    L2 -->|链表| L3
    L3 -->|链表| L4
    L4 -->|链表| L5

与其他索引结构相比:

  • 哈希索引:虽然等值查询速度极快(O(1)),但无法支持范围查询和排序
  • 二叉树:树高太高,I/O 次数多
  • B 树:与 B+树最大的区别在于,B 树的非叶子节点也存储完整数据,这导致每个节点能容纳的键值对更少,相同数据量下 B 树高度更高,查询需要更多 I/O 操作。此外,B 树不支持叶节点间的链表,范围查询时可能需要回到父节点多次寻路,效率低下

MySQL 的存储结构:页是核心

在深入 B+树查询前,我们得了解 MySQL 的存储单位——页(Page)。

想象一下,如果你有一本书要查找内容,你肯定不是一个字一个字地找,而是一页一页地翻。MySQL 也是这样,它以页为单位读取数据。

一个页通常是 16KB,里面可能包含很多行记录。当 MySQL 需要读取数据时,不是读取单条记录,而是将整个页加载到内存的缓冲池中。

缓冲池管理机制

缓冲池不仅仅是简单的内存空间,它采用了改进的 LRU(最近最少使用)算法:

  • 新读取的页不会直接放入 LRU 列表头部,而是放入 midpoint 位置(默认是 5/8 处)
  • 频繁访问的页会被移到列表头部,长时间不用的页会被淘汰
  • 支持预读(Read-Ahead)机制,当顺序读取多个页时,系统会预先加载后续可能用到的页
graph LR
    disk[(磁盘文件)] --> |读取| page1[页1]
    disk --> |读取| page2[页2]
    disk --> |读取| page3[页3]
    page1 --> buffer[缓冲池LRU]
    page2 --> buffer
    page3 --> buffer
    buffer --> memory[内存]

一个具体例子:员工表查询

我们创建一个简单的员工表来演示 B+树查询过程:

CREATE TABLE employees (
    id INT PRIMARY KEY,
    name VARCHAR(50),
    age INT,
    department VARCHAR(50),
    salary DECIMAL(10,2),
    INDEX idx_age (age)
);

假设表中已有上万条数据,为了演示完整流程,我们将展示两种查询:

-- 使用主键索引(聚簇索引)的查询
SELECT * FROM employees WHERE id = 12345;

-- 使用二级索引的查询(涉及回表)
SELECT * FROM employees WHERE age = 28;

B+树索引查询的完整过程

1. 从缓冲池查找数据页

MySQL 首先检查这条数据是否已经在内存的缓冲池(Buffer Pool)中。如果在,直接从内存获取,非常快!如果不在,就需要从磁盘读取。

2. 定位表空间文件

如果数据不在内存中,MySQL 会定位到这张表的表空间文件(ibd 文件)。

3. 从 B+树的根节点开始查找

MySQL 通过数据字典找到表的根页(root page),这是 B+树的入口。

flowchart TD
    A[开始查询 id=12345] --> B{检查缓冲池}
    B -->|有缓存| C[直接返回结果]
    B -->|无缓存| D[加载根节点页]
    D --> E[解析页内B+树节点]
    E --> F{是否是叶子节点?}
    F -->|是| G[在叶子节点中查找记录]
    F -->|否| H[根据键值确定子节点指针]
    H --> I[加载下一层节点页]
    I --> E
    G --> J[返回找到的记录]

4. 逐层向下查找

假设我们的 B+树有 3 层,使用主键查找(id=12345)的过程如下:

第一层(根节点):

  • 页中存储了多个键值对,例如键 1000 指向页号 A,键 5000 指向页号 B,键 10000 指向页号 C...
  • 系统比较 12345 和这些键:12345 > 10000,所以继续查找页号 C 对应的子节点

第二层(内部节点):

  • 加载页号 C 到内存
  • 页中可能包含键 10000 指向页号 D,键 12000 指向页号 E,键 15000 指向页号 F...
  • 12345 > 12000 且 12345 < 15000,所以继续查找页号 E 对应的子节点

第三层(叶子节点):

  • 加载页号 E 到内存
  • 这一页包含了实际的数据记录,系统在页内进行搜索
  • 找到 id=12345 的完整记录并返回

5. 数据页内的查找

当定位到叶子节点的数据页后,MySQL 不是线性遍历页内的所有记录,而是使用页目录(Page Directory)进行查找:

  1. 页目录由多个"槽"(slot)组成,每个槽指向一个记录组的最后一条记录
  2. 系统先对页目录进行二分查找,找到记录可能所在的记录组
  3. 然后在该记录组内从前往后顺序查找目标记录

InnoDB 的聚簇索引与回表操作

在 InnoDB 存储引擎中,表数据文件本身就是按 B+树组织的一个索引结构,这个索引的 key 是数据表的主键,被称为"聚簇索引"(也叫主键索引)。聚簇索引的叶子节点存放的就是整张表的行记录数据。

也就是说:数据文件本身就是主键索引的一部分!这与其他索引不同,其他索引(称为二级索引或辅助索引)的叶子节点只存储索引列和主键值,不包含行的全部数据。

如果使用SELECT * FROM employees WHERE age = 28;这样的查询,过程会有所不同:

  1. 首先沿着 age 索引的 B+树查找,定位到 age=28 的记录
  2. 但二级索引的叶子节点只存储了索引列(age)和主键值(id)
  3. 系统获取到主键值后,需进行"回表"操作:再次查询主键索引(聚簇索引)获取完整记录
  4. 实际发生两次 B+树查询:一次在二级索引树上,一次在聚簇索引树上
flowchart TD
    A[查询 age=28] --> B[在age索引B+树中查找]
    B --> C[找到age=28对应的主键id]
    C --> D[通过id在聚簇索引B+树中查找]
    D --> E[返回完整记录]

这就是为什么通过主键查询比二级索引查询更快—前者只需一次树的遍历,后者需要遍历两棵树。

范围查询是怎么工作的?

如果我们执行范围查询:

SELECT * FROM employees WHERE age BETWEEN 25 AND 30;

B+树的优势就体现出来了:

  1. 系统先定位到第一个满足条件的记录(age=25)
  2. 然后利用叶子节点间的链表指针,顺序扫描直到超出条件(age>30)
  3. 无需回到上层节点重新查找,非常高效

对比下全表扫描与索引范围扫描的 I/O 差异:

  • 全表扫描:需要读取整个表的所有页,假设有 1000 个页,就需要 1000 次 I/O
  • 索引范围扫描:假设 age=25 到 age=30 的记录只分布在 5 个页上,只需要 5 次 I/O 就能完成查询

此外,范围查询通过叶节点链表能实现顺序 I/O(连续读取),而全表扫描可能导致大量随机 I/O,性能差距更大。

graph LR
    L1["Leaf (age 20-24)"] -->|链表| L2["Leaf (age 25-28)"]
    L2 -->|链表| L3["Leaf (age 29-35)"]
    L3 -->|链表| L4["Leaf (age 36-40)"]

    style L2 fill:#f9f,stroke:#333
    style L3 fill:#f9f,stroke:#333

常见问题及解决方案

问题 1:为什么有时候创建了索引查询还是很慢?

原因分析: 可能是出现了回表查询。当查询的字段不全在索引中时,MySQL 需要先通过索引找到主键值,再通过主键索引查找完整记录。

解决方案: 可以考虑创建覆盖索引,即索引包含查询需要的所有字段。例如:

-- 如果经常查询age和department,可以创建联合索引
CREATE INDEX idx_age_dept ON employees(age, department);

-- 使用这个查询就能直接从索引获取数据,不需回表
SELECT age, department FROM employees WHERE age = 28;

问题 2:如何判断查询是否用了索引?

解决方案: 使用 EXPLAIN 命令分析 SQL 执行计划

EXPLAIN SELECT * FROM employees WHERE age = 28;

EXPLAIN 结果中的关键字段:

  • "key"列:显示使用的索引名称,如果为 NULL 则表示未使用索引
  • "rows"列:估计需要扫描的行数,越少越好
  • "type"列:显示连接类型,从好到差依次是:

    • system/const:通过主键或唯一索引定位单条记录
    • ref:使用非唯一索引查找
    • range:范围查询
    • index:全索引扫描
    • ALL:全表扫描,意味着没有使用索引

例如,如果看到 type=ALL,即使有创建索引也说明没有被使用。

问题 3:为什么有时候明明有索引,MySQL 却不使用?

原因分析:

  • 如果表很小,全表扫描可能更快
  • 如果查询的数据量超过表的大约 20-30%,MySQL 认为全表扫描更高效
  • 如果索引字段使用了函数或运算
  • 如果索引列使用了LIKE '%关键词%'(前缀通配符)
  • 如果使用OR条件且部分列无索引
  • 如果索引列的数据分布极度倾斜(如 90%是同一个值)

解决方案:
确保 SQL 语句不对索引字段做函数运算:

-- 不会使用索引
SELECT * FROM employees WHERE YEAR(birth_date) = 1990;

-- 改写后会使用索引
SELECT * FROM employees WHERE birth_date BETWEEN '1990-01-01' AND '1990-12-31';

B+树在实际场景中的表现

让我们看一个真实场景:假设我们的 employees 表有 100 万条记录,使用 InnoDB 引擎,默认页大小 16KB。

对于主键 id 索引:

  • 考虑到 InnoDB 的页结构开销,实际每个索引页能存储的索引项通常少于理论值
  • 假设中间节点每个索引项约 20 字节(整数 id + 子页指针)
  • 考虑页结构开销后,一个页实际可能存储约 700 个索引项
  • 若是 3 层 B+树,大致可索引约 3.43 亿条记录(700³)

但需注意,实际存储容量会受到多种因素影响:

  • 不同数据类型大小不同
  • 可变长度字段会影响每页存储的记录数
  • 中间节点和叶子节点的结构不完全相同

知识总结

知识点描述重要性
B+树结构多路平衡树,所有数据在叶子节点,中间节点存储分隔键和指针,叶节点通过链表相连⭐⭐⭐⭐⭐
聚簇索引InnoDB 中,数据文件本身就是主键索引的一部分,叶子节点包含完整行数据⭐⭐⭐⭐⭐
MySQL 的基本 I/O 单位,默认 16KB,包含多条记录⭐⭐⭐⭐
缓冲池内存中的数据缓存,使用改进的 LRU 算法管理,减少磁盘 I/O⭐⭐⭐⭐
查询过程从根节点开始,根据键值比较逐层向下,最后在页内使用页目录快速定位⭐⭐⭐⭐⭐
回表查询通过二级索引找到主键值,再经由聚簇索引获取完整记录⭐⭐⭐⭐
覆盖索引索引包含查询所需的所有字段,避免回表⭐⭐⭐⭐
范围查询利用叶节点链表高效进行范围扫描,减少 I/O 次数并实现顺序读取⭐⭐⭐⭐
页目录加速页内记录查找的数据结构,通过"槽"指向不同记录组⭐⭐⭐
执行计划通过 EXPLAIN 分析查询执行情况,特别关注 type、key、rows 等字段⭐⭐⭐⭐

通过理解 MySQL B+树索引的工作原理,我们能更好地设计数据库结构和优化查询语句。下次当你执行一条 SELECT 语句时,你就能清楚地知道 MySQL 在背后做了哪些工作!


感谢您耐心阅读到这里!如果觉得本文对您有帮助,欢迎点赞 👍、收藏 ⭐、分享给需要的朋友,您的支持是我持续输出技术干货的最大动力!

如果想获取更多 Java 技术深度解析,欢迎点击头像关注我,后续会每日更新高质量技术文章,陪您一起进阶成长~


异常君
1 声望1 粉丝

在 Java 的世界里,永远有下一座技术高峰等着你。我愿做你登山路上的同频伙伴,陪你从看懂代码到写出让自己骄傲的代码。咱们,代码里见!