工作这些年,我接过最多的优化需求就是慢SQL。业务方急急忙忙跑过来:“系统好慢,接口超时,数据库CPU飙到100%了”,我一查,往往就是一条要命的SQL在全表扫。慢SQL优化这件事,说难也难,说简单也简单——难在你要透过执行计划看清数据库的每一步动作,简单在于大部分慢SQL都逃不过几个固定套路:缺索引、写得太烂、统计信息不准、锁等太久。这篇文章把我自己的排查思路和落地经验完整写出来,从定位到执行计划分析,再到索引设计、SQL改写、锁问题处理和参数调整,一条线走下来。看完你至少能独立处理80%的慢SQL问题。
1. 慢SQL到底是怎么“诞生”的
1.1 先定位:什么样的SQL算“慢”
慢SQL没有一个绝对的时间标准。MySQL默认把超过10秒的查询记进慢查询日志,但线上业务超过1秒就明显卡顿,很多接口甚至要求100毫秒内返回。所以判断一条SQL慢不慢,第一参考是数据库的慢日志阈值,第二是业务侧的响应时间要求,第三还要看它占用的资源——一条SQL哪怕只跑500毫秒,但如果它每天被调用几百万次,累积消耗的CPU和IO一样能拖垮数据库。
我一般把“慢”分成三个层次:查询慢、更新慢、锁等待导致的慢。查询慢是最常见的,通常是索引或SQL写法的问题;更新慢往往跟索引维护成本、行锁冲突有关;锁等待的慢最隐蔽,SQL本身跑得飞快,但就是拿不到锁,事务卡在等待队列里出不来。排查的时候要先分清是哪一种,方向不对,调一天也白搭。
1.2 慢SQL的常见成因
从根上讲,慢SQL基本逃不出下面几个原因:
- 缺索引或者索引失效,走了全表扫描。数据量小的表全表扫没问题,一旦到了千万级,性能断崖式下跌。
- 数据量膨胀但执行计划没跟上。一张表500万行的时候SQL跑得挺快,到3000万行后同样的执行计划就开始扛不住了。
- 统计信息过期,优化器选错了执行计划。优化器是根据统计信息估算成本的,统计信息不准,它就会“瞎选”。
- SQL写法问题。比如对索引列用了函数、隐式类型转换、LIKE前置通配符,让优化器没法用索引。
- 锁与并发竞争。高并发下事务互相等待,一条查询本身很快,但被阻塞到超时。
- 硬件和配置问题,比如内存不够导致缓冲命中率低,磁盘IO慢,网络延迟高。
很多初学者习惯一上来就调参数或者加硬件,实际上绝大多数慢SQL问题,靠优化SQL和索引就能解决。数据库参数调整属于最后的手段,硬件升级更是兜底方案。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把慢SQL“揪出来”的手段
2.1 MySQL:慢查询日志和performance_schema
MySQL排查慢SQL第一步是打开慢查询日志。我习惯把long_query_time设成1秒,线上压力大的时候甚至会临时调到0.5秒抓一轮。开启方式很简单,在my.cnf里配好重启,或者直接在线打开:
sql复制SET GLOBAL slow_query_log = ON;
SET GLOBAL long_query_time = 1;
日志打开后,用mysqldumpslow做汇总分析非常方便,它能按平均耗时、扫描行数、访问频率排序,帮你快速找到最需要处理的那些SQL。
bash复制mysqldumpslow -s at -t 10 /var/log/mysql/slow.log
-performance -s at就是按平均耗时排序,-t 10取前10条,这样能快速锁定最耗时的查询。不过慢查询日志只能看“跑得慢”的SQL,有些SQL调用频率极高、单次又不慢,但累计消耗非常大。这种我用performance_schema来抓,比如查累计逻辑读最高的SQL,或者结合events_statements_summary_by_digest按总耗时排序,能发现真正的“隐形杀手”。
2.2 Oracle:AWR、ASH和v$sql视图
Oracle环境下的排查路径不太一样,它不靠慢日志,而是看AWR报告和动态性能视图。AWR能告诉你两个快照之间数据库的时间花在哪了,SQL ordered by Elapsed Time这个部分基本就是慢SQL的排行榜。如果想看正在发生的问题,用ASH视图查当前活跃会话:
sql复制SELECT sql_id, COUNT(*), event
FROM v$active_session_history
WHERE sample_time > SYSDATE - INTERVAL '30' MINUTE
GROUP BY sql_id, event
ORDER BY 2 DESC;
这条SQL能找到最近30分钟消耗资源最多的SQL_ID和它当时的等待事件。拿到SQL_ID后再结合v$sql、v$sql_plan查具体文本和执行计划。Oracle还有一个好用的经验值:如果一个SQL的BUFFER_GETS/EXECUTIONS非常高,说明它一次执行要读很多数据块,这是典型的索引或写法问题。
2.3 监控告警和巡检机制
光靠出问题时临时查是不够的,真正靠谱的做法是把慢SQL纳入日常监控。我建议搭建一套简单的巡检体系:每天定时把MySQL慢日志或者Oracle的v$sql数据采集到ES或ClickHouse里,按耗时、执行次数、扫描行数做聚合排名,超过阈值自动报警。团队可以值班处理Top SQL,并且把每次处理记录成案例库。这套机制不用搞得特别重,刚开始用脚本+定时任务跑起来,后面再逐步完善,能坚持比什么都重要。
3. 慢SQL优化的底层逻辑:执行计划
3.1 读懂执行计划的几个关键点
拿到一条慢SQL,第一件事永远是看执行计划,而不是猜。MySQL用EXPLAIN,Oracle用EXPLAIN PLAN FOR或者DBMS_XPLAN。执行计划就是数据库的执行“路线图”,它会告诉你优化器打算怎么扫表、怎么关联、怎么排序。
MySQL里我最关注这几列:
- type:从好到差依次是system > const > eq_ref > ref > range > index > ALL。看到ALL就是全表扫描,这是最需要警惕的信号。
- key:实际用到的索引,如果是NULL说明没走索引。
- rows:优化器预估扫描行数,这个值跟实际差异特别大时,说明统计信息可能有问题。
- Extra:出现Using filesort或Using temporary要格外注意,这意味着SQL要额外做排序或者建临时表,性能通常很糟糕。
Oracle的执行计划主要看TABLE ACCESS FULL(全表扫)、INDEX RANGE SCAN(索引范围扫)、INDEX UNIQUE SCAN(索引唯一扫),以及表关联方式NESTED LOOPS、HASH JOIN、SORT MERGE JOIN。COST值只能相对参考,关键看操作符和Cardinality的估算是否合理。
3.2 三步定位瓶颈
我看执行计划有一套固定的三步法:
第一步看扫描方式。有没有全表扫描?如果有,看表的数据量和过滤条件,考虑是否加索引、为什么索引没被选中。
第二步看关联方式。多表JOIN时,小表有没有被当作驱动表?关联字段有没有索引?关联顺序是否合理?驱动表的选择错误,会让整个查询的扫描行数爆炸。
第三步看排序和临时表。出现Using filesort、Using temporary或者Oracle的SORT ORDER BY时,要思考排序能否用索引替代,GROUP BY和DISTINCT是否写得冗余。
这套方法我用在很多案例里,基本都能快速定位。比如一个查询慢,执行计划显示一张千万级的表走了ALL全表扫描,Extra里还带着Using filesort,那问题就非常清晰:缺索引,尤其是排序字段和WHERE条件字段组成的联合索引。
4. 索引优化的实操经验
4.1 索引失效的典型场景
索引优化是慢SQL优化里性价比最高的手段,但前提是索引真的能被用上。我发现很多人建了索引却发现查询还是慢,问题出在索引失效。常见失效场景包括:
- 对索引列使用函数,比如WHERE DATE(create_time) = '2025-01-01',这种情况应该写成create_time >= '2025-01-01' AND create_time < '2025-01-02'。
- 隐式类型转换,比如手机号字段是varchar,查询条件写WHERE phone = 13800138000,这个数值会转成字符串再比较,索引直接失效。
- LIKE以%开头的模糊查询,比如LIKE '%abc%',这种无法走索引,只能靠全文索引或者搜索引擎。
- 联合索引不满足最左前缀原则。
- 在索引列上做运算,比如WHERE id + 1 = 100。
比较隐蔽的是优化器“主动放弃”索引的情况。当一条索引返回的行数超过全表行数的20%到30%时,优化器可能觉得回表成本太高,干脆全表扫。这不是索引失效,而是成本计算问题,需要通过改写SQL或者调整参数引导。
4.2 覆盖索引的妙用
回表是InnoDB引擎的一个特性——通过二级索引查到主键,再用主键去聚簇索引里查整行数据。如果查询的字段恰好都在索引里,就不需要回表,这种就叫覆盖索引。覆盖索引能显著减少IO,尤其对那种频繁查询但每次只取少数列的场景特别有效。
举个例子,订单表有个查询只要取订单号和状态:
sql复制SELECT order_no, status FROM orders WHERE user_id = 123;
如果单独建user_id索引,InnoDB会先查到主键再回表两次;如果建一个(user_id, order_no, status)的联合索引,数据直接从索引里就能读到,省掉回表。我在实际优化中,几乎每个高频查询都会检查能否用覆盖索引覆盖,效果立竿见影。
4.3 联合索引设计原则
联合索引的设计有几个原则:第一是等值条件字段放前面、范围条件字段放后面;第二是区分度高的字段优先;第三是兼顾排序字段。比如一个订单查询经常有user_id + status + create_time的组合条件,我会推荐建(user_id, status, create_time)联合索引。这样WHERE条件能精确定位,ORDER BY create_time也能顺便走索引避免排序。
不过联合索引不是越多越好。每增加一个索引,在INSERT、UPDATE、DELETE时都要付出维护成本。我一般的原则是:单表索引控制在5到6个以内,新增索引前先确认有没有功能重复的旧索引可以合并或删除。曾经有一张表有8个索引,写性能非常差,最后分析发现两个联合索引的前缀字段完全重叠,删掉一个后问题解决。
4.4 一个索引优化的完整案例
有个真实案例让我印象很深:一个运营后台的列表查询,数据量接近2000万,查询耗时2.8秒,接口频繁超时。业务SQL大致是:
sql复制SELECT * FROM payment_order
WHERE merchant_id = 10086
AND status = 2
ORDER BY create_time DESC
LIMIT 20;
执行计划显示全表扫描,Extra里有Using filesort。分析后发现这个查询用到的条件字段建了单列索引,但status区分度太低,优化器根本不愿意走。我把两个单列索引调整成联合索引(merchant_id, status, create_time),结果执行计划走INDEX RANGE SCAN,排序也走索引,查询耗时降到20毫秒。这一条SQL的优化,直接把整个后台页面从转圈等待拉到了秒开,数据库CPU也降了30个点。
5. SQL改写:不换索引也能提速
5.1 深分页的优化思路
MySQL深分页是经典慢SQL场景。LIMIT 100000, 10前面扫描的10万行数据其实全被丢弃,只为了拿最后10条,浪费了巨量的IO。有两个常用改写方法:
一是延迟关联。先只查主键,再通过主键关联回原表取完整行:
sql复制SELECT t.* FROM payment_order t
INNER JOIN (
SELECT id FROM payment_order
WHERE merchant_id = 10086
ORDER BY create_time DESC
LIMIT 100000, 20
) tmp ON t.id = tmp.id;
子查询只扫了主键索引,回表只针对确定的20条,性能成倍提升。二是游标分页,用WHERE create_time < 上次最大值代替LIMIT翻页。这种方式特别适合App端“下拉加载更多”的场景,时间复杂度稳定。
5.2 IN、EXISTS和JOIN的选择
网上流传很多关于IN和EXISTS谁快谁慢的说法,但放到MySQL里,优化器会做等价改写。我更关注的是业务语义和驱动表大小:如果外层表数据量小,用EXISTS往往更直观;如果IN子查询的结果集很小,优化器多半也会转成半连接JOIN执行。你不一定需要手动改写,但可以用EXPLAIN观察优化器实际生成的执行计划,发现不合理再手动干预。
JOIN优化关键在驱动表的选择和关联字段的索引。嵌套循环连接中,驱动表每匹配一行都要去探测被驱动表的索引。所以小表驱动大表通常是对的,被驱动表的关联字段一定要有索引。如果两个表都很大、关联条件又没有好索引,可能会触发HASH JOIN——这种情况下先过滤各自表的数据量再关联,往往比无脑加索引更有效。
5.3 杜绝隐式类型转换
隐式类型转换是特别常见的索引杀手。一张用户表user_id是varchar类型,查询写WHERE user_id = 123,MySQL会把字符串列转成数值比较,导致索引不可用。有些ORM框架在拼SQL时会因为字段类型定义不一致,自动把参数当成别的类型,这类问题很难一眼看出来。我的解法是:先看表结构确认字段类型,再检查SQL绑定参数的类型是否一致,必要时在SQL里显式用引号包裹字符串。
5.4 OR和UNION的取舍
WHERE条件里用OR连接字段,如果OR的两个字段都有索引,MySQL可能会走索引合并(index merge)优化。但更多时候OR会导致其中一个字段的索引失效,整体变成ALL全表扫。我通常建议把OR改写为UNION ALL,前提是两边结果集不会重复。用UNION(不带ALL)会产生去重成本,能不用就不用。
另外,排序状态下OR的改写效果更明显。WHERE a = 1 OR b = 2 ORDER BY create_time这种写法,即便a、b分别有索引,排序也大概率逃不掉Using filesort;拆成两个查询再UNION ALL,两边都能走索引排序,最后合并结果集,效率截然不同。
6. 锁等待与“长时间锁定表”的排查
6.1 锁的类型和影响
慢SQL不只是查询本身慢,锁等待导致的慢更让人头大。一个事务持有行锁不提交,其他要更新同一行的事务就会卡住,反馈到业务层就是接口超时。MySQL里除了行锁,还有间隙锁、临键锁、表锁、元数据锁;Oracle里主要有行级锁和TM表级锁。长时间锁定表的最常见原因是事务没有及时提交:应用代码里开启了事务,捕获异常后没执行rollback或commit,连接就一直占着锁不放。
6.2 现场排查方法与实战SQL
MySQL这边,我查锁等待的首选是sys库的innodb_lock_waits视图,它已经把阻塞者和被阻塞者的会话ID、事务ID、锁信息都关联好了:
sql复制SELECT * FROM sys.innodb_lock_waits\G
拿到阻塞者的thread_id和trx_id后,用performance_schema.threads和events_statements_current找到正在执行的SQL,或者通过下图工具直接查看事务详情。确认无误后,把阻塞会话kill掉让业务先恢复,再回头处理那个没提交的事务。
Oracle环境用下面这条SQL查锁和被锁的会话:
sql复制SELECT c.sid, c.blocking_session, a.object_name, b.session_id
FROM v$locked_object b
JOIN dba_objects a ON a.object_id = b.object_id
JOIN v$session c ON c.sid = b.session_id;
blocking_session有值就说明被阻塞了。还可以再查v$session里SQL_ID对应SQL文本,确认阻塞源。kill会话之前要跟业务确认,直接kill可能引发应用层数据不一致或者长事务回滚风暴,谨慎操作。
6.3 应用层规避锁问题
锁等待问题治本的方案在应用层,不在数据库。几个实战经验:事务里不要做远程调用、RPC、发消息这类耗时操作,这些时间都会变成锁的持有时间;大批量更新要分批提交,比如一次更新1000条就提交一次;确保UPDATE、DELETE语句的WHERE条件能走索引,否则行锁会升级成表锁,直接影响所有并发访问。有一个项目之前经常出现锁等待,最终查出来是有个定时任务在凌晨一次性更新几百万行,占着表锁不放,改成按主键范围分批更新后,问题彻底消失。
7. 优化器与参数调优:别急着动手改
7.1 统计信息不准是隐形坑
优化器是“盲人摸象”式的工作——它看不到真实数据,只能靠统计信息估算。如果统计信息过期,估算行数和真实数据差异巨大,就会选错执行计划。MySQL的表在大量增删改后,需要ANALYZE TABLE刷新;Oracle则用DBMS_STATS收集:
sql复制EXEC DBMS_STATS.GATHER_TABLE_STATS(ownname => 'APP', tabname => 'PAYMENT_ORDER', cascade => TRUE);
遇到一种典型情况:同一张表白天查得飞快,晚上高峰期突然变慢,执行计划也从index scan变成了full table scan。这种多半是统计信息收集任务在某个时间点跑完,把基数字段刷新到了新值,优化器的判断随之改变。遇到这类情况,不急着改SQL,先刷新统计信息再观察。
7.2 MySQL参数怎么调才有效
MySQL参数调整要讲究性价比。sort_buffer_size和join_buffer_size适合调大一些,但它们是会话级的,连接数多了内存容易爆,需要结合业务并发量评估。max_execution_time可以给查询设置超时上限,防止一条烂SQL把数据库拖死。还有个param我经常用:optimizer_switch里的mrr和batched_key_access,在特定场景下能提升索引范围扫描效率。
不过参数调整是收益递减的:一条全表扫描的SQL,你调任何参数都不如一个合适的索引来得直接。我的建议是永远先优化SQL和执行计划,参数调整解决的是优化器判断问题和资源分配问题,不要本末倒置。
7.3 Oracle优化器参数和计划固化
Oracle方面,optimizer_mode默认是ALL_ROWS,如果业务偏OLTP少量数据快速返回,可以慎重评估FIRST_ROWS的适用性,但一般不建议动全局参数。与其调参数,不如用SQL Profile固定执行计划。一条SQL的执行计划被改坏了之后,我习惯用SQL Plan Management把稳定的好计划保存下来,数据库会自动忽略新的坏计划。
Oracle里还有一个隐含参数_optimizer_skip_scan,有时候可以引导优化器在联合索引前导列未命中的情况下走Index Skip Scan。但隐含参数风险很高,升级版本可能失效,生产环境我基本不碰。能用改写SQL和统计信息解决的问题,绝不动隐含参数。
7.4 并行SQL优化的正确姿势
并行SQL适合大表扫描、大数据量聚合、批量导入导出这类重型OLAP场景。Oracle的一句/*+ PARALLEL(t 4) */可以让全表扫描拆成多个并行进程,执行时间缩短好几倍。但OLTP高并发场景绝对不要用并行,它会抢占大量系统资源,反而让其他小查询变慢。MySQL没有真正意义上的单SQL并行执行,如果一张超大表统计频繁超时,可以考虑分区表或者在大数据平台处理。
并行度的设置也需要经验。并发数不是越大越好,要结合CPU核数、IO能力、系统负载综合判断。我见过有人给一张1亿行的表开32度并行,结果IO被打满,整个实例几乎不可用。一般从4开始测试,看资源利用率再逐步调整。
8. 慢SQL优化体系的建设
8.1 上线前的SQL审核
慢SQL优化最理想的时机是SQL上线之前。很多团队在开发阶段完全没做SQL review,业务上线后才发现慢查询,这时优化成本翻倍。我建议在测试环境接入一个SQL审核工具,或者在code review阶段强制检查数据库访问代码:表数据量多大、连接数多少、查询条件是否能命中索引、是否在循环里执行SQL。几行代码提前看,能避免上线后就紧急救火。
8.2 常态化慢SQL巡检
慢SQL监控跑起来之后,关键是形成闭环。每天的巡检报告不能只是“看一下”,要有负责人、有工单、有优化确认。一般流程是:慢SQL报警 -> DBA抽取执行计划和数据分布 -> 和业务方确认查询逻辑 -> 给出优化方案 -> 评估上线 -> 观察后续监控数据是否下降。这个循环走顺了,数据库的慢查询数量会呈现递减趋势。
8.3 团队数据库开发规范
规范是防止慢SQL最便宜的手段。我参与过的团队基本都会制定一套数据库开发规范:禁止SELECT *、禁止无WHERE的UPDATE/DELETE、不允许在索引列上做函数运算、批量操作必须分批、热表不允许跨库JOIN等等。每条规范后面附上原因和反面案例。规范的价值不是为了限制开发,而是把踩过的坑沉淀下来,让后来的人少走弯路。
9. 常见问题速查表
| 问题现象 | 可能原因 | 排查思路 | 解决手段 |
|---|---|---|---|
| 查询越来越慢 | 数据量膨胀但索引设计未跟上 | 看执行计划rows和实际行数对比 | 重建统计信息,优化索引结构 |
| 同一个SQL时快时慢 | 统计信息过期或系统负载波动 | 对比不同时段的执行计划 | 刷新统计信息,必要时固定执行计划 |
| 索引建了没生效 | 索引失效或优化器估算回表成本高 | 检查函数、隐式转换、最左前缀 | 改写SQL,调整联合索引字段顺序 |
| OR条件导致全表扫描 | 多条件无法命中多索引 | 查看最终执行计划 | 拆分UNION ALL,或使用union index |
| 深分页查询极慢 | 扫描大量无效行 | 观察LIMIT值大小 | 延迟关联或游标分页 |
| UPDATE/DELETE堵死 | 大事务、锁范围过大、无索引WHERE | 查锁等待视图,找阻塞会话 | 分批操作,确保WHERE走索引 |
| 应用连接池耗尽 | 慢SQL长期占用连接 | 查活跃会话数和等待事件 | 先杀慢SQL,再优化根源 |
| 大批量统计任务慢 | 全表扫描或并行度设置不当 | 分析聚合逻辑和表数据分布 | 加索引、使用并行、或迁移大数据平台 |
这个表我不能覆盖所有场景,但80%的日常慢SQL问题都能在上面对上号。你照着这个思路排查,至少不会像无头苍蝇一样乱试。
10. 一些实在的体会和建议
做了这么多年慢SQL优化,我最大的体会是:慢SQL优化不是一个“技术活”,更是一个“方法论”的问题。你得有自己的排查顺序,绝对不能一上来就改配置或者加索引。为什么慢?基于执行计划判断,再一步步验证假设,这是最稳妥的思路。还有一点很关键——优化之后一定要观察一段时间,确认执行计划稳定,而不是今天快了明天又变回去。最后提醒一句,慢SQL优化不是把单条SQL调到最快就完了,你要看它对整体系统的资源消耗和并发影响。很多时候,一个查询从200毫秒优化到50毫秒,远不如把一个占用大量IO的60秒报表改成异步任务来得更有价值。优化是权衡的艺术,这点请务必记住。
