凌晨三点,手机响了。线上告警,一条核心业务查询从200毫秒飙到了12秒。爬起来一看,索引明明在,字段也没错。当时我心里就一个念头——得看执行计划。

果不其然,优化器选了个废柴索引,全表扫描87万行。我加了行Hint指定索引,200毫秒回来了。从告警到恢复,前后8分钟。

干了15年数据库,这种事经历太多了。80%的SQL性能问题,看一眼执行计划就能定位。 可偏偏很多开发兄弟不看,上来就加索引、改SQL,运气好碰对了,运气差越改越慢。

今天我把看执行计划的方法掰开了讲,从Oracle到MySQL,结合3个真实踩坑案例。各位看完,至少能做到:SQL慢了,知道往哪看。

执行计划是啥?简单说就是SQL的"施工图纸"

你写了一条SQL,数据库不会直接跑。它要先"规划"一下:先查哪张表、怎么关联、用哪个索引、数据怎么传。这个规划出来的方案,就是执行计划。

Oracle用 DBMS_XPLAN.DISPLAY 看,输出长这样:

---------------------------------------------------------------------------
| Id | Operation          | Name  | Rows  | Cost | Time     |
---------------------------------------------------------------------------
|  0 | SELECT STATEMENT   |       |    10 |   45 | 00:00:01 |
|* 1 |  TABLE ACCESS FULL | ORDERS|    10 |   45 | 00:00:01 |
---------------------------------------------------------------------------

主要看三个东西:Operation(做了什么操作)、Rows(估算返回多少行)、Cost(代价多大)。

MySQL用 EXPLAIN 看,格式不一样,但思路相通。MySQL重点关注:

  • type列:访问方式。从好到差依次是 system > const > eq_ref > ref > range > index > ALL。看到ALL基本就是全表扫描,得警惕。
  • key列:实际用了哪个索引。NULL表示没用索引。
  • rows列:估算扫描行数。这个数越大,SQL通常越慢。
  • Extra列:额外信息。出现 Using filesortUsing temporary 要注意,说明有额外排序或临时表开销。

Oracle的Rows对应MySQL的rows列,Cost没有直接对应但思路一样。两个平台的"语言"不同,但"语法"是一回事。

3个真实案例,手把手带你读

案例一:索引选错了,百万级表查询12秒

一条订单查询SQL,按理说走了索引应该很快,但跑了12秒。

EXPLAIN SELECT * FROM orders
WHERE user_id = 10086 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20;

看执行计划:type是 range,但key用的是 idx_created_at,扫描了87万行。

问题很清楚——优化器选错了索引。应该走 (user_id, status) 的联合索引,结果走了 created_at 的单列索引。ORDER BY created_at DESC 可能干扰了优化器的判断。

强制指定索引后:rows 从87万降到23,耗时从12秒降到50毫秒。

后来排查发现,这张表的统计信息已经两个月没更新了。优化器基于过时的数据分布做判断,选错索引太正常了。

教训: 统计信息过期是索引选错的头号原因。大表要定期更新统计信息。

案例二:两表关联,走错了连接方式

两张表JOIN,用户表50万行,订单表2000万行。SQL跑了将近3分钟。

看执行计划:驱动表是订单表(大表),对每条记录去用户表做主键查找。这是典型的Nested Loop,而且驱动表选反了。

优化思路:让小表(用户表)做驱动,大表走索引。或者直接用Hash Join,两张大表之间Hash Join往往比Nested Loop快得多。

调整后走Hash Join,耗时从3分钟降到200毫秒。

教训: 大表关联时,先看驱动表选对没有。小结果集驱动大结果集,这是基本原则。

案例三:子查询拖慢整条SQL

一个看似简单的查询,“查下过单的用户信息”:

SELECT * FROM users
WHERE user_id IN (
    SELECT user_id FROM orders WHERE amount > 1000
);

执行计划里出现了一个刺眼的词:DEPENDENT SUBQUERY。这意味着外层每查一行users,内层都要重新执行一次orders的子查询。users表有10万行,子查询就跑了10万次。

改成JOIN写法:

SELECT DISTINCT u.* FROM users u
JOIN orders o ON u.user_id = o.user_id
WHERE o.amount > 1000;

执行计划变成两个表的Hash Join,从30秒降到0.8秒。

教训: 看到 DEPENDENT SUBQUERY 要立刻警觉。90%的情况改成JOIN都能解决。

快速看懂执行计划的三步法

15年看下来,我总结了一套三步法,Oracle和MySQL通用。

第一步:找驱动表

执行计划从最内层开始执行。从ID最小的操作开始,顺着Operation往"右下方"找,找到最底层的那个扫描操作——那就是驱动表。

第二步:看访问方式

  • const / eq_ref:主键或唯一索引等值查找,通常很快
  • ref:非唯一索引等值,还行
  • range:索引范围扫描,关注范围大小
  • index:全索引扫描,比全表扫描好一点但有限
  • ALL:全表扫描,大表上出现基本要优化

同时看Extra:Using filesort(额外排序)和 Using temporary(临时表)出现就要警惕。

第三步:看连接方式和顺序

  • Nested Loop:适合小表驱动大表,被驱动表要有索引
  • Hash Join:适合大表之间关联,不需要索引
  • Sort Merge Join:已排序数据之间的关联,场景较少

连接顺序很重要。Oracle里从内到外读,MySQL里从上到下读。小结果集要放在驱动位置。

执行计划也有"坑"

执行计划不是万能的,有几个常见的误导场景。

统计信息过时,优化器估算的行数和实际行数差几个数量级。比如Oracle里某个字段NDV(不同值数量)没更新,估算100行,实际50万行。

隐式类型转换让索引失效。WHERE varchar_col = 12345,MySQL会隐式加 CAST,索引直接废了。Oracle也有类似问题。字段是什么类型,条件就传什么类型。

参数嗅探(Parameter Sniffing)问题。存储过程第一次编译时,恰好用了个不具代表性的参数值,后面每次执行都沿用第一次的计划。Oracle和SQL Server都有这个问题。

实战建议

这些年我调SQL,形成了一套固定流程:

  1. 先看执行计划,别上来就加索引
  2. 重点关注三个维度:访问方式(ALL?)、连接方式(选对没?)、行数估算(准不准?)
  3. 统计信息是根本。很多看似复杂的性能问题,更新统计信息就解决了
  4. 隐式转换是隐形杀手。字段类型不匹配,再好的索引也白搭
  5. 学会用Hint验证思路。不是让你线上用Hint,而是用它来验证你的优化方向对不对

上个月有个国产数据库迁移项目,同样的SQL在Oracle上跑得好好的,迁过去性能断崖式下跌。看了两边的执行计划,发现是统计信息采集策略不同,优化器选了完全不一样的路径。手动调整统计信息后解决了。

不管用什么数据库,统计信息管理永远是性能调优的第一步。执行计划是你的"诊断报告",学会读它,SQL调优就入门了。

各位平时看执行计划遇到过什么疑难杂症?评论区聊聊。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐