慢查询治理:从索引到 SQL 重写的全面优化 — editorial cover photo

一条慢SQL拖垮整个服务的全复盘

一条慢 SQL 拖垮整个服务——慢查询治理是后端工程师的基本功。

2025年9月15日·返回文章列表
文章大纲

我在联易融做慢 SQL 治理那阵,发票查询的核心链路卡在一条聚合 SQL 上。压测时单条接近 4 秒,前端页面整片空白,下游策略服务全部排队等结果。当时第一反应是加索引,写完发现执行计划里 type 还是 ALL,扫描行数没降。问题不在有没有索引,而在索引有没有被用上。那轮治理做完,核心链路 RT 大概降了 30%,这个数字不大,但每毫秒都是从执行计划里一行一行抠出来的。

慢 SQL 治理是后端的基本功,但很多人把它简化成了加索引四个字。真正难的不是建索引,是看懂执行计划、判断索引为什么失效、在索引和 SQL 写法之间反复权衡。下面把那轮治理里反复验证过的判断路径写清楚。

先看执行计划,别凭感觉加索引

PlantUML
正在加载图表…
PlantUML diagram

图:EXPLAIN 中 type 访问类型的优劣梯度,从最差的全表扫描到最优的常量定位

发现慢 SQL 通常有三个入口,慢查询日志按 long_query_time 阈值沉淀,APM 从应用层追溯具体哪条 SQL 慢,DB 监控看 RT 分布和慢 SQL 排行。但这三步只是定位,真正的判断发生在 EXPLAIN 之后。

EXPLAIN 输出里有几个字段决定了一条 SQL 快还是慢。type 是访问类型,从最差的 ALL(全表扫描)到 index(全索引扫描)、range(范围扫描)、ref(索引等值查找)、const(主键或唯一索引常量),逐级变优。key 是实际选中的索引,如果这里是 NULL,说明优化器放弃了所有索引。rows 是预估扫描行数,这个数字直接决定了 IO 量。Extra 里出现 Using filesortUsing temporary,说明还需要额外的排序或临时表,这两个是明确的优化信号。

type=ALL 是红色警报

百万行表上 type=ALL 意味着全表扫描,每来一个请求扫一遍。这是慢查询最常见的根因。看到 ALL,先想清楚是没建索引,还是建了没用上。这两条路径的修法完全不同。

我自己的判断顺序是固定的,先看 type,ALL 或 index 就往索引方向查,range 以上还慢就看 rows 和 Extra。rows 高说明范围扫得太大,Extra 有 filesort 或 temporary 就要改排序或改写 SQL。这套顺序比凭感觉加索引快得多,因为每个字段的异常都指向一类明确的修法。

索引失效,比没有索引更常见

PlantUML
正在加载图表…
PlantUML diagram

图:索引失效的五种典型场景及对应修法

联易融那条聚合 SQL,后来查出来不是没索引,是查询条件加了一层函数把索引挡在外面。这是慢 SQL 治理里最隐蔽的一类问题,索引建得好好的,但执行计划就是不走它。

索引失效有几个典型场景,每一个都能单独让 type 退化成 ALL。

第一类是条件列上加了函数。WHERE DATE(create_time) = '2025-09-15' 看着合理,但 DATE() 函数作用在索引列上,B+ 树的有序性被破坏,优化器只能全表扫。改写法是把函数挪到值那边,用范围查询代替,WHERE create_time >= '2025-09-15' AND create_time < '2025-09-16',索引立刻生效。

第二类是隐式类型转换。phone 字段是 varchar,查询写成 WHERE phone = 13800000000,MySQL 会把字符串转成数字再比,索引上的字符串值没法直接匹配数字比较,于是索引失效。修法就是把值加引号,WHERE phone = '13800000000'。这种坑特别难查,因为 SQL 不报错,结果也对,只是慢。

第三类是违反最左前缀。联合索引 (a, b, c) 只有按 aa,ba,b,c 的顺序查才能用上,跳过 a 直接查 b 或 c,索引白建。第四类是 LIKE 左通配,WHERE name LIKE '%张' 前导百分号让索引无法定位起点。第五类是 OR 条件里部分列没索引,优化器为了保证结果正确宁可全表扫。

最左前缀是最常踩的坑

联合索引的列顺序一旦定下来,查询就必须从最左列开始匹配。设计联合索引时要同时考虑两个维度,区分度高的列靠前(user_id 比性别更该在前),查询频率高的列靠前。这两个维度偶尔冲突,区分度和频率都高优先,其次看是否能覆盖 ORDER BY 避免额外 filesort。

覆盖索引,索引优化的天花板

索引优化做到极致是覆盖索引,也就是 SELECT 用到的所有列都在索引里,不需要回表。普通二级索引查到主键后还要回主键索引取完整行,这是两次 IO。如果查询的列全在二级索引里,直接从索引返回,省掉回表这一步。

-- 索引 (user_id, status, create_time)
SELECT user_id, status, create_time FROM orders
WHERE user_id = 123; -- 三列都在索引里, 免回表

覆盖索引不是免费的。它要求索引包含所有查询列,索引变宽,写入和存储成本都上升。所以覆盖索引通常只用在两类场景,热点查询列很少(三五个字段以内),或者 RT 要求极严的核心链路。给所有查询都做覆盖索引不现实,索引越多写入越慢,这个取舍必须想清楚。

SQL 重写,索引到位之后还能抠

索引层面榨干之后,SQL 本身的写法还有空间。这部分往往是 ROI 最高的,因为不用改表结构。

SELECT * 是最常见的浪费。它把所有列都读出来,既增加了 IO,又让覆盖索引失效(覆盖索引不可能覆盖 *)。改成具体列,不仅省 IO,还重新打开覆盖索引的可能。

深分页是另一类经典问题。LIMIT 1000000, 20 看着只取 20 行,实际 MySQL 要先扫 1000020 行再丢弃前 100 万。offset 越大越慢,而且不是线性慢,是越往后越陡。修法是游标分页,记住上一页最后一条的 id,下一页用 WHERE id > #{lastId} ORDER BY id LIMIT 20,每次稳定扫 20 行。游标分页的代价是不能跳页,只能上一页下一页翻,对后台管理类的列表够用,对需要跳页的 C 端列表得另外想招。

JOIN 优化的核心是小表驱动大表。驱动表的数据量决定了被驱动表的扫描次数,让小表做驱动表,循环次数就少。这个原则在写 JOIN 时要主动用,STRAIGHT_JOIN 可以强制指定驱动表顺序,但一般靠让小结果集放前面就够了。

子查询在某些场景改成 JOIN 会快很多,MySQL 对子查询的优化历史上一直是弱项,能改 JOIN 就改。大事务要拆,长事务持有锁和连接的时间长,会放大慢 SQL 的连锁影响。

数据量层面,最后才考虑的分库分表

索引和 SQL 都优化到极致,单表数据量本身仍是瓶颈时,才动数据层面的手术。这个顺序不能反,分库分表的成本是结构性的,一旦做了几乎不可逆。

冷热分离是成本最低的一步,把历史数据归档到冷表,热表变小,所有查询都快。汇总表适合报表类场景,预计算聚合结果,避免实时 GROUP BY。读写分离把读压力分到从库,但要注意主从延迟对实时性要求高的接口不适用。分库分表是最后手段,只有在单表数据量到亿级、且前面所有手段都做过之后才考虑。

治理的优先级

PlantUML
正在加载图表…
PlantUML diagram

图:慢 SQL 治理的四个梯度,越靠前成本越低、ROI 越高

慢 SQL 治理的判断顺序,我固定按四个梯度推进。先 EXPLAIN 看 type 和 rows,定位是没索引还是索引失效。再看具体失效原因,函数、类型转换、最左前缀、左通配,逐个排除。索引层面到顶后转 SQL 重写,SELECT 星号、深分页、JOIN 顺序。最后才是数据量优化,归档、汇总、分表。

这个顺序的逻辑是,越靠前的手段成本越低、风险越小、ROI 越高。加个索引几分钟,回滚也是几秒。重写 SQL 要改代码、要测试。分库分表动表结构,牵连所有上下游。一上来就分库分表是最常见的过度设计,绝大多数慢 SQL 在索引和重写这两层就能解决。

联易融那轮治理的产出里,30% 的 RT 提升里索引和重写占了大头,分表一条都没动。把执行计划读懂、把失效原因查清,比追新名词管用得多。

Continue Reading

关联文档推荐

查看全部
慢调用治理:从超时到熔断的链路优化 — vibrant cover for technical blog article多级缓存:从浏览器到 DB 的全链路缓存设计 — vibrant cover for technical blog article容量评估:从 QPS 推导到资源规划的全流程 — editorial cover photo
作者:archy.shawn
声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。