线上系统运行得好好的,某天突然有用户反馈页面打开特别慢,一查日志发现是一条原本几十毫秒就能返回的SQL,现在要跑十几秒。这种情况在业务数据量持续增长的项目里非常常见,而它往往不是突然出现的故障,而是日积月累的隐患终于越过了某个临界点。本文将系统梳理一条SQL变慢的常见原因,并配合实例给出排查思路和解决方法。

一、先弄清楚SQL到底慢在哪里
拿到一条慢SQL,第一步不是急着改写,而是确认它的慢属于哪种类型。是每次执行都慢,还是偶发性变慢?是全表扫描导致的CPU飙高,还是锁等待导致的时间堆积?这两种问题的处理方向完全不同。偶发性慢通常和锁竞争、缓存失效、批量任务挤占资源有关;而稳定的慢则多半是执行计划出了问题,比如索引没有命中。
在MySQL中,可以通过开启慢查询日志来收集证据,设置long_query_time为一个较小的阈值(比如0.5秒),观察慢SQL出现的频率和规律。同时结合SHOW PROCESSLIST查看当前正在执行的语句状态,如果发现大量语句处于Waiting for table metadata lock或者Sending data状态,就说明问题可能出在锁或者数据访问量上,而不是SQL本身写错了。
另一个容易被忽略的方向是数据库服务器本身的资源状况。磁盘IO如果被备份任务、大批量导入占满,即使SQL写得再好也会变慢。所以排查时应同时观察CPU、内存、磁盘IO和网络指标,先排除环境因素,再进入SQL层面的分析。
二、用EXPLAIN分析执行计划,找出索引用没用上
确认问题出在SQL本身之后,最核心的工具就是EXPLAIN。在SQL前面加上EXPLAIN执行,重点看type、key、rows和Extra这几列。type如果是ALL,说明走了全表扫描;key为NULL说明没有使用任何索引;rows是预估扫描行数,如果一张百万行的表rows显示接近一百万,那慢的原因基本就锁定了。
-- 查看执行计划 EXPLAIN SELECT * FROM orders WHERE user_id = 12345 AND create_time >= '2024-01-01' ORDER BY create_time DESC LIMIT 20; -- 典型的坏结果: -- type: ALL, key: NULL, rows: 1850000 -- 说明全表扫描了近两百万行
常见的索引失效场景有以下几类,每一类都值得单独检查。第一类是隐式类型转换,比如字段是varchar类型,查询条件却写成WHERE phone = 13800001111,MySQL需要把每一行的字符串转成数字再比较,索引直接失效。第二类是对索引字段使用函数或表达式,如WHERE DATE(create_time) = '2024-06-01',改成范围条件WHERE create_time >= '2024-06-01' AND create_time < '2024-06-02'才能命中索引。第三类是联合索引不满足最左前缀原则,索引建立在(a, b, c)上,查询条件只给了b和c,索引就无法使用。
此外还有几个隐蔽的写法陷阱:使用LIKE '%关键字'前缀模糊匹配会导致索引失效;使用OR连接条件时,如果其中一个字段没有索引,整个查询都会退化为全表扫描;NOT IN、!=这类否定条件在多数情况下优化器会放弃索引。逐项核对自己的SQL是否踩了这些坑,往往能直接找到病因。
三、数据量增长带来的统计信息过期与执行计划漂移
很多SQL并不是一开始就慢,而是随着数据量增长逐渐变慢的。这背后的一个重要机制是优化器依赖表的统计信息来选择执行计划,当统计信息过期或者数据分布发生倾斜时,优化器可能选择一个错误的索引或者错误的连接顺序,导致原本几百毫秒的查询变成几十秒。
一个典型场景是分页查询,LIMIT 1000000, 20这种深分页写法,MySQL需要先扫描并丢弃前一百万行,越往后翻页越慢。解决的思路是利用上一页的排序值做游标定位:
-- 深分页的坏写法,越翻越慢
SELECT * FROM orders ORDER BY id LIMIT 1000000, 20;
-- 游标写法,利用上一页最后一条记录的id定位
SELECT * FROM orders WHERE id > 1000000
ORDER BY id LIMIT 20;
-- 如果必须按偏移分页,可用延迟关联减少回表
SELECT o.* FROM orders o
INNER JOIN (
SELECT id FROM orders ORDER BY id LIMIT 1000000, 20
) t ON o.id = t.id;对于统计信息过期的问题,可以定期执行ANALYZE TABLE重新采样,让优化器拿到准确的数据分布。如果确定优化器选错了索引,还可以临时使用强制索引提示FORCE INDEX(idx_name)来纠正执行计划,但要记住这只是止血手段,根因还是要靠分析统计信息和数据分布来解决。
四、锁等待、连接数与缓存的连锁反应
有一类慢SQL和SQL写法无关,而是被其他操作拖慢的。比如一个长事务持有行锁不释放,后续所有修改相同行的语句都会进入锁等待,表现为这些SQL突然集体变慢。通过SELECT * FROM information_schema.INNODB_TRX可以找到长时间运行的事务,必要时Kill掉阻塞源头。
另一个常见诱因是Buffer Pool命中率下降。当某个大批量查询把大量冷数据刷进内存,把原本的热数据挤出去后,后续查询需要频繁从磁盘读取数据,整体性能明显下滑。可以通过SHOW ENGINE INNODB STATUS观察缓冲池命中率,并在业务低峰期执行大查询,减少对在线业务的冲击。
还有连接数的问题,当并发上升导致连接排队,每个请求拿到连接之前的等待时间也会被计入执行耗时,让SQL看起来变慢了。合理配置连接池大小、开启慢查询日志中的额外参数记录锁等待时间,能够帮助区分是真的执行慢还是在排队。
五、解决慢SQL的完整行动清单
综合上面的分析,处理一条突然变慢的SQL可以遵循这样的步骤:先用慢查询日志和监控确认慢的规律,再用EXPLAIN检查执行计划是否命中索引,然后逐项排查常见的索引失效写法,接着检查统计信息与数据量变化,最后排查锁等待与服务器资源。按这个顺序走下来,绝大多数慢SQL都能找到明确的病因。
解决手段上,优先级应该是:改写SQL让条件命中索引,其次是补建或调整联合索引的列顺序,再次是拆分复杂查询(比如把大事务拆成小批量、把子查询改写为JOIN),最后才是添加缓存层或读写分离。需要注意的是,加索引虽然立竿见影,但会拖慢写入速度,列的顺序也要遵循最左前缀和区分度原则,一般把等值条件的高区分度列放在前面,范围条件列放在后面。
最后建议在项目中建立慢SQL的长效治理机制:定期审查慢查询日志、为关键字段维护合理的索引、在代码评审时关注新增SQL的写法,这样才能避免每次都要等线上爆发问题后才被动救火。