暂无图片
暂无图片
1
暂无图片
暂无图片
暂无图片

Cost 13 的 SQL 跑了 349 秒:一次 Oracle 执行计划「成本反向」完整复盘

原创 布衣 3天前
96

本文是一次真实生产排查的完整复盘:一条日终余额结转的 INSERT ... SELECT,在 AWR 里累计吃掉约 50.6 小时数据库时间,且同时存在四版执行计划。最戏剧性的结论是——Cost 最贵的计划实测最快,Cost 便宜两万多倍的计划反而是性能黑洞。文中表名、索引名、schema 与实例名均已脱敏,数字与结构保持原样。

一条 SQL,四版计划,50 小时

这条语句是典型的日终批处理:按账号区间分片调用,每个分片约 5000 个账户,把"昨日余额 + 当天借贷汇总"结转成今日余额行:

INSERT INTO biz.acct_balance_d (id, acct_date, ..., end_balance, ...) SELECT SYS_GUID(), :b3, a.cust_type, a.cust_id, a.acct_id, a.end_balance, NVL(sub.c_amt,0), NVL(sub.d_amt,0), CASE WHEN b.schema_code ... THEN ... END, ... FROM biz.acct_balance_d a -- 昨日余额(2.74 亿行,INTERVAL 分区) LEFT JOIN (SELECT acct_id, SUM(DECODE(dr_cr,'C',trans_amt,0)) c_amt, SUM(DECODE(dr_cr,'D',trans_amt,0)) d_amt, ... FROM biz.acct_trans_dtl -- 当天交易明细(月度 RANGE 分区 139 个) WHERE trans_time >= :b3 AND trans_time < :b3 + 1 AND acct_id > :b2 AND acct_id <= :b1 GROUP BY acct_id) sub ON sub.acct_id = a.acct_id LEFT JOIN biz.acct_mast b ON b.acct_id = a.acct_id WHERE a.acct_date >= :b3 - 1 AND a.acct_date < :b3 AND b.del_flg = 0 AND a.acct_id > :b2 AND a.acct_id <= :b1;

业务方反馈日终窗口紧张。打开 AWR 一查,这条 sql_id 的历史统计让人睡不着觉:

plan_hash_value 执行次数 总耗时(s) 每次(s) CPU s/次 逻辑读/次 物理读/次 占总耗时
2375852073 449 156,766 349.15 320.70 28,562,045 5,132 86.1%
1472819723 746 22,770 30.52 2.66 45,763 5,191 12.5%
4034499000 151 2,193 14.52 8.32 201,582 1,439 1.2%
554147421 22 393 17.85 2.37 28,212 3,351 0.2%

同一句话,四版计划,慢的 349 秒、快的 14.5 秒——差 24 倍。而当前共享池里生效的,恰好不是最快的那个。

第一幕:执行计划本身就在撒谎

拿到当前计划的 DBMS_XPLAN 输出,第一眼就是成本模型失真:顶层 Cost 299,901、E-Time 显示 59:59(Oracle 的显示上限,实际 ≥1 小时),全部落在右侧嵌套循环上;而左支"当天交易明细的分区扫描 + 聚合"只被估成 Cost 6、E-Rows 1。

再看计划末尾一行小字:basic plan statistics not available——没有 A-Rows,所有数字都是估算。照着这份计划调优,等于跟着一个错误的模型走。

两个最可疑的结构性疑点:

  • 明细表索引 IDX_TXN_TIME_1(trans_time, cust_id, acct_id) 以时间前导,acct_id 区间只能做 filter 做不了 access;
  • 余额表走的单列全局哈希分区索引 IDX_BAL_ACCT(acct_id),探测估出 71 行、回表后按账期过滤只剩 1 行——71 倍的随机回表是白读的。

插叙:access 与 filter 的差别

谓词 作用 代价
access 在索引里定位:决定从哪个叶块开始扫、扫到哪停 定位多少读多少,不多读
filter 条目已经读出来了,再拿条件逐条判断,不符合就扔 扔掉的都是白读的 IO

复合索引的条目排序是"逐列分层"的:先按 trans_time 排,trans_time 相同的再按 cust_id、acct_id 排。当条件是 trans_time >= :b3 AND trans_time < :b3+1(范围)时,这一段在索引里连续,可以做 access;但在这一段内部,条目是按 cust_id 再 acct_id 排序的——acct_id = 1002、4120、1777 互相隔开,acct_id > :b2 AND <= :b1 这个区间框不出一段连续位置。没有"从哪开始、到哪停",Oracle 就只能把整个时间段里的条目全部读出来,每条再判断一次 acct_id——这正是计划里两行谓词的含义:

access: "trans_time" >= :B3 AND "trans_time" < :B3+1 filter: "acct_id" > GREATEST(:B2,:B2) AND "acct_id" <= LEAST(:B1,:B1)

由此得到一条通用规则:复合索引中,第一个出现范围条件(>、<、BETWEEN、LIKE 前缀)的列之后的列,都只能做 filter;等值条件(=)不破坏排序,可以一路 access 下去。 这就是为什么 (acct_id, trans_time)(等值在前、范围在后)两列都能 access,而 (trans_time, cust_id, acct_id) 只有第一列能。本例更亏的是中间还卡着一个本语句根本用不上的 cust_id,排序离 acct_id 更远——结果就是每个分片都要把当天切片扫一遍,再逐条过滤。

第二幕:收完统计信息,计划真的换了

先收了账户主表和余额表的统计信息。按 no_invalidate 默认的 AUTO_INVALIDATE 机制,依赖游标会在 0~5 小时内滚动失效——这次连 PURGE 都没用,一小时后的快照直接给出了答案:

快照时间 plan_hash_value execs 每次耗时(s) CPU s/次 逻辑读/次
2026-10-06 20:00 2375852073 12 299.62 272.88 32,162,482
2026-10-06 21:00 554147421 22 17.85 2.37 28,212

20:00 的快照里,坏计划还在跑(299.62 秒/次、单次 3,216 万逻辑读);21:00 的快照里,计划已经切到 554147421——每次 17.85 秒,逻辑读 2.8 万次,降了三个数量级。

机理值得说透:坏计划之所以被"估"得便宜,是因为外层账户/余额侧被估成 1 行(VIEW PUSHED PREDICATE 只按重跑一次计费)。这两张表的基数修正后,CBO 第一次算出了"4,997 次重跑"的真实代价,坏计划自然出局。这正是收统计最理想的效果:零 DDL,让优化器自己改对主意。

一个遗留项:排查中发现明细表当期分区从未收集统计信息——PART2026_09 起的分区 num_rows 全为空,当月分区已 STALE=YES。这是坏计划反复被生成的"土壤"(优化器曾多次从失真的基数里把它"估"成最便宜),根治动作见后文"处置"一节。

第三幕:把四版计划挖出来,用"每次执行"重新算账

从 AWR 把四版计划的结构挖出来对比:

plan_hash_value 顶层 Cost 余额表访问路径 聚合方式
2375852073 13 INDEX SKIP SCAN 同一个 LOCAL 三列索引 VIEW PUSHED PREDICATE(逐行)
1472819723 12,968 INDEX RANGE SCAN 同一个 LOCAL 三列索引 一次 HASH JOIN
4034499000 299,901 单列 GLOBAL HASH 索引,71 行回表 Cost 81 一次 HASH JOIN

如果只看 Cost,答案显而易见:钉 13 那版。但先把归一化做了再说。第一步校验可比性——四版计划的 rows_per_exec = 4978.7 / 4976.8 / 4997.4 / 4925.9,极差仅 1.5%,说明各计划执行时分片规模一致,可以直接横比。

然后是本文最核心的一张表(按每次执行耗时排序):

plan_hash_value 每次(s) CPU s/次 逻辑读/次 物理读/次
4034499000 14.52 8.32 201,582 1,439
554147421 17.85 2.37 28,212 3,351
1472819723 30.52 2.66 45,763 5,191
2375852073 349.15 320.70 28,562,045 5,132

成本模型在两个方向上全部反向:

  • Cost 13 的计划,每次 349 秒,占 AWR 总耗时 86%;
  • Cost 299,901 的计划,每次只有 14.5 秒,反而是最快的——它的 20 万次逻辑读几乎全被 buffer cache 吸收(物理读仅 1,439 次,命中率 99.3%)。

顺带一个副产物:rows_per_exec 全部约等于 5000 而不是 0,说明这条 INSERT 确实在插数据,"绑定变量传 NULL 导致静默插 0 行"的风险可以排除。

机理:Cost 13 为什么跑了 349 秒

把 2375852073 的计划骨架摆出来,账就清楚了:

id 3  NESTED LOOPS OUTER          E-Rows = 1   Cost 13
id 5    余额表 A(驱动侧)          E-Rows = 1   Cost  6   ← 被估成 1 行
id 10   VIEW PUSHED PREDICATE      E-Rows = 1   Cost  5   ← 聚合被推进循环
id 15     INDEX RANGE SCAN IDX_TXN_TIME_1       Cost  4

VIEW PUSHED PREDICATE 把聚合子查询变成了参数化子查询:外层每出 1 行,整个"当天切片扫描 + 聚合"就重跑一次。

CBO 的账本:外层估 1 行,内层只算 1 次,1 × 5 + 8 ≈ 13。

实际的账本:外层实际约 4,997 行/分片,内层被重跑 4,997 次;每次重跑都沿 IDX_TXN_TIME_1 扫当天切片——第三列 acct_id 只能 filter 不能 access——单次约 5,737 次逻辑读,合计 2,856 万 LIO。而物理读只有 5,132 次:这些逻辑读几乎全部命中 buffer cache,不等磁盘,全在烧 CPU(CPU 320.7s / 349.2s = 92%)。

一句话总结机理:嵌套循环的 Cost = 外层基数 × 内层单次成本。基数被估错多少倍,Cost 就错多少倍。 而"估 1 行"的来源,正是第二幕的遗留项——明细表热分区无统计信息,加上 INDEX SKIP SCAN 天然的低估倾向。

这也回答了一个经典疑问:Cost (%CPU)=100 只说明成本构成以 CPU 为主,与绝对快慢毫无关系。

处置:钉住最优计划,补统计根治

第一步(零 DDL、零窗口、秒级可回退):用 SQL Plan Baseline 把实测最优的计划钉住,防止计划再次回漂。

第二幕里收统计后优化器自动选中的 554147421,逻辑读最低(2.8 万/次)、CPU 最省(2.37 s/次),比靠 99.3% 缓存命中率撑着的 4034499000(20 万逻辑读/次)更抗压——钉它:

-- 目标计划此刻就在共享池,直接从游标缓存抓成基线 DECLARE n PLS_INTEGER; BEGIN n := DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE( sql_id => '<SQL_ID>', plan_hash_value => 554147421); DBMS_OUTPUT.PUT_LINE('loaded plans = ' || n); END; /

⚠️ 两个必须注意的关卡:

  • LOAD_PLANS_FROM_CURSOR_CACHE 只读本实例游标缓存,不读 AWR。指定的 plan_hash_value 不在池中时不报错、只返回 0——此时必须改走 AWR → SQL Tuning Set → LOAD_PLANS_FROM_SQLSET 路线。
  • 如果走 STS 路线,它会把 AWR 里的全部计划一起载入,包括 Cost 12,968 的那版——基线内部是按 Cost 比较的,不禁用它,基线等于白建。必须"留一个、禁其余":目标计划 ENABLED=YES + ACCEPTED=YES,其余 ENABLED=NO + ACCEPTED=NO 双保险(防止被 evolve 自动接受)。

载入后清游标(DBMS_SHARED_POOL.PURGE('address,hash','C'))让它立即生效,验证 v$sql 的 plan_hash_value 与 DISPLAY_CURSOR 输出 Note 段的 SQL plan baseline ... used。

第二步(根治):补明细表热分区统计,让基数不再塌陷(第二幕的遗留项)。

EXEC DBMS_STATS.GATHER_TABLE_STATS(ownname=>'BIZ', tabname=>'ACCT_TRANS_DTL', partname=>'PART2026_09', granularity=>'PARTITION', cascade=>TRUE, no_invalidate=>FALSE);

基数修对之后,CBO 自己就能算出"4997 × 单次成本"的真实代价,坏计划根本不会被生成——这比钉基线更治本。

第三步(看护):选定的计划也要留个心眼——本例各版计划的快慢对缓存条件很敏感(4034499000 就全靠 99.3% 命中率撑着,缓存压力上来会向 30 秒方向漂移)。设一条告警红线:cpu_s_per_exec > 50 或 gets_per_exec > 1,000,000 就回来看计划结构,八成是坏计划回来了。

写在最后

这次排查最有价值的不是某个具体参数,而是两个认知修正:

Cost 是模型输出,不是运行结果。 它的每一个乘数都是估算基数——基数因统计缺失而塌陷时,Cost 可以错出几个数量级,而且两个方向都可能反向。Cost 能解释"优化器为什么选错",不能预测"哪个跑得更快"。

排错顺序比优化技巧重要。 先用 AWR 算清楚"谁在吃时间"(占比排序),再用归一化数据横比候选计划,最后才是索引与改写。顺序反了,就会像我们前几轮那样,在只值 6 秒的节点上精算,而放跑了 43 小时的黑洞。

注:文中表名、索引名、schema、实例名与 sql_id 均已脱敏,plan_hash_value 为文本哈希无业务含义,予以保留;所有数字均为 AWR 实测。


专注于 Oracle / MySQL / PostgreSQL / 达梦等主流数据库的运维实战与架构分享,欢迎关注,一起做靠谱的数据库人。

欢迎赞赏支持或留言指正
image.png

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论