☰
Oracle执行计划分析:慢SQL排查与计划固化的生产实践
2026/10/19 2:55:46 网站建设 项目流程

干数据库运维这行久了,有个问题被问到的频率特别高:SQL明明没改,为什么突然变慢了?大多数时候,答案都藏在一个不太起眼的东西里——执行计划。执行计划是Oracle优化器给SQL选的一条物理执行路径,它决定了表怎么扫、索引怎么走、表之间按什么顺序怎么连。这篇文章就从Oracle执行计划分析入手,结合两个我实际处理过的生产案例,把读计划、查根因、做固化的完整链路讲清楚,适合每天都跟慢SQL打交道的DBA、运维和开发人员参考。

1. 执行计划是怎么被"画"出来的:从语法树到物理执行路径

1.1 一条SQL进入数据库后经历了什么

很多人把执行计划理解成"SQL的翻译结果",其实不太准确。更贴切的说法是,它是优化器在成本模型下"算"出来的一条物理执行路线。一条SQL进来,Oracle大致要经历三个阶段:解析、优化、执行。

解析阶段做的是语法检查和语义检查,通过之后会在库缓存里寻找相同文本、相同环境的游标。如果找不到,就要做硬解析,生成一棵解析树。这一步本身很消耗CPU和latch资源,所以生产系统都强调游标共享,鼓励使用绑定变量。

优化阶段才是核心。优化器先做逻辑改写,比如谓词推入(predicate pushing)、视图合并(view merging)、子查询去嵌套(subquery unnesting);接着做物理优化评估,也就是CBO的核心逻辑——它对每个可能的访问路径(全表扫描、索引范围扫描、索引唯一扫描等)、连接顺序、连接方法(嵌套循环、哈希连接、排序合并),分别估算代价,最后挑一个代价最小的组合作为执行计划。

执行阶段不用多说,优化器把选中的计划交给执行引擎,按操作符一步步取数、连接、过滤。这一步就会真实消耗CPU、内存、IO,等待事件也发生在这个阶段。

理解了这三步,你就能明白"代码没动过、SQL也没改过、但性能变了"这件事为什么会发生——改变的是优化器的输入数据。输入一变化,它重新算出来的所谓"最优计划"就可能完全走样。

1.2 执行计划里的关键要素:字段、操作符和代价

用DBMS_XPLAN或AWR拿出来的执行计划,第一眼看上去是一棵带缩进的树。里面有几个字段必须看明白,我整理了一张常用对照表:

字段含义判断要点
ID操作符编号执行顺序不按ID大小,而是按缩进深度
Operation操作类型TABLE ACCESS FULL、INDEX RANGE SCAN、NESTED LOOPS等
Name操作对象表名或索引名,必须看清楚扫的是哪张表、哪个索引
Rows优化器估算返回行数和实际行数对比是诊断的关键
Cost(%CPU)代价成本相对值,不是绝对时间
Time估算执行时间同样是估算,别当真
Predicate Information谓词信息区分access和filter至关重要

访问谓词(access)和过滤谓词(filter)的区别,很多人会忽略。access表示通过索引定位时直接限定的范围,比如INDEX RANGE SCAN的access predicate是索引列上的等值或范围条件;filter则是在操作符内部逐行过滤的条件。如果本该走access的谓词变成了filter,通常说明索引设计有问题,或者优化器没法把谓词推到索引扫描中去。

另外要特别留意Rows这一列。它写的是优化器的"预估行数",不是实际行数。如果预估出来后你和实际执行结果对不上,那这个计划从根上就是错的,后面所有连接的代价估算都会跟着歪。

1.3 怎么正确读出执行顺序:从最内层往外读

读执行计划有个基本法则:从最内层、缩进最深的操作符开始,逐层向外;同一缩进层级的节点,从上往下执行。也就是说,不能简单地按ID从小到大读。

举个例子,一个典型计划的缩进结构可能是这样:

  • 第5行是索引唯一扫描,第4行是回表
  • 第6行是另一张小表的全表扫描
  • 第3行是这两者做嵌套循环
  • 第2行是排序
  • 第1行是最终输出

实际执行时,先执行离根最远的叶子节点,数据一层层向上返回,根节点是最后输出的。很多人第一次看计划时从上往下读,结果把顺序完全读反了,这是新手最常见的问题。

还有一点必须强调:EXPLAIN PLAN FOR拿到的计划只是"预测",它不会真的执行SQL,所以拿不到实际行数、实际耗时这些信息。想拿到真实执行统计,要用:

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id', 0, 'ALLSTATS LAST'));

这样可以对比每一行的估算行数(E-Rows)和实际行数(A-Rows),还能看到实际耗时(A-Time)、物理读次数。这个习惯比单纯看计划文本有效太多,后面案例里我会反复用到。

2. 第一个生产案例:一条对账SQL从3秒退化到20分钟

2.1 故障表象与第一轮排查

这个案例发生在一个电商类的数据分析平台。业务方报障说,每日对账报表跑不出来了,之前十几秒就出结果,现在等了20多分钟还在转。这张报表挂在财务的晨间流程里,而且只能串行跑,越迟越影响后面一整套流程。

我们接手后的第一步不是直接看SQL,而是先确认数据库层面有没有异常。查了v$session,发现不是锁等待,没有日志写入问题,也没有明显的资源争用,单纯就是一条SQL在长时间执行。于是从v$session里抓到SQL_ID,再点开AWR看它的执行历史。

AWR的时间线非常直观:过去7天这条SQL平均单次执行3秒左右,从某天凌晨1点开始,单次执行时间跳到15分钟以上。同时注意到,那天凌晨正好有一个定时任务在跑,内容是历史分区清理和表迁移。几乎所有数据变更的窗口都指向同一个时间点。到这里,问题范围已经缩得很小了。

2.2 用执行计划锁定根因

我当时做了一件很关键的事:不只拿当前执行计划,还把历史执行计划也调出来。当前计划用DISPLAY_CURSOR,历史计划从AWR里取。一对,问题立刻现形:

改动前:对账主表走某个二级索引的INDEX RANGE SCAN,然后回表,再和两张小表做嵌套循环,整体代价约200。

改动后:同样的SQL,对账主表变成了TABLE ACCESS FULL,然后三张表做HASH JOIN,代价直接飙到3万以上。

接下来的问题是:为什么原来好好的索引突然不走了?得看两个点——索引还在不在,统计信息正不正常。查DBA_INDEXES,索引存在;但查DBA_TAB_STATISTICS时发现问题:主表的统计信息是空的,LAST_ANALYZED为空,NUM_ROWS显示为0。

原因链条一下就通了:凌晨的任务在迁移分区时用了TRUNCATE配合直接路径插入,表被清空重建后,自动统计信息任务还没来得及跑,优化器拿到的是"这张表0行"的元数据。既然表是空的,它当然觉得全表扫描最划算——一张空表,扫起来能有多少代价?可实际上表里有1000多万行,于是优化器做出了一个灾难性的计划。

这里要给个提醒:分区清理脚本后面一定要带手动收集统计信息的步骤,不能依赖夜间自动任务。很多生产环境的维护窗口会跳过自动统计,或者表本身被排除在自动任务范围之外,脚本里不主动收,就容易踩这个坑。

2.3 修复动作与效果验证

修复动作分两步。第一步,收集统计信息:

EXEC DBMS_STATS.GATHER_TABLE_STATS( ownname => '某用户', tabname => '对账主表', method_opt => 'FOR ALL COLUMNS SIZE AUTO', cascade => true );

收集完成后再看一次计划,索引路径已经回来了,代价回落到200附近。第二步,重跑一次对账SQL,耗时回到3秒左右。

还有一个动作很重要:把这条SQL的SQL_ID加入监控告警,后续只要plan_hash_value发生变化,立刻通知DBA核对。这类核心报表SQL一旦计划漂移,业务影响是直接的,事后补救永远不如事前发现。

2.4 这个案例沉淀的三层判断框架

通过这个案例,我在内部复盘时总结了一个执行计划诊断的三层判断框架:

第一层看现象:时长、等待事件、资源消耗,先确定是不是SQL本身的问题,排除锁、IO、归档等外围因素。

第二层看计划:当前计划对比历史计划,重点关注plan_hash_value是否变化、访问路径是否变化、估算行数与实际行数是否严重偏离。

第三层看数据环境:统计信息是否新鲜、表数据量是否发生数量级变化、是否新增或删除了索引、分区结构是否有变动。

这套框架后来被我用在好几次同类故障上,命中率很高。别急着翻代码、改SQL,先把这三层过一遍,大多数执行计划问题的根因都在里面。

3. 执行计划为什么会"变卦":统计信息、绑定变量与优化器的盲区

3.1 统计信息过期是计划漂移的最大推手

上一个案例本质上是统计信息缺失。但生产环境里更常见的是统计信息过期——表一直在写入,数据量、数据分布都在变,而DBA_TAB_STATISTICS里的LAST_ANALYZED还停在几个月前。

判断统计信息是否过期,可以查DBA_TAB_STATISTICS的STALE_STATS列。Oracle在启用自动统计任务时,会持续跟踪表的数据变化量,标记是否达到重新收集的阈值。你自己也可以对比:表当前的实际行数和统计信息里的NUM_ROWS,偏差超过5%到10%就该手动触发重新收集了。

除了行数,列级别的数据分布变化同样致命。举个例子,某张订单表的订单状态列,平时99%都是"已完成",只有1%是"待处理"。优化器基于旧的直方图猜测"待处理"只有几千行,于是对这条谓词选择了索引扫描。等某天积压到几十万行时,它可能还是走索引,结果几十万次回表,性能直接崩掉。这种情况单靠重新收集统计信息还不够,还得确认直方图覆盖了这个列的倾斜分布,收集时用FOR ALL COLUMNS SIZE AUTO或对关键列单独指定SIZE SKEWONLY。

3.2 绑定变量窥视与游标共享的两难

生产系统的SQL一般都会用绑定变量。绑定变量能提升游标共享、降低硬解析,这是好事,但它也给优化器带来了一个盲区——优化器在生成计划时,并不知道这个绑定变量具体是什么值。早期版本的做法是"窥视"第一次执行时的值(bind peeking),拿这个值去估算过滤性,生成一个计划,然后共享给后续所有执行。

问题来了:第一次执行时传入的恰好是个小选择性的值,计划选了INDEX RANGE SCAN;第二次传入大数据量的值,计划还是那个计划,不愿意重新生成,于是索引路径在数据量巨大的情况下照走不误,性能雪崩。

从11g开始,Oracle引入了自适应游标共享(Adaptive Cursor Sharing),核心思路是:同一个SQL文本,根据不同绑定变量值和执行行为特征,可以存在多个子游标,每个子游标对应不同的执行计划,并在执行后根据实际表现决定后续是否复用。DBA可以在V$SQL里看到IS_BIND_SENSITIVE、IS_BIND_AWARE、IS_SHAREABLE这些标记列。

这给我们的实操提示是:遇到"同一个SQL_ID下出现多个plan_hash_value"时,别急着下结论说优化器疯了,先检查是不是自适应游标共享在起作用,对比每个计划的实际资源消耗,把差计划用SPM禁止掉,而不是粗暴地把绑定变量拆掉。

3.3 基数估算错误会一路放大

优化器的一切判断都建立在基数估算上——它预测某一步会返回多少行。一旦基数预估错了,后面所有连接顺序、连接方法、访问路径都会跟着错,而且错误会指数级放大。

举个例子,优化器估算某张表过滤后只剩1行,实际有100万行。它可能因此选择嵌套循环,用一张小表做驱动,内层反复扫描大表100万次。从纸面上看这个计划代价极低,实际执行却是灾难。

为什么优化器会把基数估得这么离谱?最常见的原因是列之间的相关性。比如一张表同时有"城市"和"省份"两个列,优化器假设两个条件是独立的,分别估算过滤率后相乘。实际上城市列的值基本由省份决定,两个条件高度相关,乘积后的结果会严重低估实际行数。这是典型的"关联谓词被当成独立条件",处理时需要用扩展统计信息(Extended Statistics)声明列组相关性,或者干脆改写SQL,把语义表达得更直接。

4. 第二个生产案例:三表关联被优化器"带偏"的完整复盘

4.1 SQL逻辑与业务预期

第二个案例来自某公司的仓储管理系统,是一张某库存查询SQL。SQL本身不复杂,三张表关联:

  • 库存表:大表,几千万行
  • 仓库维度表:小表,几百行
  • 商品维度表:中表,几十万行

业务逻辑是按仓库加商品编码查实时库存。期望的执行顺序是:从仓库维度表开始,先过滤掉大部分仓库,然后拿仓库编码去库存表上走索引定位,最后关联商品表补字段。这样每步中间结果集都很小,整体就是毫秒到秒级。

4.2 执行计划的异常点:驱动表完全选反了

实际拿到的执行计划是这样的:

  • 驱动表变成了商品维度表,而且做的是全表扫描,优化器估算输出50万行
  • 商品表结果再和库存表做HASH JOIN,库存表同样全表扫描
  • 最后才和仓库维度表做嵌套循环

从纸面看,这个计划前面的基数预估就有问题:SQL里明明是几百个商品编码的IN列表,优化器却把过滤后的输出行数估成了几十万行。基数一大,它认为"从商品维度表开始,后面怎么走都合理",于是选了这条路。实际执行时前面一步就吐出大量行,后面再怎么优化也救不回来。

这里暴露了一个常见现象:IN列表的基数估算,Oracle往往比实际要保守得多。尤其是IN列表里包含大量值时,优化器对去重后的有效值个数预估不准,导致驱动表的选择完全失控。

4.3 用10053追踪优化器的决策过程

为了确认优化器为什么做这个选择,我开启了10053事件,把这条SQL重新硬解析一次,把优化器的决策日志拉出来看:

ALTER SESSION SET EVENTS '10053 trace name context forever, level 1';

从trace里能看到两件事。一是各单表访问路径的代价评估,二是所有连接顺序和连接方法的排列组合结果。排列组合部分会清晰列出"商品表做驱动表"和"仓库维度表做驱动表"两条路径各自的估算代价。因为商品维度表全表扫描的代价被算得很低,索引路径的代价反而被高估,最终优化器选了错的那条。

10053的trace文件很长,并不适合每次都读全,但要想知道"优化器为什么这么选",它比任何工具都直接。我一般只看里面Join order和Cost相关行,不纠结细节。

4.4 干预手段:改写SQL与Hint验证

拿到结论后,我实际做了两轮干预。

第一轮用SQL改写:把IN列表改成临时表的EXISTS关联,并用WITH子句固定连接顺序,让中间结果集先压缩。改完后计划明显好转,执行时间从几十秒降到2秒以内。

第二轮为了形成兜底,同时测试Hint方案。加上:

/*+ LEADING(仓库维度表 库存表 商品维度表) USE_NL(库存表) */

效果和改写后的计划一致,驱动表回到了仓库维度表,嵌套循环用的索引路径也恢复了。

但我不建议把Hint直接写死在应用代码里。Hint写进SQL后,后续代码迭代很容易被误删,不同环境索引名不一致还会直接报错。更稳的做法是把固定计划放在数据库侧,用SQL Plan Management固化。

最终采取的组合方案是:应用端使用改写后的SQL,数据库侧把这个执行计划作为SPM基线。这样即使以后统计信息变化、优化器想换计划,也只能在基线范围内选,不会再跳回原来的坏计划。

5. 把执行计划"钉住"的工程化手段:Hint、Profile与SPM的取舍

5.1 Hint不是救命稻草,用错更麻烦

很多开发遇到SQL慢,第一反应是加Hint。但从故障处理经验看,Hint应该放在最后面,而不是第一步。

原因有三。第一,Hint会把执行计划"冻死",后续数据量变化、索引变化、统计信息刷新,它都不会自动适应,属于一次性的补丁。第二,Hint的优先级很高,一旦写错,比如指向一个不存在的索引,优化器会忽略该Hint,有时干脆导致SQL解析失败。第三,多个Hint之间存在交互,LEADING指定了驱动表,USE_NL又指定了另一组连接方法,冲突时计划会变得莫名其妙。

我的原则是:先做统计信息、SQL改写、索引设计这三件事,解决不了再考虑Hint。用Hint时尽量用意图式提示,比如USE_NL、USE_HASH,少用路径式提示如INDEX(t idx1),后者对索引名高度敏感,换个环境就失效。

5.2 SQL Profile与SPM的功能边界

SQL Profile本质上是给优化器一个附加输入,通过修正基数估算的"调整系数"影响计划选择,它不会强制指定某个固定计划。统计数据变了,Profile仍然可能失效。

SPM(SQL Plan Management)更像一个"计划白名单",固定的是plan_hash_value级别的执行计划。优化器只在被接受的计划列表里挑选,不会跑到列表之外。从生产实践看,SPM是更可靠的治理手段。

SPM的标准操作流程大致是这样:

  1. 从游标缓存中确认当前最优计划:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('SQL_ID', NULL, 'ALL'));
  1. 把当前计划加载为基线:
EXEC DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE( sql_id => 'SQL_ID', plan_hash_value => 1234567890 );
  1. 验证基线状态:
SELECT sql_handle, plan_name, enabled, accepted, fixed FROM dba_sql_plan_baselines;

这里有个细节:加载基线之后不等于立刻生效。如果启用了自动演进,优化器会尝试新计划,确认更优后自动接受;如果关闭自动演进,新计划会停留在不可接受状态,需要人工验收。对核心SQL,我建议关闭自动演进,人工把关每一版计划变更。

5.3 把执行计划治理前置到发布流程

比较理想的做法,是把执行计划检查纳入发布流程。每次新SQL上线,先在预发布环境抓一次执行计划,人工确认访问路径和基数估算是否合理。上线后持续观察AWR变化,重点关注plan_hash_value是否发生切换。

另外很推荐给核心SQL建一个"计划基线登记表",字段包括SQL_ID、业务描述、首次发布时间、已知可用的plan_hash_value集合。这样一旦线上出现性能退化,第一件事就是对比当前plan_hash_value和登记表里的可用集合,十分钟就能定位是不是计划漂移。这张表用普通业务表维护就行,数据量不大,但价值极高。

6. 日常会诊习惯:拿到一条慢SQL后我按什么顺序做事

6.1 一张排查清单

处理过的慢SQL多了,我总结了一个固定的检查顺序,算不上什么方法论,就是踩坑踩出来的:

  1. 确认身份:拿到SQL_ID、plan_hash_value、执行平均/最大耗时、执行频次。
  2. 看等待事件:它在等什么,CPU、磁盘IO、网络还是锁。等待事件决定方向。
  3. 看当前计划:用DISPLAY_CURSOR拿真实执行统计,对比A-Rows和E-Rows。
  4. 找历史计划:当前计划不对劲,就去DBA_HIST_SQL_PLAN对比,看是计划被换掉了还是本来就这样。
  5. 查统计信息健康度:表级STALE_STATS标记、列级直方图、分区数量。
  6. 查绑定变量:同一个SQL_ID下多个子游标,确认是否自适应游标共享,比较各子计划的资源消耗。
  7. 最后才动手:根据根因选手段,能收集统计信息解决就不改写,能改写就不加Hint,能加Hint就不做SPM。

这套顺序看起来很简单,但真正出了问题,很多人会跳过第2步直接去改SQL。改完可能确实快了一点,但根因还在,下一次换个数据量照样崩。

6.2 几个随手就能跑的取数SQL

查当前缓存里的执行统计:

SELECT sql_id, plan_hash_value, executions, elapsed_time / 1000000 AS seconds, buffer_gets, disk_reads, cpu_time / 1000000 AS cpu_seconds FROM v$sql WHERE sql_text LIKE '%你的表名%' ORDER BY elapsed_time DESC;

查某条SQL当前缓存中的真实执行计划:

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('SQL_ID', 0, 'ALLSTATS LAST'));

查历史执行计划(来自AWR):

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_AWR('SQL_ID', NULL, NULL, 'ALL'));

查统计信息新鲜度:

SELECT table_name, num_rows, last_analyzed, stale_stats FROM dba_tab_statistics WHERE owner = '某用户' AND table_name = '某表';

这些SQL几乎每天都在用,不管哪个环境都通用,复制粘贴改个表名就能跑。建议存成一个工具脚本集,放到团队共享目录里。

6.3 规范化的执行计划传递方式

团队协作中,我强烈建议不要截图发执行计划。最规范的做法是发SQL_ID加plan_hash_value,配合环境信息,让同事自己用DBMS_XPLAN拉取。截图会丢失谓词信息和真实统计,而且不同方式拿到的计划可能完全不一样。

稍微熟练一点的同学,可以把关键信息打成摘要,例如:

SQL_ID: abc123 当前plan_hash_value: 1234567890 历史plan_hash_value: 0987654321 统计信息last_analyzed: 2024-01-01 表当前行数约: 1200万

这样的信息,有经验的DBA一看就知道问题大概率在哪,也方便归档到前面说的计划基线登记表里。

最后说一点个人体会。执行计划分析这件事,技术上并不难,难的是在故障压力下保持分析的顺序感。很多人一上来就改SQL、加索引,结果往往是按下葫芦浮起瓢。我现在的习惯是,不管多急,先花几分钟把当前计划、历史计划、统计信息三样东西拿到手,再决定下不下手。这套流程帮我少熬了很多夜,也希望对你有点用。另外,每次处理完一次故障,记得把结论沉淀成一页纸的诊断记录,下次遇到同类问题,翻一下就能少走弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询