MySQL索引优化:从B+Tree到实战调优
// 目录 · contents
引言
索引是MySQL性能优化的核心武器。一个设计良好的索引可以将查询速度提升几个数量级,而一个不合理的索引则可能成为数据库性能的瓶颈。本文将从B+Tree的底层结构出发,逐步深入到各种索引优化技术的原理与实战。
B+Tree 数据结构
为什么选择B+Tree
MySQL的InnoDB存储引擎选择B+Tree作为索引的底层数据结构,核心原因在于:
- 磁盘IO友好:B+Tree的节点大小通常与磁盘页(16KB)对齐,减少随机IO
- 范围查询高效:叶子节点通过双向链表连接,支持高效的范围扫描
- 树高度低:3-4层即可支撑千万级数据,每次查询只需3-4次IO
graph TD
A[Root Node<br/>P1|30|P2|60|P3] --> B[Internal Node<br/>P1|10|P2|20|P3]
A --> C[Internal Node<br/>P1|40|P2|50|P3]
A --> D[Internal Node<br/>P1|70|P2|80|P3]
B --> E[Leaf Node<br/>1,5,8,10]
B --> F[Leaf Node<br/>12,15,18,20]
B --> G[Leaf Node<br/>22,25,28]
C --> H[Leaf Node<br/>30,33,35]
C --> I[Leaf Node<br/>40,43,45]
C --> J[Leaf Node<br/>50,53,55]
E <-.->|双向链表| F
F <-.->|双向链表| G
G <-.->|双向链表| H
H <-.->|双向链表| I
I <-.->|双向链表| J
B+Tree vs B-Tree vs Hash
| 特性 | B+Tree | B-Tree | Hash |
|---|---|---|---|
| 范围查询 | 高效(叶子链表) | 需要中序遍历 | 不支持 |
| 等值查询 | O(log n) | O(log n) | O(1) |
| 排序 | 天然有序 | 需额外排序 | 不支持 |
| 磁盘IO | 更少(非叶节点不存数据) | 较多 | 取决于冲突率 |
InnoDB的B+Tree页结构
InnoDB每个B+Tree节点对应一个16KB的页(Page),页内部结构如下:
1 | |
聚簇索引与二级索引
聚簇索引(Clustered Index)
InnoDB的主键索引就是聚簇索引,数据行直接存储在B+Tree的叶子节点中。
graph TD
subgraph 聚簇索引
A[主键: 1, 5, 10, 20] --> B[叶子节点<br/>PK=1, name=Alice, age=25<br/>PK=5, name=Bob, age=30]
A --> C[叶子节点<br/>PK=10, name=Carol, age=28<br/>PK=20, name=Dave, age=35]
end
subgraph 二级索引 idx_name
D[索引键: Alice, Bob, Carol, Dave] --> E[叶子节点<br/>name=Alice, PK=1<br/>name=Bob, PK=5]
D --> F[叶子节点<br/>name=Carol, PK=10<br/>name=Dave, PK=20]
end
E -.->|回表查询| B
F -.->|回表查询| C
聚簇索引的选择规则:
- 如果有主键,以主键作为聚簇索引
- 如果没有主键,选择第一个非空唯一索引
- 如果都没有,InnoDB会创建一个隐藏的
row_id作为聚簇索引
回表查询的代价
通过二级索引查询时,如果需要获取非索引列的数据,就需要回表——根据二级索引中存储的主键值,再到聚簇索引中查找完整的行记录。
1 | |
联合索引与最左前缀原则
联合索引的结构
联合索引(a, b, c)在B+Tree中的排序规则是:先按a排序,a相同时按b排序,b相同时按c排序。
1 | |
graph LR
subgraph "联合索引 (user_id, status, created_at)"
A["(1, 'paid', '2025-01-01')"] --> B["(1, 'paid', '2025-01-15')"]
B --> C["(1, 'shipped', '2025-01-10')"]
C --> D["(2, 'paid', '2025-01-05')"]
D --> E["(2, 'shipped', '2025-01-20')"]
E --> F["(3, 'paid', '2025-01-03')"]
end
最左前缀匹配规则
1 | |
索引列顺序的选择原则
- 区分度高的列放前面:区分度 = COUNT(DISTINCT col) / COUNT(*)
- 等值查询的列放前面,范围查询的列放后面
- 频繁出现在WHERE条件中的列优先
1 | |
覆盖索引
覆盖索引是指查询所需的所有列都包含在索引中,不需要回表查询。
1 | |
在EXPLAIN输出中,如果Extra列显示Using index,说明使用了覆盖索引。
1 | |
索引下推(ICP)
索引下推(Index Condition Pushdown)是MySQL 5.6引入的优化。在使用联合索引时,将部分WHERE条件下推到存储引擎层进行过滤,减少回表次数。
sequenceDiagram
participant Client as 客户端
participant Server as Server层
participant Engine as 存储引擎层
Note over Server, Engine: 不使用ICP
Server->>Engine: 查询 user_id=1
Engine->>Server: 返回所有 user_id=1 的记录(含回表)
Server->>Server: 在Server层过滤 status='paid'
Server->>Client: 返回结果
Note over Server, Engine: 使用ICP
Server->>Engine: 查询 user_id=1 AND status='paid'
Engine->>Engine: 在索引中过滤 status='paid'
Engine->>Engine: 只对满足条件的记录回表
Engine->>Server: 返回过滤后的记录
Server->>Client: 返回结果
1 | |
索引合并(Index Merge)
当查询涉及多个单列索引时,MySQL可能会使用索引合并优化:
1 | |
通常来说,使用联合索引比索引合并更高效。如果频繁出现Index Merge,应考虑创建联合索引。
EXPLAIN 执行计划分析
EXPLAIN 关键字段
1 | |
| 字段 | 含义 | 关键值 |
|---|---|---|
| type | 访问类型 | system > const > eq_ref > ref > range > index > ALL |
| possible_keys | 可能使用的索引 | - |
| key | 实际使用的索引 | NULL表示全表扫描 |
| key_len | 使用的索引长度 | 越短越好 |
| rows | 预估扫描行数 | 越少越好 |
| filtered | 过滤比例 | 越接近100越好 |
| Extra | 额外信息 | Using index / Using filesort / Using temporary |
type 访问类型详解
1 | |
慢查询优化实战
案例一:分页查询优化
1 | |
案例二:排序优化
1 | |
案例三:索引失效的常见原因
1 | |
案例四:JOIN优化
1 | |
索引设计最佳实践
flowchart TD
A[分析查询模式] --> B{频繁查询?}
B -->|是| C[确定WHERE/JOIN/ORDER BY列]
B -->|否| D[不创建索引]
C --> E[计算列区分度]
E --> F{需要多列?}
F -->|是| G[创建联合索引<br/>等值列在前 范围列在后]
F -->|否| H[创建单列索引]
G --> I{能覆盖查询?}
H --> I
I -->|是| J[使用覆盖索引]
I -->|否| K[考虑加入SELECT列]
K --> L{索引太宽?}
L -->|是| M[保持当前索引]
L -->|否| J
J --> N[验证EXPLAIN]
M --> N
N --> O[监控慢查询日志]
索引设计原则总结:
- 控制索引数量:单表索引不超过5-6个,避免过度索引影响写入性能
- 优先使用联合索引:一个联合索引可以替代多个单列索引
- 关注索引长度:使用前缀索引减少索引大小(
ALTER TABLE t ADD INDEX idx_name(name(10))) - 定期清理无用索引:通过
sys.schema_unused_indexes查看未使用的索引 - 避免冗余索引:
(a, b)已包含(a),无需再单独创建(a)索引
1 | |
总结
MySQL索引优化是一个系统工程,核心要点包括:
- 理解B+Tree结构是一切优化的基础,它决定了索引的能力和局限
- 聚簇索引与二级索引的区别直接影响查询是否需要回表
- 联合索引的最左前缀原则指导我们如何设计索引列的顺序
- 覆盖索引是消除回表查询的利器
- EXPLAIN是诊断查询性能的必备工具
- 实际优化中,需要结合慢查询日志和业务场景综合考虑
索引不是银弹,过多的索引会增加写入开销和存储空间。好的索引设计需要在查询性能和写入性能之间找到平衡点。
踩坑记录
业务增长后订单表突破 3000 万行,用户订单列表接口开始超时。SQL 是
WHERE user_id = ? AND status = ? ORDER BY create_time DESC,user_id
和 status 都有独立索引,但 EXPLAIN 显示走的是
status 索引,扫描了 120 万行。原因是 status
枚举值只有 5 种,区分度极低,优化器选错了索引。
加了 (user_id, status, create_time)
联合索引后,rows_examined 从 120 万降到
1。顺序很关键:user_id
放第一位是因为它区分度最高,create_time
放最后支持排序,这样整个查询走覆盖索引,不需要回表。
还有一个坑是 ORDER BY 方向。联合索引是
(user_id, create_time ASC),业务要
ORDER BY create_time DESC,MySQL
依然可以用这个索引反向扫描,但如果是
(user_id ASC, create_time DESC)
混合排序,索引就用不上了。这个细节坑了我一次。
实测结果
数据量:3,200 万行订单,测试用户有 8,000 条记录,8 核 16G MySQL 5.7
| 方案 | 平均耗时 | rows_examined | 是否回表 |
|---|---|---|---|
走 status 索引(优化器误选) |
1,800ms | 1,200,000 | 是 |
加联合索引 (user_id, status, create_time) |
3ms | 1 | 否(覆盖索引) |
索引额外空间:约 2.1GB,对于这个查询频率完全值得。
我的看法
覆盖索引的价值被严重低估。很多人加了索引还是慢,根本原因是回表。设计索引时我会先问自己:这条 SQL 能不能只靠索引就拿到所有需要的字段? 能的话就设计成覆盖索引,性能会有数量级的差距。
另外,不要迷信
FORCE INDEX。优化器选错索引应该通过优化索引设计来解决,而不是强制指定——一旦数据分布变化,强制索引可能反而变成拖累,而且代码里埋的
hint 很难被后人注意到。