做 Oracle 性能优化这些年,我几乎每天都会重复一个动作:拿到一条慢 SQL,先不急着加索引、改业务,先看执行计划。执行计划就是优化器给这条 SQL 设计好的“执行路线图”——先访问哪张表、怎么连接、走全表扫描还是索引、预估扫多少行,全都写在那几行缩进里。很多人觉得执行计划难读,其实只是没掌握读的顺序和几个关键栏位,再加上生产环境里统计信息过期、隐式转换、连接方式误判这些坑,才会觉得玄乎。这篇文章就围绕执行计划分析,结合几个我在生产环境里实际处理过的案例,把怎么读、怎么定位、怎么解决一次讲清楚。适合刚接触 SQL 调优的开发同学、专职 DBA,以及所有被生产慢查询折磨过的朋友。
1. 执行计划到底在算什么?先看懂优化器的“算盘”
1.1 一条 SQL 变成执行计划,优化器经历了什么
你给 Oracle 丢一条 SQL,它会经历解析、优化、生成行源代码几个阶段,其中优化这一步决定了最终的执行计划。基于代价的优化器(CBO)会枚举多条可能的执行路径:全表扫还是走索引,嵌套循环连接还是哈希连接,先连哪张表再连哪张表。然后根据表的统计信息,比如行数、块数、列的基数、直方图分布等,给每条路径算一个代价,最后选代价最低的作为执行计划。
这里一定要理解一个关键点:优化器不是在真正执行 SQL,它是在“猜”执行这条 SQL 的代价。猜得准不准,取决于统计信息全不全、新不新,也取决于查询条件本身能不能被准确估算。生产环境里绝大多数执行计划问题,都出在这个“猜”的环节。所以我排查问题时有个习惯,先看统计信息的 last_analyzed,再看谓词条件,然后才看连接方式。顺序反了,容易被表象带偏。
1.2 访问路径和连接方式,这两张老底子必须背下来
执行计划里最核心的两类操作,一个是访问路径(怎么取数据),一个是连接方式(多张表怎么拼)。新手最容易在这里犯迷糊:是不是走索引就一定快?答案是不一定。访问路径的选择是全局博弈,跟过滤条件的选择性、表的大小、统计信息都有关系。
| 访问路径 | 典型场景 | 备注 |
|---|---|---|
| TABLE ACCESS FULL | 返回数据比例大、小表、无可选索引 | 不等于“差”,大表扫 80% 数据时比走索引更合理 |
| INDEX RANGE SCAN | 列上有等值或范围条件 | 最常用,按索引定位后再回表 |
| INDEX FULL SCAN | 查询列都在索引中且需要排序 | 单块读,通常比全表扫慢,但能避免排序 |
| INDEX FAST FULL SCAN | 查询列都在索引中,不需要排序 | 多块读,适合只取索引列的统计类查询 |
| INDEX SKIP SCAN | 复合索引前导列没有过滤条件 | 依赖前导列基数低,否则代价很高 |
连接方式同样适合用表格对照理解:
| 连接方式 | 适合场景 | 劣势 |
|---|---|---|
| NESTED LOOP | 外层结果集很小,内层能走索引 | 外层结果集大时,循环次数暴增 |
| HASH JOIN | 两张大表等值连接,或没有可用索引 | 建哈希表耗内存 |
| SORT MERGE JOIN | 数据已排序、非等值连接(>、<) | 排序开销大 |
优化器选择连接方式时,核心看两个东西:驱动表(外层表)的返回行数,以及被驱动表能不能高效定位数据。这两个数字一旦估算错误,连接方式就会翻车,后面案例里我会详细展开。
1.3 代价不等于真实性能,别被 Cost 数字带偏
执行计划里的 Cost 是优化器内部计算的相对代价单位,不是秒,也不是逻辑读数量。生产上经常出现这样的情况:计划里 cost 是 3,实际跑了 30 秒;另一个计划 cost 是 300,实际反而 1 秒完成。所以拿到执行计划,别盯着 Cost 列纠结,重点看 E-Rows(估算行数)、A-Rows(实际行数)、Buffers(逻辑读)、A-Time(实际耗时)这几个可以被验证的指标。Cost 的作用是让优化器自己排序比较,不是给 DBA 看性能的。
2. 拿到执行计划后怎么读?顺序和方法是命门
2.1 三种最常用的取计划方式
第一种,EXPLAIN PLAN FOR 配合 DBMS_XPLAN.DISPLAY,适合事前预测,它不会真正执行 SQL,只是让优化器算一条计划出来:
EXPLAIN PLAN FOR SELECT * FROM T_ORDER WHERE ORDER_ID = :id; SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY);第二种,DBMS_XPLAN.DISPLAY_CURSOR,这是调优时我最常用的一招。它能拿到一条 SQL 真实执行时的计划外加实际统计信息,比如 A-Rows、A-Time、物理读:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR('sql_id', NULL, 'ALLSTATS LAST'));第三种,DBMS_XPLAN.DISPLAY_AWR。当 SQL 早就跑完、shared pool 里已经找不到时,可以从 AWR 快照里挖历史计划,特别适合排查那种“昨晚慢了一下,现在又正常”的问题:
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_AWR('sql_id'));提示:EXPLAIN PLAN 给的是估算值,只有 ALLSTATS 里带出来的 A-Rows 才是实际值。排查性能问题时,能用 DISPLAY_CURSOR 就别只用 EXPLAIN PLAN。
2.2 读执行计划的顺序:从缩进最深处开始,由内向外
很多新手拿到计划直接从上往下念,这恰好是错的。Oracle 执行计划的执行顺序是:缩进最深、层级最靠右的操作最先执行;同一缩进级别的操作,按从上到下顺序执行;父操作要等子操作的结果返回后才开始。看下面这个简单的嵌套循环计划:
-------------------------------------------------------------------------------- | Id | Operation | Name | E-Rows | A-Rows | -------------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | | 100 | | 1 | NESTED LOOP | | 100 | 100 | | 2 | TABLE ACCESS BY INDEX ROWID| T_A | 10 | 10 | |* 3 | INDEX RANGE SCAN | IDX_A | 10 | 10 | | 4 | TABLE ACCESS BY INDEX ROWID| T_B | 1 | 10 | |* 5 | INDEX RANGE SCAN | IDX_B | 10 | 10 | --------------------------------------------------------------------------------执行顺序是:先执行 Id 3(缩进最深),扫描 T_A 表上的 IDX_A 索引取得 rowid,再回表 Id 2 取出 T_A 的行数据,这样得到第一批驱动行;然后针对每一行驱动数据,执行 Id 5 扫描 T_B 的索引 IDX_B,再回表 Id 4 取 T_B 的行。Id 1 的 NESTED LOOP 只是承接这些结果。理解这个“先内后外、先右后左”的读取顺序,你才能把计划读成真正的时间线,而不是一堆操作名词的堆叠。
2.3 重点盯住 E-Rows 和 A-Rows 的差距
执行计划分析中,最重要的诊断信号就是 E-Rows 与 A-Rows 的差异。E-Rows 是优化器根据统计信息估算的返回行数,A-Rows 是实际执行返回的行数。理论上两者应该接近,一旦差了一个数量级以上,就说明优化器对条件或连接基数的判断出了严重偏差,这种偏差会在连接方式、访问路径上放大,最终发展为慢查询。
我拿到计划后,会先看 Predicate Information 这一段。它分两种:access 表示通过索引或哈希直接定位数据的谓词,filter 表示拿到数据后再过滤的谓词。如果某个过滤条件出现在 filter 而不是 access 里,往往意味着数据量被低估,或者索引设计有问题。这一栏是整个执行计划里信息密度最高的地方,值得每个做调优的人反复研究。
3. 生产案例一:统计信息过期,让好索引被晾在一边
3.1 凌晨跑批突然多了 40 秒
某公司财务系统的对账批处理,有一张大表 T_FLOW(账户流水表),每天凌晨按日期范围汇总记账。有一天批处理时间从 8 分钟膨胀到 20 分钟,直接拖慢了后续一系列任务。抓出来造成问题的 SQL 是这样的:
SELECT 账号, SUM(金额) FROM T_FLOW WHERE 流水日期 BETWEEN :start_date AND :end_date GROUP BY 账号;T_FLOW 表里积累了一年的流水,总量约 1.2 亿行,流水日期上建了普通索引 IDX_FLOW_DATE。这条 SQL 以前跑得一直很快,为什么偏偏今天变慢了?查库时我发现一个细节:表数据这半年几乎翻了一倍,而问题恰恰出在“数据量变了,统计数据却没跟上”。
3.2 执行计划显示:索引就在那,优化器就是不选
用 DBMS_XPLAN.DISPLAY_CURSOR 拿到的真实计划如下:
----------------------------------------------------------------------------------------------- | Id | Operation | Name | E-Rows | A-Rows | Buffers | A-Time | ----------------------------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | | 100K | 480235 | 00:00:42.12 | | 1 | HASH GROUP BY | | 100K | 100K | 480235 | 00:00:42.12 | | 2 | TABLE ACCESS FULL | T_FLOW | 12M | 12M | 478889 | 00:00:35.60 | -----------------------------------------------------------------------------------------------A-Rows 显示实际扫了 1200 万行,占全表的 10%,这个量级确实适合全表扫描。但问题是,这条 SQL 是跑某一天的流水,真正应该命中的数据只有 90 万行左右。也就是说,优化器把查询范围的选择率估算得过高,误以为 BETWEEN 条件会命中 12% 的数据,于是放弃了索引。
3.3 一查统计信息,果然半年没收集
排查步骤很直接。先看表统计信息:
SELECT table_name, num_rows, last_analyzed FROM user_tables WHERE table_name = 'T_FLOW';结果 last_analyzed 还是半年前的日期。那时候这张表只有 6000 万行,日期列的最小值和最大值也都跟现在不一样了。统计信息过期,等于优化器拿着半年前的“地图”在给现在的数据指路,走错路一点都不意外。我手工收集了统计信息:
BEGIN DBMS_STATS.GATHER_TABLE_STATS(ownname => 'DW', tabname => 'T_FLOW', method_opt => 'FOR ALL COLUMNS SIZE AUTO', estimate_percent => DBMS_STATS.AUTO_SAMPLE_SIZE); END; /收完之后重新执行,执行计划切回索引范围扫描,批处理恢复到 8 分钟左右。从发现问题到恢复,整个过程不到半小时,但前提是你会先怀疑统计信息,而不是一上来就改 SQL。
3.4 复盘:统计信息维护是纪律问题
这个案例给我最大的教训是:大表的统计信息维护不能靠运气。生产环境里增长有明显规律的表,应该做成定时任务,每天或每周根据数据变化频率收集。就算怕收集开销大,也至少要保证大表的 num_rows 和关键列的高价值直方图是新的。另外要记住一个排查原则:看到执行计划里出现全表扫描,先反问一句“统计信息新鲜吗?”再下结论。很多时候问题不在 SQL 写得烂,而在优化器掌握的情报已经过时了。
4. 生产案例二:隐式类型转换,索引被“绑住手脚”
4.1 一条本来没问题的 SQL,上线一个月后开始拖库
某业务系统的用户表 T_USER,有个查询语句:
SELECT * FROM T_USER WHERE ACCOUNT_NO = :acc;ACCOUNT_NO 字段是 VARCHAR2(30),上面建了普通索引 IDX_USER_ACCOUNT。上线初期执行一直是毫秒级,一个月之后突然变成秒级全表扫描。用户量只增长了一点点,为什么性能会掉这么多?抓 SQL 的时候我注意到,接口层的调用参数是数值类型,而表里的账号列是字符串,这个细节很可能就是根源。
4.2 计划里的谓词信息泄露了天机
使用 DISPLAY_CURSOR 抓计划,重点看 Predicate Information:
---------------------------------------------------------------------------------------------- | Id | Operation | Name | E-Rows | A-Rows | Predicate Information | ---------------------------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | | 10 | | |* 1 | TABLE ACCESS FULL | T_USER | 10 | 10 | filter(TO_NUMBER("ACCOUNT_NO")=:acc) | ----------------------------------------------------------------------------------------------看到 TO_NUMBER("ACCOUNT_NO") = :acc 这行,问题立刻清楚了。绑定变量 :acc 传进来的是 NUMBER 类型,而 ACCOUNT_NO 列是 VARCHAR2。Oracle 在解析时选择把列类型转换为数字去比较,等于在列上套了个函数。只要列被函数包裹,普通的 B+ 树索引就没法用范围扫描,只能全表扫一遍,再对每行做转换和过滤。
这类隐式转换是生产环境里最常见的“隐形杀手”。它不报错、不告警,就是让你发现 SQL 越来越慢。比较典型的原因有三个:应用代码没有按表结构定义参数类型,接口层把账号当数字处理,或者 SQL 里直接拿数字字面量与字符串列比较。
4.3 修复手段:优先改应用,兜底建函数索引
最干净的修法是让应用层保证绑定变量是字符串类型,SQL 写成 WHERE ACCOUNT_NO = '123456',从句法上消灭转换。如果应用改起来成本高,还有一招是建函数索引来配合 Oracle 的隐式转换:
CREATE INDEX IDX_USER_ACCOUNT_NUM ON T_USER(TO_NUMBER(ACCOUNT_NO));这样 Oracle 虽然还是会做 TO_NUMBER,但索引本身也是 TO_NUMBER 后的值,两边能对上,索引就能用。不过函数索引属于兜底方案,等于承认了“列类型设计不合理”这个事实,后续维护会多一些成本。我个人的习惯是:能在应用层改就改应用,不能改再考虑函数索引。
4.4 排查时专门盯谓词区
以后大家拿到执行计划,可以养成一个职业习惯:先扫一眼 Predicate Information,只要看到 TO_NUMBER、TO_DATE、TO_CHAR 这些函数包着列名,基本就可以锁定隐式转换。这个检查只需要十秒钟,却能在所有索引优化手段之前,帮你排除一个非常隐蔽的根因。
5. 生产案例三:连接方式被基数估算带偏
5.1 多表关联查询,晚上高峰直接“卡死”
某订单系统的报表查询,在每天晚上高峰时段出现积压。SQL 大致是这样:
SELECT o.order_id, m.merchant_name FROM T_ORDER o JOIN T_MERCHANT m ON o.merchant_id = m.merchant_id WHERE o.order_time >= :start_dt AND o.order_time < :end_dt AND m.status = 1;T_ORDER 订单表大概 5000 万行,T_MERCHANT 商户表只有 5 万行,这个量级下通常会让小表驱动大表,配合哈希连接。但当时这条 SQL 单次执行就要 45 秒,并发一高,共享池和 I/O 全部告急。
5.2 优化器选择了一个离谱的嵌套循环
抓到执行计划:
--------------------------------------------------------------------------------------------- | Id | Operation | Name | E-Rows | A-Rows | Buffers | --------------------------------------------------------------------------------------------- | 0 | SELECT STATEMENT | | | 200K | 800301 | | 1 | NESTED LOOP | | 200K | 200K | 800301 | | 2 | TABLE ACCESS FULL | T_MERCHANT | 1 | 5 | 20 | |* 3 | TABLE ACCESS FULL | T_ORDER | 1 | 200K | 800200 | ---------------------------------------------------------------------------------------------注意 E-Rows 这一列:T_MERCHANT 被估算成只返回 1 行,因为统计信息里 status=1 的选择率被算得极低。优化器一看,驱动表只有 1 行,拿这 1 行去嵌套循环连 T_ORDER,成本不高,于是选了这个计划。可实际执行时,T_MERCHANT 里满足 status=1 的有 5 万行,这意味着外层循环跑了 5 万次,每次都要对 5000 万行的 T_ORDER 做全表扫描。A-Rows 显示的 200K 只是最终返回结果,真正恐怖的逻辑读是 80 万个块。
5.3 修复思路:先修统计,再考虑用提示干预
这种场景的处理路径有三条:
- 重新收集 T_MERCHANT 表上 status 列的统计信息,并确保有直方图,让优化器正确认识 status=1 的选择率;
- 用提示强制走哈希连接:
SELECT /*+ USE_HASH(m o) LEADING(m o) */ o.order_id, m.merchant_name FROM T_ORDER o JOIN T_MERCHANT m ON o.merchant_id = m.merchant_id WHERE o.order_time >= :start_dt AND o.order_time < :end_dt AND m.status = 1;- 改写 SQL,先过滤出小商户集合,再和订单表关联。
我当时先收集了这两个表过期的统计信息,优化器重新计算后自己切回了哈希连接。但考虑到这是核心报表面,改天统计信息一变化,计划可能又飘回去,所以最终还是用 SQL 计划基线把“哈希连接 + 商户表先过滤”这个形态固定了下来。
5.4 这个案例给的核心规律
E-Rows 和 A-Rows 的差距是执行计划最大的警报信号。一旦看到差一个数量级以上,第一反应应该是“优化器在瞎猜”,然后顺着统计信息去查。修复时两条腿走路:先用统计信息让基数回归正确,再用计划基线或提示把成果固化。这样既能解决眼前问题,又能避免明天因为一次统计信息的变化重新掉坑。
6. 生产案例四:OR 条件引发的“被迫全表扫描”
6.1 一个 OR,让建好的索引全白搭
某短信平台有张消息表 T_MSG,按 create_time 和 send_status 分别建了索引。有一天运营同学要拉一批“昨天创建且发送中或发送成功”的消息做核对:
SELECT * FROM T_MSG WHERE create_time >= :start_dt AND create_time < :end_dt AND (send_status = 1 OR send_status = 2);执行计划是清一色的 TABLE ACCESS FULL。表一共 8000 万行,其实按 create_time 范围过滤后只剩几万行,理论上走索引非常划算。但 OR 条件让优化器在两个可选的索引路径之间难以取舍——它既不想先按时间过滤再回表判断 send_status,也不想先按状态索引拿一半数据再去和时间比对,最后干脆选择了全表扫描。这就是 OR 条件最常见的代价。
6.2 改写成 UNION ALL 分支,各走各的索引
把 OR 拆开成两个互斥分支:
SELECT * FROM T_MSG WHERE create_time >= :start_dt AND create_time < :end_dt AND send_status = 1 UNION ALL SELECT * FROM T_MSG WHERE create_time >= :start_dt AND create_time < :end_dt AND send_status = 2;改写后,两个分支都能稳稳地用到 create_time 上的索引,先按时间范围收窄数据,再过滤状态。实际执行时间从 20 秒降到了 0.5 秒。注意这里必须用 UNION ALL 而不是 UNION,因为 send_status 分别为 1 和 2 的两个查询结果在逻辑上不可能重复,UNION 会额外做一次去重和排序,纯属浪费。
6.3 什么时候不用改写
如果 OR 里的条件都是等值,而且取值很少,比如性别、类型这种枚举字段,总数只有两三个值,并且总选择性本来就低,那么直接用 IN 列表有时候也能走索引。关键是让每条分支路径都可索引。写 SQL 时把条件改写成“每个分支都能独立走索引”的形态,比依赖优化器智能更可靠。尤其在数据量超过千万级的场景里,这种改写带来的收益非常明显。
6.4 留个自查标准
以后遇到带 OR 的查询,先从计划里看两件事:是不是全表扫描?谓词能不能拆成可索引的分支?能拆就拆,不能拆再考虑加复合索引或者换执行方式。记住,OR 不是原罪,让 OR 变成全表扫描的,是优化器面对多路选择时无法精准评估合并代价的短板。
7. 生产环境里那些坑:执行计划变了、固定计划失败怎么办
7.1 为什么同一个 SQL 的计划会变来变去
很多开发同学有这样的困惑:这条 SQL 昨天还走索引,今天突然全表扫描,代码一行没动。其实执行计划是优化器基于统计信息、绑定变量、数据库参数、环境变量综合算出来的,任何一个输入变了,输出都可能变。常见的“计划突变”诱因包括:
- 统计信息更新,选择性重新估算;
- 绑定变量窥探(bind peeking)机制下,不同取值会生成不同计划;
- 数据库参数调整,比如 optimizer_index_cost_adj、optimizer_mode 变化;
- 索引被删除、失效或者重建;
- 12c 之后的自适应计划特性会根据运行时反馈切换连接方式。
理解这一点后,就不会把执行计划当成固定不变的东西,而是会主动给关键 SQL 做“计划保护”。
7.2 固定计划的三板斧
第一板斧是 SQL Plan Baseline,从 11g 开始支持,能够捕获 SQL 的执行计划快照,并在后续执行中锁定。数据库升级、统计信息变化导致计划突变时,基线能帮你安全度过。第二板斧是 SQL Profile,它不直接锁定计划,而是通过调整优化器的估算系数来修正基数误差,适合不能改代码的场景。第三板斧是 Hints 改写,适合能改 SQL 的场合,前面用到的 LEADING、USE_HASH、INDEX 都是常用武器。
选择顺序上,我一般先看 SQL 能不能改,能改优先 Hints,配合代码评审;不能改再看能不能建基线。SQL Profile 属于干扰优化器估算的手段,能用但要有度,用多了会让执行计划越来越依赖人工干预。
7.3 排查执行计划问题实用清单
| 症状 | 排查点 |
|---|---|
| 不该全表扫描却全表扫 | 统计信息、隐式转换、OR 条件、索引是否真的存在且有效 |
| 连接方式异常(本该哈希却嵌套) | 驱动表基数估算、连接列数据分布、能否走索引 |
| E-Rows 与 A-Rows 差异巨大 | 直方图缺失、采样率低、绑定变量取值异常 |
| 计划白天正常晚上变慢 | 并发变化、参数变化、统计信息收集窗口、分区裁剪 |
| 数据库升级后计划突变 | 版本行为差异、未建立计划基线 |
这张表是我每次处理“慢 SQL 溯源”时的工作底稿,基本上照着过一遍,方向不会跑偏。
7.4 一个小技巧:用真实执行统计代替估算
最后分享一个实用技巧。如果你想亲眼看到一条 SQL 的真实执行路径和实际行数,可以在单独会话里打开统计级别,再执行 SQL,然后取计划:
ALTER SESSION SET statistics_level = ALL; -- 在这里执行你的业务SQL SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, 'ALLSTATS LAST'));生产库上不建议全局开启,但在具体会话里开启影响很小。ALLSTATS LAST 会给你最关键的 A-Rows 和 A-Time,这比干啃 EXPLAIN PLAN 的估算值靠谱得多。我处理大多数疑难杂症时,都是靠这一招拿到真实数据的。
做执行计划分析这几年,我最大的体会是:别把它当成玄学,它是可以系统拆解的。拿到一条慢 SQL,固定动作就是一看统计信息、二看谓词、三看连接方式、四看实际行数,四个动作做完,90% 的问题都能定位。案例里那些奇奇怪怪的计划突变,归根结底都是基数估算出了问题。另外想提醒一句,调优解决的是当前问题,如果不想明天再踩同一个坑,务必把统计信息维护和计划基线这些长效机制一并做掉。这行干久了会明白,最值钱的不是把一个计划调多快,而是让系统明天、下个月还能保持这个速度。