»MySQL 核心机制:从树形结构演进看 B+Tree 索引底层原理
2026-06-192026-06-19数据库6 分钟读完(约 1538 字)
MySQL 索引的本质是一种方便高性能获取数据的排好序的数据结构。为了在磁盘 I/O 成本极高的数据库中实现毫秒级查询,索引的数据结构经历了漫长而严密的演进。
树形结构演进史:为什么是 B+Tree?
从最基础的二叉树到 MySQL 最终选用的 B+Tree,每一次演进都是为了解决前者的致命缺陷。
阶段一:普通二叉查找树(BST)
- 缺陷:当顺序插入数据时(如自增主键 1, 2, 3, 4, 5),二叉树会退化成一个单向链表
- 后果:查询性能从 O(log N) 暴跌至 O(N),层级过深,检索极慢
BST 顺序插入 1→2→3→4→5 后:
1
\
2
\
3
\
4
\
5 ← 变成链表,查 5 需要 5 次 I/O
阶段二:高度平衡二叉树(AVL 树)
- 核心特征:严格平衡,任一节点左右子树高度差的绝对值不超过 1
- 优点:由于极度平衡,查询效率极高
- 缺点:每次插入或删除都可能导致频繁的旋转调整,写入性能消耗巨大
阶段三:红黑树(Red-Black Tree / 弱平衡二叉树)
- 核心特征:放弃绝对平衡,追求大致平衡。任一节点左右子树高度差不超过两倍
- 优点:降低了旋转频次,插入和删除性能比 AVL 树更高效
- 缺点:当数据量极大时,层级依然太深。每次节点的逻辑跳转对应一次磁盘 I/O,层级深 = 磁盘寻道多 = 性能瓶颈
阶段四:多路平衡查找树(B-Tree)
- 核心改变:从"二叉"走向"多路",由纵向长高改为横向变胖
- 特征:每个节点不仅存放键值(Key)和指针,还存放了整行真实的业务数据(Data)
- 缺陷:每个节点(页)的存储空间有限(默认 16KB),如果真实数据很大,能存储的 Key 和指针数量就急剧减少,层级依然会变深
阶段五:MySQL 的终极选择 — B+Tree
┌─────────────────────────────────────────────────────────┐
│ B+Tree 结构 │
│ │
│ ┌──[10|30]──┐ ← 非叶子节点:只存 Key+指针 │
│ │ │ │ │ 一个 16KB 页可存上千个指针 │
│ ▼ │ │ ▼ │
│ ┌──[3|7]──┐│┌──[15|25]───┐ │
│ │ │ │ │││ │ │ │ │ │
│ ▼ │ │ ▼│▼ ▼ │ │ ▼ │
│ [1|2][4|5][8|9][12|13][18|20][27|28][35] ← 叶子节点 │
│ ↓ ↓ │
│ 存储完整业务数据 存储完整业务数据 │
│ ←──────── 双向链表串联 ────────→ (支持高效范围查询) │
└─────────────────────────────────────────────────────────┘
B+Tree 三要素:
- 非叶子节点:只存储 Key 和指针,不存真实数据 → 一个节点可塞下海量指针,出度极大
- 叶子节点:存储所有的 Key 和完整的业务数据
- 双向链表:所有叶子节点用指针串联成双向循环链表(MySQL 对原生单链表做了优化)
| 对比 | B-Tree | B+Tree |
|---|---|---|
| 非叶子节点存数据 | ✅ 存完整业务数据 | ❌ 只存 Key + 指针 |
| 叶子节点 | 分散在各层 | 全部在最底层 |
| 叶子节点链表 | ❌ 无 | ✅ 双向链表串联 |
| 范围查询 | 需中序遍历,层间跳来跳去 | 顺链表走即可 |
| 单点查询 I/O 次数 | 不定(数据可能在非叶子层) | 稳定 = 树高 |
为什么 B+Tree 只需 3-4 层就支撑千万级数据?
假设:
- 一个 16KB 页用于非叶子节点
- 每个 Key ≈ 8B,每个指针 ≈ 6B
- 每个节点可存:16384 / 14 ≈ 1170 个(Key + 指针)
三层 B+Tree:
根节点(1170)→ 第二层(1170² = 137万)→ 叶子层(1170³ = 16亿+)
3 层 = 可存 1170³ 条数据
4 层 = 可存 1170⁴ 条数据
结论:千万级数据 ≈ 3 层,亿级数据 ≈ 4 层 → 最多 3-4 次磁盘 I/O
聚簇索引 vs 非聚簇索引
这是 MySQL 索引体系中最核心的一对概念:
┌─────────────────────────────────────────────┐
│ 聚簇索引 (Clustered) │
│ │
│ InnoDB 主键索引 = 聚簇索引 │
│ 叶子节点 = 完整行数据(所有列) │
│ 一张表只有 1 个聚簇索引 │
│ │
│ B+Tree: │
│ 主键 Key │
│ ├── 叶子 = {id=1, name="Tom", age=20} │
│ ├── 叶子 = {id=2, name="Jerry", age=22}│
│ └── 叶子 = ... │
└─────────────────────────────────────────────┘
┌─────────────────────────────────────────────┐
│ 非聚簇索引 (Non-Clustered) │
│ │
│ 二级索引 / 辅助索引 │
│ 叶子节点 = 索引列 + 主键 ID(不是完整行) │
│ 查询非索引列需要回表 │
│ │
│ B+Tree (索引列: name): │
│ name Key │
│ ├── 叶子 = {name="Jerry", id=2} │
│ │ ↓ 回表查聚簇索引 │
│ │ → {id=2, name="Jerry", age=22} │
│ ├── 叶子 = {name="Tom", id=1} │
│ └── 叶子 = ... │
└─────────────────────────────────────────────┘
回表:通过二级索引查到主键 ID 后,再去聚簇索引中查完整行数据的过程。这是索引优化中要尽量避免的。
存储引擎的 Page(页)机制
在 MySQL InnoDB 存储引擎中,磁盘和内存之间交互的最小逻辑单位叫做 Page(页)。
| 属性 | 值 |
|---|---|
| 默认大小 | 16KB(可通过 innodb_page_size 配置) |
| I/O 行为 | 无论读 1 字节还是 100 字节,都一次性将整页加载到内存 |
| 与 B+Tree 的关系 | B+Tree 的每个节点在物理结构上对应一个 16KB 的数据页 |
| 页内结构 | File Header + Page Header + User Records + Free Space + Page Directory + File Trailer |
# 查看当前数据库的页大小
SHOW VARIABLES LIKE 'innodb_page_size';
# 默认输出: 16384 (字节) = 16KB
完整的索引分类体系
| 分类维度 | 索引类型 | 说明 |
|---|---|---|
| 物理存储 | 聚簇索引 | InnoDB 主键索引,叶子存完整行数据,一张表只能 1 个 |
| 物理存储 | 非聚簇索引(二级索引) | 叶子存主键 ID,需要回表 |
| 逻辑约束 | PRIMARY KEY | 主键索引,有且仅有一个,不允许 NULL |
| 逻辑约束 | UNIQUE | 唯一索引,值必须唯一但允许 NULL,可以多个 |
| 逻辑约束 | INDEX(普通索引) | 无唯一限制,仅提速,数量不限 |
| 逻辑约束 | FULLTEXT | 全文索引,用于海量文本搜索 |
| 逻辑约束 | SPATIAL | 空间索引,用于地理位置数据 |
| 列数量 | 单列索引 | 仅包含一个列 |
| 列数量 | 联合索引(组合索引) | 包含多个列,遵循最左前缀法则 |
联合索引最左前缀法则
在生产环境中,推荐使用联合索引来代替零散的单列索引。但必须遵循严格的底层定律。
为什么有最左前缀法则?
如果创建联合索引 INDEX(a, b, c),B+Tree 的构建逻辑是:
先按 a 排序 → a 相同时按 b 排序 → b 相同时按 c 排序
B+Tree 顺序(逻辑视图):
{a=1, b=1, c=1}
{a=1, b=1, c=2}
{a=1, b=2, c=1} ← 注意:这些行在物理上就是按 a→b→c 排序的
{a=2, b=1, c=1}
{a=2, b=3, c=1}
{a=3, b=1, c=1}
命中与失效对照表
假设索引为 INDEX(a, b, c):
| WHERE 条件 | 命中索引列 | 说明 |
|---|---|---|
a=1 AND b=2 AND c=3 | a, b, c 全部 | ✅ 完美命中(顺序不影响,优化器自动调整) |
a=1 AND b=2 | a, b | ✅ 命中前两列 |
a=1 AND c=3 | 仅 a | ⚠️ 中间断档,c 无法使用索引 |
a=1 AND b>2 AND c=3 | a, b | ⚠️ 范围查询后面的 c 失效 |
b=2 AND c=3 | 无 | ❌ 绕过了最左列 a,索引完全失效 |
a>1 AND b=2 | 仅 a | ⚠️ a 用了范围查询后 b 失效 |
核心口诀
全值匹配我最爱,最左前缀要遵守;带头大哥不能死,中间兄弟不能断;范围之后全失效。
覆盖索引
在写 SQL 时,尽量避免 SELECT *。如果查询的列正好包裹在你的联合索引中:
-- 假设有联合索引 INDEX(name, age)
-- 假设有主键索引 PRIMARY KEY (id)
-- ❌ 需要回表:name 和 age 从索引直接拿,但 email 不在索引中
SELECT * FROM users WHERE name = 'Tom';
-- ✅ 覆盖索引:所有查询列都在索引中,无需回表
SELECT name, age FROM users WHERE name = 'Tom';
非覆盖索引查询流程:
二级索引(查 name) → 找到 id → 聚簇索引(查完整行) → 返回
↑ 这一步是"回表"
覆盖索引查询流程:
二级索引(查 name) → 直接拿 name + age → 返回
↑ 不需要回表!
Using index 出现在 EXPLAIN Extra 列中 = 覆盖索引生效,性能最优。
EXPLAIN 分析索引使用
生产环境中判断索引是否生效的标准工具:
EXPLAIN SELECT * FROM users WHERE name = 'Tom' AND age > 20;
| 列名 | 含义 | 关键值 |
|---|---|---|
| type | 访问类型(性能从好到差) | const > eq_ref > ref > range > index > ALL(全表扫描) |
| key | 实际使用的索引 | NULL 表示未使用索引 |
| key_len | 索引中使用的字节数 | 越短越好,可判断用了联合索引的几列 |
| rows | 预估扫描行数 | 越小越好 |
| Extra | 额外信息 | Using index(覆盖索引)✅ / Using filesort(文件排序)❌ / Using temporary(临时表)❌ |
常见索引失效场景
| 场景 | 示例 | 原因 |
|---|---|---|
| 函数运算 | WHERE LEFT(name, 3) = 'Tom' | 对索引列做运算导致索引失效 |
| 隐式类型转换 | WHERE phone = 13800138000(phone 是 VARCHAR) | 字符串不加引号触发类型转换 |
| LIKE 前导模糊 | WHERE name LIKE '%Tom' | % 开头无法使用索引 |
| OR 非索引列 | WHERE name='Tom' OR age=20(age 无索引) | OR 的一边无索引导致全表扫描 |
| != 或 <> | WHERE status != 1 | 不等于无法精确定位 |
| IS NULL / IS NOT NULL | WHERE name IS NULL | 取决于数据分布,可能全表 |
索引设计最佳实践
- 主键尽量用自增 ID:顺序插入减少页分裂,UUID 随机插入会导致大量页分裂和碎片
- 联合索引优于单列索引:一个 3 列联合索引 ≈ 3 个单列索引的功能(需满足最左前缀)
- 区分度高的列放联合索引最左侧:
sex(区分度 2)不适合,user_id(区分度千万级)适合 - 避免在频繁更新的列上建索引:每次 UPDATE 都要维护索引树
- 不在 WHERE 条件中做函数运算:
WHERE DATE(create_time) = '2026-01-01'应改为WHERE create_time >= '2026-01-01' AND create_time < '2026-01-02' - 用 EXPLAIN 验证每条 SQL:不凭感觉,只信执行计划
总结
MySQL 索引的核心知识链:
- 演进逻辑:BST → AVL → 红黑树 → B-Tree → B+Tree,每一步都在解决前者的磁盘 I/O 问题
- B+Tree 核心:非叶子只存 Key+指针(增加出度),叶子存完整数据 + 双向链表(范围查询利器)
- 回表机制:二级索引找到 ID → 聚簇索引拿完整数据,覆盖索引可避免回表
- 最左前缀:联合索引按列顺序排序,跳过最左列 = 索引失效
- 实战武器:
EXPLAIN看执行计划,type=ALL必须优化,Using filesort需要重视