小Cの已经记不起来的博客

SQL 慢查询优化:从执行计划到索引覆盖的排查步骤

前几天一个后台列表接口突然变慢,前端转圈转到我以为接口挂了。结果把慢查询日志打开一看,是一条看着很普通的 select ... where user_id = ? and status = ? order by created_at desc limit 20。表也不大,几十万行,但执行时间就是能跑到两秒多。后来 EXPLAIN 看了一眼,type=ALL,key 是 NULL,这玩意儿不慢才怪。

这篇就按我自己的排查顺序,从慢查询日志到执行计划,再到索引覆盖,把整个过程捋一遍。不是教科书,更多是实际踩坑之后觉得有用的步骤。

先别急着加索引,先找到那条慢 SQL

很多人一发现接口慢,第一反应就是“给这个字段加个索引”。但字段是哪个、SQL 长什么样、慢在数据库还是慢在代码,这些都还没搞清楚,加索引基本靠猜。

我一般先看慢查询日志有没有开:

show variables like 'slow_query_log';
show variables like 'long_query_time';

没开的话临时开一下,别一上来就设 0.1 秒,生产上日志能刷得你怀疑人生。可以先设个 1 秒,跑一段时间看看:

set global slow_query_log = on;
set global long_query_time = 1;

然后拿 mysqldumpslow 或者 pt-query-digest 把日志聚合一下,看哪条 SQL 出现次数多、总耗时高。有时候单次不算特别慢,但一秒跑几十次,那也挺要命。定位到具体 SQL 之后,再拿真实参数去数据库里跑 EXPLAIN,别拿一个 where id = 1 这种理想条件自欺欺人。

用 EXPLAIN 看执行计划,但别只盯着 type

定位到 SQL 之后,下一步就是执行计划。MySQL 里我一般先用:

explain select id, user_id, status, created_at
from orders
where user_id = 123 and status = 1
order by created_at desc
limit 20;

重点看这几列:type、key、rows、filtered、Extra。

type 从差到好大概是 ALL、index、range、ref、eq_ref、const。看到 ALL 基本就是全表扫描,但也不是说看到 range 就万事大吉。key 看实际用了哪个索引,有时候你建了索引,它没用,或者用了另一个选择性很差的索引。rows 是估算扫描行数,如果这个数大得离谱,说明要么索引不对,要么统计信息不准。Extra 里如果出现 Using filesort、Using temporary,也得留意,尤其是数据量大的时候。

MySQL 8 里还可以用 explain analyze 看实际执行情况:

explain analyze
select id, user_id, status, created_at
from orders
where user_id = 123 and status = 1
order by created_at desc
limit 20;

这个会真的执行 SQL,然后给出实际耗时和实际行数。估算行数和实际行数差很多的时候,往往就是统计信息或者数据倾斜的问题。我之前就遇到过 rows 估算几千,实际扫了几十万,最后发现是某个状态值占了整张表的 90%。

索引没命中,先排查这几个常见坑

执行计划里明明有索引,但 key 就是 NULL,这种情况我遇到最多的几个原因:

第一个是最左前缀。联合索引 (user_id, status, created_at),你查询条件里没有 user_id,那这个索引基本用不上。不是说你 where status = 1 就一定不行,而是要看优化器愿不愿意走索引扫描,数据量一大通常还是全表。

第二个是字段上套了函数。比如:

where date(created_at) = '2025-01-01'

这种写法很难走 created_at 的索引,改成范围查询:

where created_at >= '2025-01-01 00:00:00'
  and created_at < '2025-01-02 00:00:00'

第三个是隐式转换。字段是 varchar,你传了个数字,或者字段是 int,你传了个字符串,都可能让索引失效。这个坑很隐蔽,尤其是接口参数从 JSON 里解析出来的时候,类型一不小心就变了。

第四个是 like '%xxx'、or 混用、排序字段和索引顺序对不上。比如索引是 (user_id, status, created_at),你 where user_id = ? order by created_at,中间少了 status,排序能不能用上索引就得看具体情况了,别想当然。

从回表到索引覆盖,别为了覆盖而覆盖

二级索引里存的是索引列加主键值。通过二级索引找到主键之后,还要回主键索引取整行数据,这就是回表。查询返回的列越少、命中索引的列越多,回表的成本就越低。如果查询需要的列全都在索引里,那就能看到 Extra: Using index,也就是索引覆盖。

比如上面那条 SQL:

select id, user_id, status, created_at
from orders
where user_id = 123 and status = 1
order by created_at desc
limit 20;

建一个 (user_id, status, created_at) 的联合索引,id 是主键,本来就在二级索引里,那这个查询就有机会走覆盖索引,不用回表。但如果 select 里再加一个 amount,而索引里没有 amount,那就得回表。

不过我不建议一看到回表就把所有列都塞进联合索引。索引越大,写入越慢,占的空间也越多。覆盖索引适合那种查询频率高、返回列少、条件相对固定的 SQL。你要是为了一个一天跑几次的报表,把五六个列拼成一个巨大索引,最后维护成本可能比省下来的那点查询时间还高。

分页和子查询这种慢,执行计划也会骗你

深分页也是老生常谈:

select * from orders order by id limit 100000, 20;

这种写法越往后越慢,因为它得先扫过前面十万行。可以考虑延迟关联:

select o.*
from orders o
join (
  select id from orders order by id limit 100000, 20
) t on o.id = t.id;

如果业务允许,用游标分页更好,比如记住上一页最后一个 id,下一页直接 where id > ? order by id limit 20。子查询也一样,别看到 explain 里有个 DERIVED 就觉得没事,实际执行的时候可能物化出一张大临时表。能用 join 改写的,先试试 join。

改完 SQL 记得再跑一遍,别只看感觉

最后一步其实最重要:改完 SQL 或者加完索引,把原来的真实参数再跑一遍 explain analyze,对比实际耗时、扫描行数和返回行数。别只看“好像快了”,也别只在测试库几十条数据上验证。生产数据分布不一样,执行计划可能完全两回事。

加索引之前也顺手看一眼写入频率,别一个高频写入表上加一堆联合索引,查询是快了,写入开始排队。慢查询优化这事儿,说到底就是多看执行计划、多拿真实数据跑,少凭感觉加索引。基本就这些,不出问题的话,大部分慢查询都能定位个七七八八。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放