慢 SQL 是几乎每个用到关系型数据库的团队都会遇到的问题:有的 UPDATE 一跑就是几分钟,有的报表查询扫了几千万行,有的统计 SQL 动不动就卡住。多数问题其实反复踩在同一批坑里。这篇文章不堆语法,而是把慢 SQL 的成因、排查流程和典型场景的优化手法串成一套可复用的方法论,帮你在下次遇到"报表跑不出来"时知道从哪下手。
先搞懂:一条 SQL 为什么会慢
动手优化之前,得先明白 MySQL 是怎么执行一条 SQL 的。它大致要经过:连接器 → 分析器 → 优化器 → 执行器 → 返回结果。其中对性能影响最大的是优化器和执行器——优化器决定"怎么查"(选哪个索引、表按什么顺序连接、要不要临时表排序),执行器按计划干活。
SQL 变慢通常逃不开这几类原因:
- 扫描了太多无关数据,比如没建索引导致全表扫描;
- 生成了巨大的临时表,比如 GROUP BY 或去重没有合适索引;
- 锁等待,比如长事务占着锁不放;
- 优化器选错了执行计划,往往是统计信息不准。
索引之所以能加速,是因为 B+ 树像字典目录:等值查询直接定位,范围查询顺着目录往后翻,覆盖索引甚至连回表都省了。但索引会在这些情况下失效,值得刻进肌肉记忆:
- 对索引列用函数,如
WHERE YEAR(create_time) = 2026; - 隐式类型转换,比如字符串字段和数字比较;
- 左模糊匹配
LIKE '%xx'; - 否定条件
!=、NOT IN。
JOIN 用的是嵌套循环:外层驱动表的每一行,去内层被驱动表找匹配。优化的关键就一句话——驱动表要小,被驱动表连接列要有索引。很多千万级扫描的慢查询,根子就在被驱动表缺索引。
一套通用的排查流程
遇到慢 SQL 不必慌,按固定步骤走就不会乱:
- 找到问题 SQL:开启慢查询日志(
slow_query_log=ON,设置long_query_time),用 pt-query-digest 或 mysqldumpslow 分析,或用SHOW FULL PROCESSLIST看当前在跑什么。 - 看执行计划:
EXPLAIN重点关注四个字段——type(访问类型,ALL是全表扫描的危险信号)、key(是否用到索引)、rows(预估扫描行数,越大越危险)、Extra(出现Using filesort、Using temporary都要警惕)。 - 定位根源:对着 SQL 和表结构逐条自查:WHERE 列有没有索引、索引列上有没有函数或隐式转换、JOIN 被驱动表连接列有没有索引、ORDER BY/GROUP BY 的列在不在索引里、是不是
SELECT *挡住了覆盖索引、分页 OFFSET 是不是太大。 - 实施优化:加复合索引、改写 SQL(子查询改 JOIN、UNION 改 UNION ALL)、大事务拆小、用生成列把函数结果落成可索引的列。
- 验证效果:改完先用 EXPLAIN 确认执行计划真的变了,在测试环境验证后再上线,别凭感觉直接发布。
八个典型场景与对应解法
下面这些是生产环境里高频出现的慢 SQL 形态,把它们和解法对上号,能覆盖相当一部分日常问题。
超长 IN 列表的 UPDATE。 一次性更新几千个 ID,解析开销大、锁在内存里累积、undo 日志被撑大,容易锁住一切。优先分批处理(每批一两百个 ID),或把 ID 灌进临时表再 JOIN 更新;更彻底的做法是回头看业务,为什么一次要更新这么多。
逗号连接的多表关联扫全表。 用逗号连接、又缺复合索引时,优化器难生成高效计划,动辄扫描千万行。改成显式 INNER JOIN,并按 JOIN 和过滤条件建复合索引:
ALTER TABLE user_test ADD INDEX idx_userid_expirets (UserID, ExpireTS);
ALTER TABLE user ADD INDEX idx_userid_custid_createts_status (UserID, CustID, CreateTS, Status);
前导通配符的 NOT LIKE '%test%' 无法用索引,若必须用可考虑全文索引或加个标记字段。
ORDER BY RAND() 取少量随机行。 它要给所有符合条件的行生成随机数、放进临时表全排序,为了取 50 条却处理上千万行,数据量大时临时表还会落盘。改用主键随机(先取 MIN/MAX ID,再随机定位)或先用索引缩小扫描基数,避开全量排序。
大偏移量分页 LIMIT 164000, 1000。 MySQL 会先扫描 M+N 行再丢掉前 M 行,偏移越大无效开销越大。用游标分页(记住上一页最后一条的排序值,WHERE sn < @last_sn ORDER BY sn DESC LIMIT N)能把扫描行数稳定在页大小附近,翻到第几页都不变慢。
UNION 去重拖出超长查询。 UNION 默认去重,需要全排序或哈希临时表。若业务上确认两个子查询结果不重复,直接改 UNION ALL 省掉去重,再给过滤字段补索引,耗时能从小时级降到秒级。
超长 IN 列表的 SELECT。 上万个值的 IN 解析开销极大。把这些值插入临时表并建主键,再 JOIN 查询,优化器会选临时表当驱动表,避开超长列表解析:
CREATE TEMPORARY TABLE temp_card_nos (card_no VARCHAR(50) PRIMARY KEY);
-- 批量插入后 JOIN
SELECT ... FROM biz_table m
JOIN temp_card_nos t ON m.card_no = t.card_no;
索引没覆盖 WHERE 加 ORDER BY。 当没有一个索引能同时满足过滤和排序时,会产生额外排序。按"等值条件列在前、范围条件列居中、排序列放最后"的顺序建覆盖索引,MySQL 就能直接定位且天然有序,省掉 filesort。
缺主键导致 UPDATE 长时间锁等待。 WHERE 条件列没有主键或唯一索引时,MySQL 只能全表扫描并加锁,锁等待动辄几十秒。补上主键后锁等待可降到毫秒级——这是最基础、却最常被忽略的一条。
可以记住的几条黄金法则
把上面的场景归纳一下,日常优化时对照检查即可:
- 索引是基石,但要合理设计:复合索引遵循"等值在前、范围居中、排序最后",避免在索引列上用函数或触发隐式转换,尽量用覆盖索引。
- 小结果集驱动大结果集:JOIN 时让行数少的表当驱动表,被驱动表连接字段必须有索引。
- 分而治之:大操作拆小、UNION 改 UNION ALL、大分页改游标分页。
- 警惕隐蔽杀手:ORDER BY RAND()、超长 IN 列表、
LIKE '%xx'都要换写法。 - 善用生成列:把函数计算结果落成列再建索引,绕开索引失效。
- 永远先看执行计划:EXPLAIN 是你的眼睛,
type=ALL和巨大的rows就是要动手的信号。
慢 SQL 优化说到底不是记住一百条技巧,而是养成"先看执行计划、再动手改"的习惯。把排查流程固定下来,多数问题都能在几步之内定位到根因。