我在联易融做慢 SQL 治理那阵,发票查询的核心链路卡在一条聚合 SQL 上。压测时单条接近 4 秒,前端页面整片空白,下游策略服务全部排队等结果。当时第一反应是加索引,写完发现执行计划里 type 还是 ALL,扫描行数没降。问题不在有没有索引,而在索引有没有被用上。那轮治理做完,核心链路 RT 大概降了 30%,这个数字不大,但每毫秒都是从执行计划里一行一行抠出来的。
慢 SQL 治理是后端的基本功,但很多人把它简化成了加索引四个字。真正难的不是建索引,是看懂执行计划、判断索引为什么失效、在索引和 SQL 写法之间反复权衡。下面把那轮治理里反复验证过的判断路径写清楚。
先看执行计划,别凭感觉加索引
图: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 filesort 或 Using temporary,说明还需要额外的排序或临时表,这两个是明确的优化信号。
百万行表上 type=ALL 意味着全表扫描,每来一个请求扫一遍。这是慢查询最常见的根因。看到 ALL,先想清楚是没建索引,还是建了没用上。这两条路径的修法完全不同。
我自己的判断顺序是固定的,先看 type,ALL 或 index 就往索引方向查,range 以上还慢就看 rows 和 Extra。rows 高说明范围扫得太大,Extra 有 filesort 或 temporary 就要改排序或改写 SQL。这套顺序比凭感觉加索引快得多,因为每个字段的异常都指向一类明确的修法。
索引失效,比没有索引更常见
图:索引失效的五种典型场景及对应修法
联易融那条聚合 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) 只有按 a、a,b、a,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 ordersWHERE 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。读写分离把读压力分到从库,但要注意主从延迟对实时性要求高的接口不适用。分库分表是最后手段,只有在单表数据量到亿级、且前面所有手段都做过之后才考虑。
治理的优先级
图:慢 SQL 治理的四个梯度,越靠前成本越低、ROI 越高
慢 SQL 治理的判断顺序,我固定按四个梯度推进。先 EXPLAIN 看 type 和 rows,定位是没索引还是索引失效。再看具体失效原因,函数、类型转换、最左前缀、左通配,逐个排除。索引层面到顶后转 SQL 重写,SELECT 星号、深分页、JOIN 顺序。最后才是数据量优化,归档、汇总、分表。
这个顺序的逻辑是,越靠前的手段成本越低、风险越小、ROI 越高。加个索引几分钟,回滚也是几秒。重写 SQL 要改代码、要测试。分库分表动表结构,牵连所有上下游。一上来就分库分表是最常见的过度设计,绝大多数慢 SQL 在索引和重写这两层就能解决。
联易融那轮治理的产出里,30% 的 RT 提升里索引和重写占了大头,分表一条都没动。把执行计划读懂、把失效原因查清,比追新名词管用得多。








