MySQL 调优基础
Java 中级 · 存储与缓存
线上慢查询大多源于索引没走对或SQL 写法拖累。本篇从 InnoDB 的 B+ 树索引结构说起,讲清为什么索引能加速、回表是什么、最左前缀为什么成立、EXPLAIN 每个字段怎么看,最后给出可落地的优化套路。
一、B+ 树:InnoDB 索引的底层结构
1.1 为什么是 B+ 树
| 结构 | 查找复杂度 | 问题 |
|---|---|---|
| 二叉搜索树 | O(log n) | 可能退化成链表 |
| 红黑树 | O(log n) | 树高仍偏高,磁盘 IO 次数多 |
| B 树 | O(log n) | 非叶子节点也存数据,单页能放的索引少 |
| B+ 树 | O(log n) | 非叶子只存键、数据全在叶子、叶子有链表 ✅ |
B+ 树的两个关键优势:
- 非叶子节点只存键值,一页(16KB)能放几百上千个键,树更矮(3-4 层可存千万级数据),磁盘 IO 次数极少。
- 叶子节点用双向链表串联,天然支持范围查询和排序(
BETWEEN、ORDER BY)。
1.2 聚簇索引与二级索引
- 聚簇索引(主键索引):叶子节点存整行数据。所以 InnoDB 表必须有序,这就是「主键自增」最佳实践的原因。
- 二级索引(普通索引):叶子节点存索引列 + 主键值。
sql
-- 在 name 上建索引
CREATE INDEX idx_name ON user(name);
-- 查询:先在 idx_name 找到主键 id,再回聚簇索引取整行 → 这一步叫「回表」
SELECT * FROM user WHERE name = 'Tom';覆盖索引:消灭回表的利器
如果查询的列都在索引里,就无需回表。例如 SELECT name FROM user WHERE name='Tom',idx_name 已含 name(查询列)+ id(主键),直接返回。 实践:把常用查询列放进联合索引,或显式只查索引内的列,能显著提速。
二、最左前缀原则
联合索引 idx(a, b, c) 的排序是先按 a、再按 b、再按 c。因此查询条件必须从最左列开始连续匹配才能用上。
| 查询条件 | 是否用索引 | 用了哪些列 |
|---|---|---|
a=1 | ✅ | a |
a=1 AND b=2 | ✅ | a, b |
a=1 AND b=2 AND c=3 | ✅ | a, b, c |
b=2(跳过 a) | ❌ | 全表扫描 |
a=1 AND c=3(跳过 b) | ⚠️ | 只用到 a |
范围查询会「中断」后续列
WHERE a=1 AND b>2 AND c=3:a 用到索引,b 用了范围查找,c 就用不上索引了(因为 b 是范围,后续无序)。所以联合索引列顺序要把等值列放前、范围列放后。
三、EXPLAIN:看懂执行计划
sql
EXPLAIN SELECT * FROM user WHERE name = 'Tom' AND age > 18;| 字段 | 含义 | 关注点 |
|---|---|---|
id | 查询序号 | 越大越先执行 |
select_type | 查询类型 | SIMPLE/PRIMARY/SUBQUERY |
type | 访问类型(最重要) | 性能:system > const > eq_ref > ref > range > index > ALL |
possible_keys | 可能用的索引 | — |
key | 实际用的索引 | 为 NULL 说明没走索引 |
key_len | 索引使用长度 | 判断联合索引用了几列 |
rows | 预估扫描行数 | 越小越好 |
Extra | 额外信息 | Using index(覆盖索引) / Using filesort(额外排序) / Using temporary(临时表) |
看到这些就要优化
type = ALL:全表扫描,加索引。Extra = Using filesort:文件排序,考虑给ORDER BY列加索引。Extra = Using temporary:用了临时表,常见于GROUP BY无索引列。rows巨大但实际返回很少:索引选择性差。
四、常见慢 SQL 与优化
sql
-- ❌ 索引失效:对索引列做运算
SELECT * FROM user WHERE YEAR(create_time) = 2024;
-- ✅ 改为范围
SELECT * FROM user WHERE create_time >= '2024-01-01' AND create_time < '2025-01-01';
-- ❌ 索引失效:隐式类型转换(phone 是 varchar,传数字)
SELECT * FROM user WHERE phone = 13800138000;
-- ✅ 传字符串
SELECT * FROM user WHERE phone = '13800138000';
-- ❌ 前导模糊查询,用不上索引
SELECT * FROM user WHERE name LIKE '%Tom';
-- ✅ 后缀模糊可以用
SELECT * FROM user WHERE name LIKE 'Tom%';
-- ❌ SELECT *:可能无法覆盖索引,触发回表
SELECT * FROM user WHERE name = 'Tom';
-- ✅ 只取需要的列
SELECT id, name FROM user WHERE name = 'Tom';
-- ❌ 深分页:扫描并丢弃前 100 万行
SELECT * FROM user ORDER BY id LIMIT 1000000, 10;
-- ✅ 游标/延迟关联
SELECT * FROM user WHERE id > 1000000 ORDER BY id LIMIT 10;4.1 索引失效的常见原因
text
1. 对索引列使用函数或运算 WHERE YEAR(dt)=...
2. 隐式类型转换(字符串 vs 数字)
3. LIKE 以 % 开头
4. OR 两侧有一侧无索引
5. 联合索引违反最左前缀
6. 使用 != / NOT IN 有时放弃索引(优化器判断全表更快)
7. 索引列参与计算或排序方向不一致五、其他调优要点
| 方向 | 做法 |
|---|---|
| 索引 | 为 WHERE/ORDER BY/JOIN 列建索引;区分度高的列优先 |
| 表结构 | 字段尽量 NOT NULL;用合适类型(int vs varchar) |
| 分页 | 游标/延迟关联;避免深分页 |
| JOIN | 小表驱动大表;关联列建索引;别 JOIN 太多表 |
| 事务 | 事务尽量短;避免大事务锁表 |
| 架构 | 读写分离、分库分表(见高级篇) |
索引不是越多越好
每个索引都要占空间,且增删改要维护所有索引,写性能下降。一般单表索引不超过 5-6 个,优先联合索引覆盖多条件。
六、最佳实践
- 建索引前先
EXPLAIN,看type、key、Extra三个字段。 - 联合索引把等值列放前、范围列放后,遵循最左前缀。
- 能用覆盖索引就别回表(
SELECT只取索引内列)。 - 避免对索引列做运算/函数/隐式转换。
- 深分页用游标(
WHERE id > ? LIMIT n)。 - 慢查询日志:
slow_query_log = ON+long_query_time = 1,定期分析pt-query-digest。
📚 官方文档与参考资料
✏️ 练习与自测
- 为什么 InnoDB 用 B+ 树而不是 B 树/红黑树?B+ 树的叶子链表有什么用?
- 什么是回表?覆盖索引为什么能避免回表?
- 联合索引
idx(a,b,c),以下哪些查询能用上索引?a=1、b=2、a=1 AND c=3、a=1 AND b>2 AND c=3。 - 给出一段慢 SQL,用
EXPLAIN分析并写出优化后的 SQL(至少涉及索引失效与深分页两个点)。
本节小结
MySQL 调优的根是 B+ 树索引:非叶子只存键(树矮)、叶子存数据+链表(范围快)。二级索引要回表,覆盖索引可免;最左前缀决定联合索引能用几列;EXPLAIN 看 type/key/Extra 三个字段就能定位大部分问题。索引列别做运算、别隐式转换、别前置 %、深分页用游标。