干后端这些年,我优化过的慢查询没有一千也有八百条。很多朋友一听“SQL性能优化”就觉得高深,仿佛得懂什么玄学调优。实际上,90%的慢查询问题,靠一张 Execute计划(执行计划) 就能定位个七七八八。优化前后性能差百倍,真不是夸张的说法,我见过的案例里,从几十秒压到几百毫秒,甚至从十几秒压到几十毫秒的情况比比皆是。
这篇文章我就用三个真实案例,带你完整看透Execute计划在优化里的作用。这三个案例分别代表了三类最常见的性能杀手:索引失效、深分页排序、多表关联选错驱动表。我把当时的现场SQL、执行计划长什么样、怎么定位问题、最后怎么改的,全部复盘出来。无论你是刚接触数据库优化不久,还是已经在处理慢SQL但总感觉差点意思,这篇文章都能给你一套可以直接照抄的思路。
1. 为什么要死磕Execute计划?先搞清楚性能和成本差在哪
1.1 一个慢查询把你拖到怀疑人生的真实场景
我记得很清楚,有一年项目做活动大促,线上订单量蹭蹭往上涨。某天下午运营同学跑过来说,后台订单列表打不开了,点一下要转十几秒,最后直接超时。我第一反应不是去看服务器负载,而是先翻慢查询日志——果然,里面躺着几十条同款SQL,执行时间一条比一条离谱,最慢的一条跑了12秒多。
这种时刻你去看什么?看代码?代码本身逻辑很简单。看服务器?CPU内存都正常。真正能告诉你数据库内部在干什么的,只有Execute计划。MySQL里用EXPLAIN,SQL Server里直接看执行计划,本质都一样:它告诉你优化器打算怎么执行这条SQL,先读哪张表、怎么读、读多少行、要不要排序、要不要临时表。
不是跟你开玩笑,很多“性能问题”根本不是机器不行,是SQL把数据库的力气全花在了没意义的地方。你用全表扫描去查一个本该走索引的数据,数据库就得老老实实把几百万行翻一遍——这中间的浪费,往往就是几十倍到几百倍的时间差。
1.2 Execute计划到底在告诉你什么?(执行计划读法入门)
很多人看执行计划就是扫一眼有没有显示“Using index”,没显示就觉得要加索引。这是比较初级的状态。真正会读计划的人,重点看这么几个字段:
- type:访问类型。从好到差大概是 system > const > eq_ref > ref > range > index > ALL。看到ALL(全表扫描)就要警觉,这是性能差的最大元凶。
- key:实际用到的索引。如果是NULL,说明索引没生效。
- rows:优化器预估需要扫描的行数。这个数字是评估查询成本最直观的指标,从几十万行降到几十行,性能自然天差地别。
- Extra:额外信息。看到Using filesort说明要排序,看到Using temporary说明要建临时表,看到Using join buffer说明关联时可能出现了最糟糕的笛卡尔式匹配,这些都是危险的信号。
MySQL 5.7及以上还能用 EXPLAIN FORMAT=JSON 看到更细的成本估算值,MySQL 8.0.18以上干脆有 EXPLAIN ANALYZE,会真实执行一遍SQL并把每一步的实际耗时和行数打出来,定位问题时非常好用。后面案例里我会结合这些工具来拆。
1.3 优化前后的效果怎么量化?(什么叫“百倍差距”)
我经常跟团队里的小朋友说,优化不是看“感觉快了”,要有数据。怎么量化?很简单,两个指标:执行耗时和扫描行数。执行耗时你可以直接看SQL执行的毫秒数,扫描行数看执行计划里的rows字段。
比如案例一,优化前12秒,优化后0.08秒,12除以0.08等于150倍。案例二从11.5秒降到180毫秒,也接近64倍。案例三从7.8秒降到0.23秒,接近34倍。三个案例里有两个确实达到了“百倍”量级,更重要的是,操作其实并不复杂:调整SQL写法、补一个合适的索引、改变一下关联顺序,全都是在Execute计划里一眼就能看出来的问题。
所以这篇文章想传递的第一件事就是:性能优化不是靠猜,Execute计划是你唯一值得信赖的现场证据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 案例一:一条索引失效的SQL,从12秒到0.08秒
2.1 现场还原:代码里查得好好的,生产上慢成PPT
第一个案例来自一个C端订单查询接口。业务逻辑很简单:用户的订单列表页,要求根据订单号精确查询某一条订单。对应订单表 orders,有约153万行数据。订单号字段 order_no 定义的是 varchar(32),也建了唯一索引 uk_order_no。
当时应用代码里写的是这样一条SQL:
sql复制SELECT * FROM orders
WHERE order_no = 2837491029384756;
你没看错,订单号老老实实有索引,SQL看起来也正常得不能再正常。但接口就是慢,慢到生产环境一调用就超时。本地连测试库怎么跑都挺快,一上生产就原形毕露。
为什么?因为测试库数据量只有几万行,全表扫描也能秒出结果。而生产库有153万行,全表扫描的开销完全不一样。这就是为什么我坚持要在生产环境或等同量级的库上看执行计划,小数据量掩盖的问题太多了。
2.2 用Execute计划定位问题:type=ALL和possible_keys的猫腻
当时我在生产库上跑了一下EXPLAIN:
sql复制EXPLAIN SELECT * FROM orders
WHERE order_no = 2837491029384756;
执行计划输出大致如下:
| id | select_type | table | type | possible_keys | key | rows | Extra |
|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | ALL | uk_order_no | NULL | 1534621 | Using where |
第一眼看过去,possible_keys 里有 uk_order_no,但 key 是 NULL,type 是 ALL,rows 是153万行。这说明优化器在说一句话:我知道有索引,但这个条件下我没法用。
原因就是隐式类型转换。order_no 在表里的类型是 varchar(32),但传入的参数 2837491029384756 是一个整数类型。MySQL在做比较时,会把字段本身转成数字类型再比较,也就是说实际执行的是 CAST(order_no AS SIGNED) = 2837491029384756。
一旦对索引列加了函数操作,索引就废了。这跟 WHERE DATE(create_time) = '2024-01-01' 导致索引失效是同一个原理——你在索引列上做任何运算,优化器就没法用B+树的顺序查找能力了。
2.3 优化方案:修改SQL写法或调整索引设计(附参数计算)
这个问题的解法其实特别简单,核心就一条:别让数据库对索引列做类型转换。
第一种改法,把参数改成字符串类型,让两边类型一致:
sql复制SELECT * FROM orders
WHERE order_no = '2837491029384756';
我在生产上验证后的执行计划如下:
| id | select_type | table | type | key | rows | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | const | uk_order_no | 1 | NULL |
type 变成了 const,rows 直接降到1。这条SQL从全表扫描153万行,变成了走唯一索引直接命中1行。实际执行耗时从12秒直接掉到0.08秒,150倍差距就是这么来的。
第二种改法,如果你的应用层确实只能拿到数字类型(比如从接口里解析出来的JSON字段),那就在SQL里显式转换:
sql复制SELECT * FROM orders
WHERE order_no = CAST(2837491029384756 AS CHAR);
这个写法效果一样。但我更推荐第一种,在应用层就保证类型是对的,别把脏活留给数据库。
注意:隐式类型转换不只是发生在字符串和数字之间,字符串和日期、utf8和utf8mb4的排序规则不一致,也可能让索引失效。排查这类问题时,看到
possible_keys有值但key为NULL,第一反应就应该是字段类型或排序规则不匹配。
3. 案例二:深分页+排序组合拳,从11.5秒到180毫秒
3.1 现场还原:管理后台的列表页一翻页就超时
第二个案例是管理后台的一个订单明细查询。运营同学需要一个订单明细列表,支持按创建时间倒序,并且能翻到很后面的页。后台用的是传统的分页方式,每页20条,翻到第50000页时,应用生成的分页SQL长这样:
sql复制SELECT * FROM order_detail
ORDER BY create_time DESC
LIMIT 999980, 20;
order_detail 表当时有约540万行数据。翻到第50000页时,页面几乎打不开,接口响应时间11.5秒。
这个问题的直观感知是“越翻越慢”,前几页很快,后面几页越来越慢,最后直接超时。这就是深分页典型的特点。
3.2 问题解析:filesort和回表的代价
我们看一下这条SQL的执行计划:
sql复制EXPLAIN SELECT * FROM order_detail
ORDER BY create_time DESC
LIMIT 999980, 20;
| id | select_type | table | type | key | rows | Extra |
|---|---|---|---|---|---|---|
| 1 | SIMPLE | order_detail | index | PRIMARY | 1000000 | Using filesort |
看重点:Using filesort 出现了。这意味着MySQL读取了所有符合条件的数据(这里条件就是全表所有行),然后放到排序缓冲区里做一次完整排序,再从头数到第999980行才开始取数据。
这里面的开销有两块:
- 排序开销:540万行做排序,即使走内存也要花不少时间,数据量一旦超过
sort_buffer_size,就要用磁盘临时文件排序,那更慢。 - 回表开销:
SELECT *会拿主键回表读取每一行的完整数据,而排序发生在拿到这些数据之后。虽然执行计划上用的是PRIMARY索引扫描,但配合filesort,代价还是很吓人的。
LIMIT的偏移量越大,MySQL要提前扫描并丢弃的行就越多。偏移999980行,数据库就得多读999980行——这就是深分页的性能黑洞。
3.3 优化方案:延迟关联(deferred join)+覆盖索引
对于这种“先排序再分页”的场景,最经典的优化手段是延迟关联。思路很简单:第一步只在索引里查出符合条件的主键ID,第二步再用这些ID去关联原表取完整行。因为第一步只查主键ID的话,可以用到覆盖索引,不需要回表;数据量也小很多,排序速度快得多。
改写后的SQL:
sql复制SELECT od.*
FROM order_detail od
INNER JOIN (
SELECT id
FROM order_detail
ORDER BY create_time DESC
LIMIT 999980, 20
) tmp ON od.id = tmp.id
ORDER BY od.create_time DESC;
注意:MySQL里子查询作为派生表时,会先执行子查询,把结果放到临时表里,再跟外层表关联。这里的关键是子查询 SELECT id ... LIMIT 999980, 20 只需要从二级索引 (create_time, id) 里读取,不需要回表,走的覆盖索引。
配套的索引设计也必不可少。我给这张表建了一个复合索引:
sql复制ALTER TABLE order_detail ADD INDEX idx_create_time_id (create_time, id);
优化后的执行计划里,子查询的 Extra 变成了 Using index(代表覆盖索引),Using filesort 依然存在,但排序的数据变成了纯ID列且来自覆盖索引,代价大幅下降。
这条SQL优化后,执行耗时从11.5秒降到180毫秒,大约64倍提升。
除了延迟关联,我还习惯推荐一种更彻底的方案:游标分页(keyset pagination)。也就是不传页码,而是传上一页最后一条记录的排序字段值,比如:
sql复制SELECT * FROM order_detail
WHERE (create_time, id) < ('2024-05-20 10:00:00', 182953)
ORDER BY create_time DESC, id DESC
LIMIT 20;
这样每一次查询走 range 扫描,从头到尾最多扫20条+索引范围内的一部分数据,无论翻多少页,性能都很稳定。代价是后端接口的分页逻辑要改,前端不能直接点页码跳转了。如果产品能接受“加载更多”的交互,这个方案是最稳的。
心得:看到
Using filesort不一定是坏事,但如果它配合一个大偏移量的LIMIT,基本就是深分页症状。凡是分页列表越翻越慢,第一反应就是看LIMIT偏移量和执行计划里的rows、filesort标记。
4. 案例三:多表Join条件写错,执行计划直接给你“惊喜”
4.1 现场还原:数据量不大但怎么查都慢
第三个案例是让我印象最深的。当时是一个报表查询,需要把用户表 users(约20万行)、订单表 user_orders(约300万行)、订单明细表 order_items(约500万行)关联起来,导出一份最近一周的下单用户明细。
最初SQL是这样写的:
sql复制SELECT
u.user_name,
o.order_no,
oi.product_name
FROM users u
LEFT JOIN user_orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.order_no = oi.order_no
WHERE u.created_at >= '2024-05-13 00:00:00';
数据量其实不算特别离谱,但这条SQL一跑就是7.8秒。报表工具还经常超时,业务部门天天催。
我当时的第一反应同样是先看执行计划,结果看完就明白了,这问题不在数据量,而在驱动表选错了。
4.2 用Execute计划找出驱动表和关联顺序的问题
执行计划(关键列):
| id | table | type | key | rows | Extra |
|---|---|---|---|---|---|
| 1 | u | ALL | PRIMARY | 200000 | Using where |
| 1 | o | ALL | NULL | 3000000 | Using where; Using join buffer (Block Nested Loop) |
| 1 | oi | ALL | NULL | 5000000 | Using where; Using join buffer (Block Nested Loop) |
这张计划表特别说明问题。两个核心信息:
user_orders和order_items的type都是ALL,key都是NULL。也就是说关联时连索引都没走,两张表都是全表扫描。Extra里有Using join buffer (Block Nested Loop),意味着MySQL在内存里对驱动表的数据块做了一次全量匹配,循环嵌套地去另一张表里逐行比较——这种操作是最耗CPU和内存的。
为什么会出现这种结果?查一下表结构就清楚了:user_orders 表虽然在 user_id 上建了索引,但**order_no 上没有索引**。order_items 表的 order_no 列虽然建了索引,但却是 varchar 类型,而 user_orders.order_no 是 bigint 类型。两个字段类型不一致,在关联时又触发了隐式转换,order_items.order_no 的索引直接失效。
于是优化器算了一笔账:你让我关联两张300万、500万的表,结果关联列没有可用的索引,怎么关联?只能用BNL算法,拿出一张表的每一行去另一张表匹配。数据量一大,这个成本高到离谱。
4.3 优化方案:改写关联条件,让小表驱动大表
定位到根因后,优化思路分两步走:
第一步,把 order_items.order_no 改成和 user_orders.order_no 一致的类型。我这里是统一改成 varchar(32),因为订单号本身不需要参与数值计算,字符串类型更安全。同时把 user_orders.order_no 和 order_items.order_no 都建上索引:
sql复制ALTER TABLE user_orders ADD INDEX idx_order_no (order_no);
ALTER TABLE order_items ADD INDEX idx_order_no (order_no);
第二步,重写SQL,缩小驱动表的数据范围。原SQL是先关联全部数据再过滤,优化后先过滤 users 表,再关联订单表和明细表:
sql复制SELECT
u.user_name,
o.order_no,
oi.product_name
FROM (
SELECT id, user_name
FROM users
WHERE created_at >= '2024-05-13 00:00:00'
) u
LEFT JOIN user_orders o ON u.id = o.user_id
LEFT JOIN order_items oi ON o.order_no = oi.order_no;
优化后执行计划如下:
| id | table | type | key | rows | Extra |
|---|---|---|---|---|---|
| 1 | u | range | idx_created_at | 1180 | Using where; Using index |
| 1 | o | ref | idx_user_id | 3 | NULL |
| 1 | oi | ref | idx_order_no | 5 | NULL |
一眼就能看到变化:访问类型从ALL变成了range和ref,预估扫描行数从“百万级”降到了“千行内”。最终SQL执行耗时从7.8秒降到0.23秒,接近34倍提升。
重要:多表关联慢,先看两张关联表的关联字段有没有索引、类型是否一致。很多人只在WHERE条件列上加索引,忽略了JOIN ON后面的关联列也需要索引。如果关联列类型不一致,索引照样白给。
5. 排查慢查询的实用清单与避坑经验
5.1 我在优化Execute计划时踩过的几个坑
上面三个案例过程看着很顺,但实际排查时坑一点都不少。简单总结几个我踩过而且大家很容易再踩的地方。
第一个坑是只看type不看rows。有时候你看到type是ref,感觉还不错,但rows可能有几十万。为什么?因为选择性差的索引(比如性别字段)会让ref退化成接近全表扫描的存在。所以要结合rows一起看,扫描行数才是性能最直接的指示器。
第二个坑是在测试环境看执行计划,数据量却和生产差了几个数量级。有时候测试库只有几万行,全表扫描也就几十毫秒,你根本看不出问题。我一直建议大家把生产环境的慢SQL脱敏后,导入到预发环境或者固定一个压测库,保证数据量级一致再看执行计划,结果才有参考价值。
第三个坑是加了索引却没生效。比如你把 idx_create_time_id 建成了 (id, create_time),那排序时还是走不了覆盖索引;或者你在WHERE条件里写了 status = 1 AND create_time > ?,但索引前导列是 status,选择性不好,导致优化器嫌弃它。索引设计要结合SQL的实际WHERE条件和ORDER BY来定,不是随便建两个列就行。
第四个坑是统计信息过期。MySQL的优化器是依据统计信息来选索引的,如果你对表做了大量增删改,但没有及时更新统计信息,优化器可能基于错误的数据分布选了一个很差的执行计划。遇到这种情况,先跑一遍 ANALYZE TABLE 表名;,再重新看执行计划,很可能计划就变了。
5.2 需要长期坚持的SQL性能检查习惯
与其等到线上出了慢查询再救火,不如在开发和上线阶段就把Execute计划作为标准动作。我这边长期执行的一个流程分享给大家:
- 任何新上线的SQL,只要是线上核心链路,必须过一遍执行计划,确认
rows在预期范围内,不存在ALL扫描。 - 慢查询日志开起来,阈值设置1秒,每天定时检查慢SQL TOP10,把新出现的慢SQL逐个过执行计划。
- 每两周做一次索引健康检查,用
SHOW INDEX FROM 表名确认冗余索引和失效索引,及时清理。 - 大表的统计信息定期更新,尤其是频繁写入的表,至少每周一次
ANALYZE TABLE。
这套流程看起来简单,但坚持半年以上,线上“莫名变慢”的故障会大幅减少。很多性能问题在数据量涨上来之前,你是感觉不到的。等感觉到了,通常已经晚了。
5.3 工具选择:怎么快速拿到一份可读性高的执行计划
针对不同的数据库,我看执行计划的偏好也不太一样。
MySQL的话,如果用的是5.7,优先用 EXPLAIN FORMAT=JSON 看成本估算;如果上了8.0,直接上 EXPLAIN ANALYZE,它会把每一步的实际耗时、实际行数和循环次数都打出来,比传统的EXPLAIN直观很多。注意 EXPLAIN ANALYZE 会真实执行SQL,所以不要在超大数据量全表查询时直接跑,先加个LIMIT 或者放到从库上执行。
sql复制EXPLAIN ANALYZE
SELECT * FROM orders
WHERE order_no = '2837491029384756';
SQL Server用户可以在SSMS里按 Ctrl+M 开启实际执行计划,再执行SQL就能看到图形化计划。个人经验是,图形化计划里的“百分比”和每个操作符的“估计行数vs实际行数”对比,比任何字段都直观——如果两个数字差很多,优化器判断就失真了。
另外一个很实用的小技巧:把慢SQL复制到 explain.depesz.com(PostgreSQL)或者各类SQL格式化工具里,先调整好缩进再读执行计划,会清晰很多。很多时候SQL一长,嵌套子查询一多,肉眼根本看不清关联顺序,格式化之后再对照执行计划看,条理就出来了。
6. 优化前必问自己的3个问题
我在带团队做代码评审的时候,遇到优化相关的内容,一定会让人先回答这样几个问题:
- 问题现象真的跟SQL本身有关吗? 先排除网络抖动、连接池打满、锁等待、机器IO瓶颈等外部因素。盲目优化一条SQL,结果慢在锁等待上,完全是浪费时间。
- 优化后是否会引入新问题? 比如为了查得快,给大表加了很多索引,但索引本身会拖慢写入,还会占空间。所以要评估这条查询的频率,如果是低频报表,不值得为它加太多索引。
- 能不能在业务层减少查询量? 有时候最好的优化不是把SQL调到极致,而是直接不查了。加缓存、减少大字段输出、把实时报表改成预聚合,这些都是比调SQL更彻底的方案。
这三点是我踩过足够多的坑之后,养成的习惯。很多初学者拿到一个慢查询,上来就加索引,加完发现还是慢,回头再看执行计划,问题根本不在索引上。先确认问题边界,再动手改,才不会白忙活。
Execute计划这个东西,说到底就是数据库优化器给你的一张“流程图”。你看懂了,就能知道它每一步在干什么、在哪一步浪费了最多时间;你看不懂或懒得看,就只能靠猜。靠猜去优化性能,三回里面有两回会翻车。把这三个案例里的思路吃透——索引失效、深分页排序、关联选错驱动表——以后再遇到慢SQL,你就有了清晰的方向。优化前记得看一下执行计划,优化后再看一次执行计划做对比,看到rows和耗时都在降,那这个优化就基本稳了。
