mysql-slow-query
name: mysql-slow-query
description: MySQL 慢查询定位与索引优化。当接口变慢、数据库 CPU 打满、慢日志堆积、要看执行计划、要加索引又怕加错、或者遇到深分页和 N+1 时使用。触发词:慢查询、慢 SQL、SQL 优化、加索引、索引失效、EXPLAIN、执行计划、深分页、数据库 CPU 高、锁等待、死锁、N+1。不负责 MySQL 部署运维和主从复制搭建。
MySQL 慢查询定位与优化
顺序是先找到、再看懂、后动手。绝大多数「数据库慢」的现场,跳过前两步直接加索引,结果是索引越加越多、写入越来越慢、慢查询还在。
一、找到慢在哪
开慢日志。 生产上 long_query_time 设 1 秒起步,不要设 0(会把日志写爆)。
SHOW VARIABLES LIKE 'slow_query_log%';
SHOW VARIABLES LIKE 'long_query_time';
聚合分析,不要逐条看。 用 pt-query-digest 或 mysqldumpslow 把慢日志按「SQL 指纹」归并,按总耗时排序,不是按单次耗时。
判断依据:一条 5 秒执行 10 次的 SQL(总 50 秒),远不如一条 0.2 秒执行 5000 次的(总 1000 秒)重要。后者才是 CPU 杀手,而且它经常不在慢日志里,因为单次没超阈值。
在线抓现行。 数据库正在冒烟时:
SELECT id, user, db, command, time, state, LEFT(info, 200)
FROM information_schema.processlist
WHERE command <> 'Sleep' AND time > 1
ORDER BY time DESC;
看 state 列:Sending data 通常是扫行多,Copying to tmp table 是排序或分组落磁盘,Waiting for table metadata lock 是有 DDL 卡着。
没有慢日志的情况下,用 performance_schema 的语句摘要:
SELECT digest_text, count_star, avg_timer_wait/1e9 AS avg_ms,
sum_timer_wait/1e12 AS total_s, sum_rows_examined/count_star AS avg_rows
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC LIMIT 20;
avg_rows(平均扫描行数)是最有价值的一列。扫描行数远大于返回行数,就是索引问题。
二、看懂执行计划
EXPLAIN ANALYZE SELECT ...; -- MySQL 8.0.18+,给出实际耗时和实际行数
EXPLAIN FORMAT=JSON SELECT ...;
优先用 EXPLAIN ANALYZE。普通 EXPLAIN 的 rows 是估算值,经常和实际差一个数量级。
按这个顺序读:
| 看什么 | 危险信号 | 含义 |
|---|---|---|
type |
ALL |
全表扫描 |
type |
index |
全索引扫描,也很慢 |
key |
NULL |
没走索引 |
rows × 嵌套层数 |
数量级远超返回行数 | 索引选择性差或没走上 |
Extra |
Using filesort |
排序没走索引 |
Extra |
Using temporary |
用了临时表,通常是 GROUP BY / DISTINCT |
Extra |
Using where 且 rows 大 |
索引没过滤掉多少,回表后再过滤 |
Using index 是好东西,代表覆盖索引,不回表。
三、索引失效的常见原因
按现场出现频率排序:
- 在索引列上做运算或调用函数。
WHERE DATE(created_at) = '2026-09-03'走不了索引。改成范围:WHERE created_at >= '2026-09-03' AND created_at < '2026-09-04'。 - 隐式类型转换。 列是
VARCHAR,条件传了数字,整条索引作废。这是最隐蔽的一种,EXPLAIN里只表现为不走索引。 - 字符集或排序规则不一致的 JOIN。 两张表的关联列一个
utf8mb4_general_ci一个utf8mb4_unicode_ci,JOIN 时必须转换,索引失效。 - 最左前缀没满足。 联合索引
(a, b, c),条件只有b和c,用不上。 - 范围条件后面的列用不上索引。
(a, b, c)上WHERE a=? AND b>? AND c=?,c排不上用场。所以等值列放前面,范围列放最后。 OR连接的两侧不都有索引。 有一侧没有就全表扫。改成UNION ALL。- 前导通配符。
LIKE '%关键字%'走不了 B+ 树,需要全文索引或搜索引擎。 NOT IN/!=选择性太差,优化器主动放弃索引。
四、几类典型问题的改法
深分页。 LIMIT 1000000, 20 要先扫过一百万行再丢掉。改成游标:
-- 差
SELECT id, title FROM articles ORDER BY id LIMIT 1000000, 20;
-- 好
SELECT id, title FROM articles WHERE id > :last_id ORDER BY id LIMIT 20;
必须支持跳页时,用延迟关联:先在覆盖索引上取主键,再回表。
SELECT a.id, a.title
FROM articles a
JOIN (SELECT id FROM articles ORDER BY id LIMIT 1000000, 20) t ON a.id = t.id;
SELECT *。 除了浪费网络,还会让本来能覆盖的索引失效,被迫回表。永远列出需要的列。顺带一提,password_hash 这类敏感列绝对不要出现在给前端的查询里,哪怕前端不用。
N+1。 ORM 里一个列表查询后逐条查关联,一百条数据一百零一次查询。用 preload / eager loading / JOIN FETCH,或者手工批量查一次再在内存里拼。
COUNT(*) 全表。 大表精确总数很贵。列表页的总数改成「估算值」(information_schema.tables 的 table_rows)或者干脆不显示总页数,只给「下一页」。
批量写入。 循环单条 INSERT 改成一条多值 INSERT,或者 INSERT ... ON DUPLICATE KEY UPDATE。差距通常是十倍以上。
五、加索引的纪律
- 加之前先证明:给出
EXPLAIN ANALYZE的前后对比,扫描行数从多少降到多少。 - 联合索引优于多个单列索引。 顺序按「等值列(选择性高的在前) → 范围列 → 排序列」。
- 所有外键列必须有索引。 没有的话,删父表记录会锁全子表。
- 命名统一:
idx_<表>_<列>,唯一索引uk_<表>_<列>。 - 走迁移文件,不要手改数据库。 大表加索引用
ALGORITHM=INPLACE, LOCK=NONE,或者上gh-ost/pt-online-schema-change,别在业务高峰直接ALTER。 - 加完复查冗余:如果已有
(a, b, c),就不要再建(a)和(a, b)。 - 单表索引控制在 5 个以内。每个索引都是写入时的额外代价。
六、锁与事务
慢查询排完还慢,看锁。
SELECT * FROM performance_schema.data_lock_waits;
SELECT * FROM information_schema.innodb_trx ORDER BY trx_started;
SHOW ENGINE INNODB STATUS\G -- 看 LATEST DETECTED DEADLOCK 段
纪律很简单:事务要短,控制在秒级。事务里不要做 HTTP 调用、不要等用户输入、不要 sleep。长事务会一直持有锁并撑大 undo,是慢查询之外最常见的数据库雪崩原因。
死锁的通用解法是让所有事务以相同顺序访问资源,比如永远按主键升序更新。
七、交付
优化完给出四样:
- 问题 SQL 清单,按总耗时排序,标出前五。
- 每条的前后
EXPLAIN ANALYZE对比,含扫描行数和实际耗时。 - 索引变更的迁移文件(up 和 down 成对,down 实际测过)。
- 上线后的观察项:慢日志条数、数据库 CPU、对应接口的 P99。