线上接口超时、报表跑一夜、分页越翻越慢——很多时候根因就是一条 SQL。排查思路像医生看病:先弄清症状在哪一层,再决定开什么药。千万别一上来就翻代码、加索引,那往往是瞎猜。
一、先说清楚:慢,不一定怪 SQL
很多人第一反应是「这条 SQL 有问题」。其实慢可能出在好几层:
应用层 → 循环里查库(N+1)、一次拉太多数据
连接层 → 连接池满了、长连接没释放
SQL 层 → 没索引、写法不对、优化器选错路
数据库层 → 被锁堵住、内存不够、磁盘 IO 扛不住
先外后内:先用数据库自带的「仪表盘」看外面发生了什么,再决定要不要动 SQL、加索引。分库分表、换存储那种大招,放最后。
二、四步排查法(主线)
第一步 望 → 病在哪?先找到嫌疑 SQL
第二步 切 → EXPLAIN 看 MySQL 打算怎么跑
第三步 闻 → SQL 看着没问题还慢?查锁、内存、磁盘
第四步 下药 → 索引、改写法、改分页,对症处理
改完别急着收工:同一条 SQL、同一套参数再跑一遍,对比前后耗时,免得白忙一场。
三、望——病在哪?(定位嫌疑 SQL)
别盲查。问题正在发生,先看当前谁在跑;问题已经过去了,翻慢查询日志。
3.1 看当前「活跃」查询
-- 查看当前所有连接及其正在执行的 SQL(FULL 会显示完整语句,不带 FULL 可能被截断)
SHOW FULL PROCESSLIST;
输出里常用列含义:
| 列 | 含义 |
|---|---|
Id | 连接/线程 ID,后续 KILL Id 时可用来终止会话 |
User / Host | 哪个账号、从哪台机器连进来 |
db | 当前使用的数据库 |
Command | 连接状态,Query 表示正在执行 SQL,Sleep 表示空闲 |
Time | 当前状态持续了多少秒,越大越可疑 |
State | 线程在干什么(排序、等锁、读数据等) |
Info | 正在执行的 SQL 全文 |
-- 从系统表查进程列表,过滤掉 Sleep,按执行时间倒序
SELECT id, user, host, db, command, time, state, info
FROM information_schema.PROCESSLIST
WHERE command != 'Sleep'
ORDER BY time DESC;
和上面 SHOW FULL PROCESSLIST 本质一样,好处是可以自己加 WHERE、ORDER BY,比如只看待超过 10 秒的:
SELECT id, time, state, info
FROM information_schema.PROCESSLIST
WHERE command = 'Query' AND time > 10
ORDER BY time DESC;
几个常见 State,看到就知道大方向了:
| State | 多半是什么问题 |
|---|---|
Copying to tmp table | 在建临时表,GROUP BY / DISTINCT 写法或数据量有问题 |
Sorting result / Creating sort index | 在做排序,ORDER BY 很可能没吃到索引 |
Sending data | InnoDB 在读/过滤行,关注扫了多少、返回多少 |
Waiting for lock / Locked | 不是算得慢,是在等行锁 |
Waiting for table metadata lock | 在等表元数据锁,常有 DDL 或长查询占着 |
3.2 慢查询日志——事后翻案用
现场过去了,就靠慢查询日志。下面两条是运行时动态修改(重启可能失效,适合临时排查):
-- 打开慢查询日志:把超过阈值的 SQL 写到 slow.log
SET GLOBAL slow_query_log = ON;
-- 设置阈值(秒):执行时间 >= 1 秒的 SQL 会被记录
SET GLOBAL long_query_time = 1;
持久化写到 my.cnf,重启后仍然生效:
[mysqld]
slow_query_log = ON # 开启慢查询日志
long_query_time = 1 # 超过 1 秒记一条
log_queries_not_using_indexes = ON # 没走索引的 SQL 也记(即使很快)
log_slow_extra = ON # 额外记录扫描行数等信息(MySQL 8.0)
日志里重点看四个数:
| 字段 | 含义 |
|---|---|
Query_time | 这条 SQL 执行了多少秒 |
Rows_examined | 引擎扫描/检查了多少行 |
Rows_sent | 最终返回给客户端多少行 |
| 出现次数 | 同模板 SQL 跑多少次(决定优化优先级) |
扫描很多、返回很少 → 索引或写法问题;返回本身就很多 → 业务或分页问题。
分析工具二选一:
# mysqldumpslow:MySQL 自带,按总耗时排序取 Top 10
# -s t 按总时间(total)排序
# -t 10 只显示前 10 条
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# pt-query-digest:报告更细,会按 SQL 模板聚合
pt-query-digest /var/log/mysql/slow.log > report.txt
MySQL 8.0 还可以在线查历史 Top SQL(不用等日志落盘),数据来自 performance_schema:
SELECT
DIGEST_TEXT, -- SQL 模板(参数被归一化)
COUNT_STAR AS exec_count, -- 执行次数
ROUND(SUM_TIMER_WAIT / 1e12, 2) AS total_sec, -- 累计耗时(秒)
SUM_ROWS_EXAMINED AS rows_examined, -- 累计扫描行数
SUM_ROWS_SENT AS rows_sent -- 累计返回行数
FROM performance_schema.events_statements_summary_by_digest
WHERE SCHEMA_NAME IS NOT NULL -- 排除未指定库的语句
ORDER BY SUM_TIMER_WAIT DESC -- 按总耗时降序
LIMIT 10;
SUM_TIMER_WAIT 单位是皮秒(ps),除以 1e12 才变成秒。
3.3 拿到 SQL 之后,记得补参数
MyBatis 日志里经常是 WHERE id = ?,你拿这个去 EXPLAIN,和线上真实计划可能完全两码事。一定要拼出带真实参数的完整 SQL,再去从库或低峰期测。
四、切——EXPLAIN 看执行计划(核心)
找到嫌疑 SQL 后,在它前面加 EXPLAIN,意思是:别真关心结果集,告诉我你打算怎么查。
-- 看优化器的执行计划(估算值,不真正跑完整个查询)
EXPLAIN SELECT ... \G
-- \G 表示纵向打印,一行一个字段,方便肉眼看
-- MySQL 8.0:真的会执行这条 SQL,并给出每个步骤的实际耗时和行数
EXPLAIN ANALYZE SELECT ...;
两者区别:
| 命令 | 会不会真跑 SQL | 看什么 |
|---|---|---|
EXPLAIN | 不会(一般情况) | 优化器估算的计划,快,适合反复试 |
EXPLAIN ANALYZE | 会 | 真实耗时、实际扫描行数,适合最终确认 |
4.1 EXPLAIN 输出字段怎么读
| 字段 | 含义 | 危险信号 |
|---|---|---|
| type | 访问类型,表示怎么找数据 | ALL 全表扫;大范围 index 扫整个索引 |
| possible_keys | 优化器认为可能用到的索引 | 为空 = 觉得没有可用索引 |
| key | 实际选用的索引 | NULL = 没用索引 |
| rows | 预估要扫描的行数 | 越大越慢 |
| filtered | 预估过滤后剩余行的百分比 | 很低说明扫了很多但筛掉大半 |
| Extra | 额外信息 | Using filesort、Using temporary 是红灯;Using index 是好事 |
type 从好到差,记个大概:
system > const > eq_ref > ref > range > index > ALL
const:主键/唯一索引等值查,最多一行eq_ref:JOIN 时用主键/唯一索引匹配,一行对一行ref:普通索引等值查range:索引范围扫描(BETWEEN、<、>)index:扫整个索引(比全表扫好一点,但仍可能很慢)ALL:全表扫描,最差
Extra 里两个红灯、一个绿灯:
Using filesort:额外排序,内存不够就落磁盘Using temporary:用了临时表,常见于 GROUP BY / DISTINCT 不当Using index:覆盖索引,查询列全在索引里,不用回表
4.2 一个容易忽略的点:有索引却全表扫
如果 type=ALL,rows 显示 100 万,但表实际才 10 万行——索引不是没建,是失效了。常见原因:
- 隐式类型转换:字符串列传了数字,如
phone = 13800138000 - 索引列上套函数:
WHERE DATE(create_time) = '2026-09-01' - 左模糊:
LIKE '%abc',前导%走不了 B+Tree - 联合索引不满足最左前缀:索引
(a,b,c),条件只有b - JOIN 两列字符集 / collation 不一致
- 统计信息过期,优化器选错 → 可以
ANALYZE TABLE
-- 坏:对 create_time 套 DATE(),索引失效,变成全表扫
WHERE DATE(create_time) = '2026-09-01'
-- 好:直接对列做范围比较,能走 create_time 上的索引
WHERE create_time >= '2026-09-01 00:00:00'
AND create_time < '2026-09-02 00:00:00'
五、闻——SQL 看着没问题,查底层
EXPLAIN 挺正常、扫描行数也不多,但就是慢?别死磕 SQL 了,往下看。
5.1 是不是被锁堵住了
SQL 本身不复杂,偶尔卡几十秒,多半是等锁,不是算得慢。
-- sys 库视图:当前谁在被谁阻塞(blocking = 占着锁不放的人)
SELECT
waiting_pid, -- 等待方的连接 ID
waiting_query, -- 等待方正在执行的 SQL
blocking_pid, -- 阻塞方的连接 ID
blocking_query -- 阻塞方正在执行的 SQL
FROM sys.innodb_lock_waits\G
-- 查看所有未提交的事务:trx_started 很早的,很可能占着锁不释放
SELECT
trx_id, -- 事务 ID
trx_started, -- 事务开始时间
trx_mysql_thread_id, -- 对应连接 ID(可和 PROCESSLIST 对照)
trx_query -- 事务里最后一条 SQL
FROM information_schema.innodb_trx
ORDER BY trx_started;
-- InnoDB 引擎整体状态:事务、锁、Buffer Pool、最近死锁信息
SHOW ENGINE INNODB STATUS\G
输出很长,排查时重点找这几段:
| 段落 | 看什么 |
|---|---|
TRANSACTIONS | 有没有 LOCK WAIT 状态的事务 |
LATEST DETECTED DEADLOCK | 最近一次死锁,谁杀了谁 |
BUFFER POOL AND MEMORY | Buffer Pool 大小、命中率 |
处理思路:
- 事务别写太长,
@Transactional里别调 HTTP、别发 MQ - 多表更新固定顺序,减少死锁
- 高峰期别对大表
ALTER TABLE(会抢 MDL 锁)
5.2 Buffer Pool 命中率够不够
InnoDB 把热点数据页缓存在内存里(Buffer Pool)。内存不够就得读磁盘,什么 SQL 都像慢半拍。
-- 查看 Buffer Pool 配置大小(建议设为物理内存的 50%~70%)
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- 查看 Buffer Pool 运行统计
SHOW STATUS LIKE 'Innodb_buffer_pool%';
关键两个计数器:
| 变量 | 含义 |
|---|---|
Innodb_buffer_pool_read_requests | 读请求总次数(内存命中 + 磁盘读) |
Innodb_buffer_pool_reads | 不得不从磁盘读页面的次数 |
命中率粗算:
命中率 = (1 - Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests) × 100%
一般 95% 以上比较安心。机器内存有空闲,可以考虑调大 innodb_buffer_pool_size。
5.3 磁盘 IO 和 CPU
到机器上看(这不是 SQL,但是排查慢库必备):
top # wa 列 = IO 等待占比,高说明磁盘扛不住
iotop # 看哪个进程在狂读狂写磁盘
- CPU wa 高 → 磁盘慢,加索引也救不了 IO 瓶颈
- CPU us 高 → SQL 计算量大,排序、聚合、JOIN 太多
5.4 连接数是不是打满了
-- 实例允许的最大连接数
SHOW VARIABLES LIKE 'max_connections';
-- 历史峰值用了多少连接(接近 max_connections 就要警惕)
SHOW STATUS LIKE 'Max_used_connections';
-- 按用户/来源统计当前连接数,看是不是某个应用占满了
SELECT user, host, db, COUNT(*) AS conn_count
FROM information_schema.PROCESSLIST
GROUP BY user, host, db
ORDER BY conn_count DESC;
连接打满 → 新请求排队 → 应用侧报超时,这时候改 SQL 没用。
SHOW PROCESSLIST 里大量 Sleep 连接、应用报超时——先查连接池:
max_connections是不是太小- 连接借了没还(长事务、连接泄漏)
- Druid / HikariCP 池子是不是已经满了
这种时候你 EXPLAIN 到天亮也没用。
六、下药——对症下药
根据前面几步的发现,对号入座。
6.1 索引问题
-- 查看表上已有哪些索引,避免重复加
SHOW INDEX FROM your_table;
SHOW INDEX 重点列:
| 列 | 含义 |
|---|---|
Key_name | 索引名 |
Column_name | 索引包含的列 |
Seq_in_index | 列在联合索引中的顺序(1、2、3…) |
Cardinality | 基数估算,越大通常区分度越高 |
-- 在线加索引:INPLACE 原地构建,LOCK=NONE 尽量不阻塞读写
ALTER TABLE your_table
ADD INDEX idx_a_b_c (col_a, col_b, col_c),
ALGORITHM=INPLACE,
LOCK=NONE;
ALGORITHM=INPLACE:尽量不在服务器上复制整张表LOCK=NONE:加索引期间允许并发读写(大表仍可能阶段性变慢)
几个口诀:
- 联合索引遵循最左前缀:
(org_id, status, time)条件要从左往右写 - 区分度高的列放前面
- 覆盖索引:查询列都在索引里,
Extra出现Using index,少回表 - 写多读少的表,索引越多 INSERT/UPDATE 越慢
联合索引 (a, b, c) 相当于先按 a 排序,再 b,再 c:
- ✅
WHERE a = ? AND b = ?能用上 - ❌ 只有
WHERE b = ?,跳过了a,用不全
-- 统计信息过期时,优化器可能选错计划;ANALYZE 会重新采样索引基数
ANALYZE TABLE your_table;
6.2 写法问题
- 别
SELECT *,只拿需要的列 - 别
LIKE '%xxx'(前导%无法用 B+Tree 索引) - 别在索引列上做函数运算
- 大表
COUNT(*)别放热路径,能缓存就缓存(InnoDB 无 WHERE 时要扫索引/全表,大表很贵)
6.3 分页慢
LIMIT 100000, 10 的含义是:跳过前 10 万行,再取 10 行。MySQL 仍要扫描前面那些行,越往后越慢。
-- 坏:OFFSET 越大越慢
SELECT * FROM orders ORDER BY id LIMIT 100000, 10;
-- 好:游标分页,记住上一页最大 id,只往前扫
SELECT * FROM orders
WHERE id > 100000
ORDER BY id
LIMIT 10;
-- 延迟关联:子查询只查主键(窄、走覆盖索引),再 JOIN 回表取全列
SELECT o.*
FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 100000, 10
) t ON o.id = t.id;
还有一招:加搜索条件缩小结果集,别硬翻到第 5000 页。
6.4 表实在太大
单表过亿了,考虑分区、归档、分库分表——但这是最后手段。SQL 和索引都优化完了,单表数据量实在扛不住,再上这一步。
6.5 应用层也别漏
MyBatis 里循环查库(N+1),单条 SQL 不慢,接口照样超时:
// 坏:100 条主记录 → 101 次查询
for (Order o : orders) {
o.setItems(itemMapper.selectByOrderId(o.getId()));
}
// 好:JOIN 一次拉 / 批量 IN / MyBatis collection
七、手足无措时,先跑这段「急救脚本」
-- 1. 当前所有连接及正在跑的 SQL
SHOW FULL PROCESSLIST;
-- 2. InnoDB 引擎详情:事务、锁、Buffer Pool、死锁
SHOW ENGINE INNODB STATUS\G
-- 3. 慢查询相关计数(Slow_queries = 自启动以来慢 SQL 累计条数)
SHOW GLOBAL STATUS LIKE '%slow%';
-- 4. 当前是否存在锁等待
SELECT * FROM sys.innodb_lock_waits\G
输出截图留档,别凭感觉猜。
几个真实场景,帮你对号入座
场景 A:列表越翻越慢
第 1 页 50ms,第 5000 页 8s → 深分页问题,改游标分页或加搜索条件缩小范围。
场景 B:明明有索引,还是全表扫
phone 列有索引,type 却是 ALL → 八成隐式类型转换,字符串列别传数字。
场景 C:UPDATE 偶尔卡 30 秒
SQL 就一行主键更新 → 查 sys.innodb_lock_waits,被别的事务堵了。
场景 D:夜间报表把库拖垮
白天没事,22:00 CPU 100% → 大 JOIN + GROUP BY 缺索引,报表扔从库,别和备份撞车。
八、改完之后,怎么确认真的好了
别「感觉快了」就交差,留张对比表:
| 项 | 改之前 | 改之后 |
|---|---|---|
| EXPLAIN 的 type / key / rows | ||
| 执行耗时(同参数) | ||
| Rows_examined | ||
| 业务结果对不对 |
-- 在从库用真实参数跑,看每个步骤 actual time 和实际 rows
EXPLAIN ANALYZE SELECT ...;
几条容易忘的:
- 别只看 EXPLAIN 不跑真 SQL,MySQL 8.0 用
EXPLAIN ANALYZE确认 - 别在空表上验证,数据和生产差太多,计划完全不同
- 加索引要权衡写入,索引越多 INSERT/UPDATE 越慢——加完顺便瞄一眼写入接口
- 别跳过应用层,N+1 问题 EXPLAIN 单条 SQL 看不出来
九、面试口述:怎么答「MySQL 跑得慢怎么排查」
下面按面试口述整理。背主线,细节按追问补。正文里已经展开过的命令,这里只记答法。
30 秒版(先讲这个)
MySQL 慢我会按四步走:先定位嫌疑 SQL,用
SHOW PROCESSLIST或慢日志;再 EXPLAIN 看执行计划,看有没有全表扫、filesort、没用索引;如果计划正常还慢,查锁等待、Buffer Pool 命中率、磁盘 IO 和连接数;最后对症优化,改索引、改 SQL 写法、改深分页,改完同参数对比验证。不会一上来就加索引,先确认是不是锁或连接池的问题。
常问清单
| 问题 | 答法要点 |
|---|---|
| 整体思路是什么? | 定位 → 分析 → 深挖 → 优化 → 验证。口诀:先外后内,先量后改 |
| 怎么找到慢 SQL? | 正在慢用 PROCESSLIST;已经慢过了用慢日志 + mysqldumpslow / pt-query-digest;在线用 performance_schema |
| EXPLAIN 看哪些字段? | type、key、rows、Extra;8.0 再用 EXPLAIN ANALYZE 看 actual time |
| 有索引为什么还全表扫? | 隐式转换、列上套函数、左模糊、最左前缀、字符集不一致、统计过期 |
| 联合索引和最左前缀? | (a,b,c) 条件要从 a 开始写;区分度高的列放前面;覆盖索引少回表 |
| 深分页为什么慢? | LIMIT 大offset, n 仍要扫前面的行;改游标分页或延迟关联 |
| SQL 正常为什么还慢? | 行锁 / MDL、Buffer Pool、磁盘 IO、连接打满、连接池、从库延迟 |
| 怎么查锁? | sys.innodb_lock_waits、innodb_trx、SHOW ENGINE INNODB STATUS |
| Buffer Pool 够不够? | 命中率 ≈ (1 - reads / read_requests) × 100%,一般 95%+;太低可调大,通常占物理内存 50%~70% |
追问速答(一句话版)
| 追问 | 速答 |
|---|---|
EXPLAIN 和 EXPLAIN ANALYZE 区别? | 前者看估算计划,后者真执行看实际耗时 |
Using filesort 什么意思? | 额外排序,内存不够就落磁盘,很慢 |
Using temporary 什么意思? | 用了临时表,常见于 GROUP BY / DISTINCT 不当 |
COUNT(*) 大表为什么慢? | InnoDB 无 WHERE 时要扫索引/全表,大表很贵 |
| 覆盖索引是什么? | 查询列全在索引里,不用回表,Using index |
| 什么时候分库分表? | SQL 和索引都优化完了,单表数据量实在扛不住,最后手段 |
| 慢日志怎么开? | slow_query_log=ON,long_query_time=1 |
| 怎么 Kill 慢查询? | PROCESSLIST 拿到 Id,KILL Id; |
| 从库延迟怎么看? | SHOW REPLICA STATUS,看 Seconds_Behind_Source |
收尾可以加一句:
我排查 MySQL 慢查询,核心是先定位、再分析、后优化、必验证。不会上来就加索引,会先区分是 SQL 问题、锁问题还是资源问题。MySQL 8.0 里我会结合
EXPLAIN ANALYZE和performance_schema拿数据说话,而不是凭感觉改。
小结:记住这条线就行
PROCESSLIST / 慢日志 → 找到是哪条 SQL
EXPLAIN → 看计划有没有红灯
锁 / Buffer Pool / IO → SQL 正常还慢,查这里
改索引 / 改写法 → 对症下药
同条件再测一遍 → 确认没改坏
MySQL 8.0 慢查询排查,说到底就一句话:
先找到病人,再下刀;别还没看清症状就开药。
命令速查
-- 当前会话与 SQL
SHOW FULL PROCESSLIST;
-- InnoDB 引擎状态(锁、事务、死锁、Buffer Pool)
SHOW ENGINE INNODB STATUS\G
-- 看执行计划(估算 / 真实)
EXPLAIN SELECT ... \G
EXPLAIN ANALYZE SELECT ...;
-- 锁等待:谁 block 谁
SELECT * FROM sys.innodb_lock_waits\G
-- 表上有哪些索引
SHOW INDEX FROM table_name;
-- 更新表统计信息,纠正优化器估算
ANALYZE TABLE table_name;
# 慢日志 Top 10(按总耗时)
mysqldumpslow -s t -t 10 /var/log/mysql/slow.log
# 慢日志详细分析报告
pt-query-digest /var/log/mysql/slow.log > report.txt