索引失效那篇讲的是"为什么没走索引",讨论的是 SQL 写法本身。但线上遇到的"某条 SQL 突然变慢了",原因不止没走索引一种,而且它可能根本不是 SQL 的问题------是被锁堵住了。这篇把排查的顺序理一遍:先确认慢在哪,再看执行计划,最后按原因分类。
第一步:拿到慢的是哪条
改 SQL 之前得先知道改哪条,靠的是慢查询日志。
sql
show variables like 'slow_query_log%';
show variables like 'long_query_time';
三个关键参数:
| 参数 | 作用 | 默认 |
|---|---|---|
slow_query_log |
开关 | OFF |
long_query_time |
超过多少秒算慢 | 10 秒 |
slow_query_log_file |
日志文件路径 | 数据目录下 |
sql
-- 不用重启就能开
set global slow_query_log = on;
set global long_query_time = 1;
注意 long_query_time 的默认值是 10 秒,而线上大部分慢 SQL 在几百毫秒的量级,按 10 秒去抓基本什么都抓不到,一般调成 1 秒甚至 0.x 秒。
日志本身是一行行的原始记录,量大了没法直接看,用工具聚合成"哪类 SQL 排第一、执行了多少次、平均耗时、扫描行数":
shell
mysqldumpslow -s t /var/lib/mysql/slow.log # 按总耗时排序
pt-query-digest /var/lib/mysql/slow.log # 报告更细,推荐
除了历史日志,
show processlist能看到当前正在执行的语句和它的state,用来抓"正在发生"的慢查询。这里的state后面判锁还要用到。
第二步:看执行计划
锁定语句之后,EXPLAIN 是绕不开的:
sql
explain select * from t where a = 1 and b > 2;
输出里重点就四列:
| 列 | 看什么 |
|---|---|
type |
访问方式,ALL 全表扫描最差;由好到坏 const > eq_ref > ref > range > index > ALL |
key |
实际用的索引,NULL 就是没走 |
rows |
预估扫描行数,和实际差太远说明统计信息不准 |
Extra |
额外信息,几处坑都在这 |
Extra 里几个常见的值:
| 值 | 含义 |
|---|---|
Using filesort |
排序没走索引,额外做了一次排序 |
Using temporary |
建了临时表,常见于 group by、distinct |
Using index |
覆盖索引,要的列都在索引里,不用回表,是好事 |
Using index condition |
用上了索引下推 |
Using where |
拿回来的行还要再过滤一遍 |
Using filesort 和 Using temporary 是两个明显信号,看到就该查这里是不是缺了对应索引。这条线索和 索引失效那篇里读 EXPLAIN 的部分是同一个。
按原因分类
没走索引
最常见,也最好办。条件写法破坏了 B+ 树的顺序,或者优化器算下来觉得全表更划算。具体分哪几种、怎么改写,都在 索引失效的场景总结 里,这里不重复。
走了索引,但走错了
key 列有值,不是 NULL,但选的不是你以为的那个索引。
原因是优化器靠统计信息 估代价,统计信息不准就会选错。rows 明显偏离实际、或者 possible_keys 里有更合适的索引却没被选中,基本都是这个情况。
sql
analyze table t; -- 重新收集统计信息
多数情况下 analyze 完就好了。还不行可以用 force index 硬指定,但这属于打补丁,多半是统计信息或者 SQL 设计本身有问题,别当常规手段。
扫的行不多,但回表太多
rows 很小,type 也是 ref,可就是慢。这时要看 select 的列------如果查的列不在索引里,每命中一行就要回一次主键索引,也就是回表。命中一万行就是一万次随机 IO。
两个方向:把要查的列放进索引做成覆盖索引(Extra 会显示 Using index),或者干脆别 select *,只查真正要用的列。
深分页
sql
select * from t order by id limit 1000000, 10;
这条 SQL 只想要 10 行,但 MySQL 得先扫出前 1000010 行、再把前面的丢掉。limit 的偏移量越大越慢,耗时和数据量成正比。
两种改法:
sql
-- 游标:记住上一页最后一条的 id,下一页从它往后取
select * from t where id > 1000000 order by id limit 10;
-- 延迟关联:先只在索引上翻页拿到主键,再回表取这 10 行
select * from t
join (select id from t order by id limit 1000000, 10) x using (id);
游标的办法最快,但要前端配合;延迟关联不用改接口,把回表推迟到最后那 10 行上。
join 太多,驱动表选错
多表 join 时优化器要决定以哪张表为驱动表。驱动表越大、被驱动表上没索引,代价就越离谱。EXPLAIN 有多行输出,table 列的顺序就是执行顺序,重点看被驱动表的 type 是不是 ALL。
join 的表数也建议控制住,MySQL 对 join 的优化能力有限,超过三张表就该考虑是不是 SQL 设计本身要拆。
不是 SQL 慢,是被锁堵住了
这一类最容易被误判。SQL 本身写得没问题,平时也很快,某段时间突然慢------因为它要改的行正被别的事务占着,一直在等锁。
判断办法是看 state:
sql
show processlist;
如果状态是 Waiting for table metadata lock、Waiting for row lock 之类,就不是执行计划的问题。再看谁占着锁:
sql
select * from information_schema.innodb_trx order by trx_started;
trx_started 很早的记录就是长事务的嫌疑对象。长事务的危害在 Undo Log 那篇里讲过,它不只堵住别人,还会让 undo 一直清不掉。
一个区分技巧:慢查询日志里如果某条语句
rows_examined很小、rows_sent也很小,却耗了好几秒,基本可以断定在等锁------扫描不到几行不可能慢,时间都花在等待上。
环境变了
前面几条都是针对单条 SQL 的,还有一类慢是整个环境造成的,SQL 一个字没改。
| 变化 | 表现 |
|---|---|
| 数据量涨了 | 原来几千行走索引很快,涨到几百万行扫描代价变了,优化器可能改走全表 |
buffer pool 装不下 |
热数据进不了内存,每次查都落磁盘随机 IO,innodb_buffer_pool_size 太小 |
| 机器负载高 | CPU、磁盘 IO 被别的进程占满,SQL 本身没问题但响应变慢 |
| 并发上来了 | 单条不慢,一起跑就慢,锁竞争和资源竞争 |
这一类要在 SQL 之外找,看监控里的 QPS、连接数、磁盘 IO、buffer pool 命中率。
排查顺序
串成一条路,遇到慢 SQL 按这个顺序走,能少绕弯:
- 先分清是"执行慢"还是"被堵住" 。看
show processlist的state,或者在慢日志里看扫描行数和耗时是否成正比。被锁的先去解决锁,改 SQL 没用。 - 再拿
EXPLAIN看计划 。type = ALL或者key = NULL,进"没走索引";key有值但选错了,看统计信息;rows小但还是慢,看回表和排序(Extra)。 - SQL 本身没问题就往外看 。数据量、
buffer pool、机器负载、并发。
大部分线上慢 SQL 落在第 2 步,而第 2 步里的绝大多数又是"没走索引",也就是索引失效那篇里列的那些场景。