»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 三要素

  1. 非叶子节点:只存储 Key 和指针,不存真实数据 → 一个节点可塞下海量指针,出度极大
  2. 叶子节点:存储所有的 Key 和完整的业务数据
  3. 双向链表:所有叶子节点用指针串联成双向循环链表(MySQL 对原生单链表做了优化)
对比B-TreeB+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=3a, b, c 全部✅ 完美命中(顺序不影响,优化器自动调整)
a=1 AND b=2a, b✅ 命中前两列
a=1 AND c=3仅 a⚠️ 中间断档,c 无法使用索引
a=1 AND b>2 AND c=3a, 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 NULLWHERE name IS NULL取决于数据分布,可能全表

索引设计最佳实践

  1. 主键尽量用自增 ID:顺序插入减少页分裂,UUID 随机插入会导致大量页分裂和碎片
  2. 联合索引优于单列索引:一个 3 列联合索引 ≈ 3 个单列索引的功能(需满足最左前缀)
  3. 区分度高的列放联合索引最左侧sex(区分度 2)不适合,user_id(区分度千万级)适合
  4. 避免在频繁更新的列上建索引:每次 UPDATE 都要维护索引树
  5. 不在 WHERE 条件中做函数运算WHERE DATE(create_time) = '2026-01-01' 应改为 WHERE create_time >= '2026-01-01' AND create_time < '2026-01-02'
  6. 用 EXPLAIN 验证每条 SQL:不凭感觉,只信执行计划

总结

MySQL 索引的核心知识链:

  1. 演进逻辑:BST → AVL → 红黑树 → B-Tree → B+Tree,每一步都在解决前者的磁盘 I/O 问题
  2. B+Tree 核心:非叶子只存 Key+指针(增加出度),叶子存完整数据 + 双向链表(范围查询利器)
  3. 回表机制:二级索引找到 ID → 聚簇索引拿完整数据,覆盖索引可避免回表
  4. 最左前缀:联合索引按列顺序排序,跳过最左列 = 索引失效
  5. 实战武器EXPLAIN 看执行计划,type=ALL 必须优化,Using filesort 需要重视
MySQL 核心机制:从树形结构演进看 B+Tree 索引底层原理 | Shanhai