MySQL 8.0 跑得慢怎么排查?像医生看病,先望闻问切

115 阅读16分钟
晚上好,消失几天,最近领导疯狂给我派活,给我忙的不可开交,早上刚到工位就喊过去开会,对需求,给我累的,最近两个星期都不好受了,抽时间更文章。用「望闻问切、先外后内」的思路排查 MySQL 8.0 + InnoDB 慢查询。先找嫌疑 SQL,再 EXPLAIN 看计划,然后查锁和硬件,最后对症下药。命令可直接 copy,每条下面都写了它在干什么、输出怎么看。

线上接口超时、报表跑一夜、分页越翻越慢——很多时候根因就是一条 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 dataInnoDB 在读/过滤行,关注扫了多少、返回多少
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 万行——索引不是没建,是失效了。常见原因:

  1. 隐式类型转换:字符串列传了数字,如 phone = 13800138000
  2. 索引列上套函数:WHERE DATE(create_time) = '2026-09-01'
  3. 左模糊:LIKE '%abc',前导 % 走不了 B+Tree
  4. 联合索引不满足最左前缀:索引 (a,b,c),条件只有 b
  5. JOIN 两列字符集 / collation 不一致
  6. 统计信息过期,优化器选错 → 可以 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 MEMORYBuffer 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 ...;

几条容易忘的:

  1. 别只看 EXPLAIN 不跑真 SQL,MySQL 8.0 用 EXPLAIN ANALYZE 确认
  2. 别在空表上验证,数据和生产差太多,计划完全不同
  3. 加索引要权衡写入,索引越多 INSERT/UPDATE 越慢——加完顺便瞄一眼写入接口
  4. 别跳过应用层,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