Oracle SQL 执行计划——成本模型、基数估算与 Hints 实战干预

执行计划是 Oracle 优化的核心,但也是最让人头疼的东西。我见过一个客户,一条核心 SQL 跑了两年都是 2 秒,某天突然变成 2 分钟,AWR 显示执行计划变了,但没人知道为什么变。DBA 花了三天时间,最后发现是统计信息过期,优化器估算的行数差了 1000 倍。这篇文章把执行计划的成本模型、基数估算原理、以及 Hints 的实战用法拆清楚,不讲虚的,直接上案例。

1 成本模型不是算钱,是算 I/O + CPU + 网络

Oracle 优化器的成本模型(Cost-Based Optimizer, CBO)基于三个资源维度:I/O 成本:读一个数据块的成本(单块读 vs 多块读)。单块读(db file sequential read)成本默认是 1,多块读(db file scattered read)成本默认是 0.278。这些默认值来自 Oracle 内部假设,可以通过 DBMS_XPLAN 的 +COST 细节看到。CPU 成本:处理一行数据的 CPU 周期数。比如过滤一行数据需要 1000 个 CPU 周期,排序一行需要 5000 个周期。Oracle 把 CPU 周期换算成"成本单位",跟 I/O 成本统一度量。网络成本:分布式查询时,数据传输的成本。本地查询网络成本为 0,DB Link 查询有额外成本。优化器的目标是最小化总成本,但"最小成本"不等于"最快执行"——因为成本是估算值,估算错了,计划就错了。

成本估算的关键输入是统计信息:表的总行数(NUM_ROWS)、块数(BLOCKS)、平均行长度(AVG_ROW_LEN);列的 distinct 值数(NUM_DISTINCT)、最小最大值(LOW_VALUE/HIGH_VALUE)、直方图;索引的层级(BLEVEL)、叶子块数(LEAF_BLOCKS)、聚簇因子(CLUSTERING_FACTOR)。如果统计信息不准,成本估算就会偏差。比如一个表实际有 1000 万行,但统计信息显示 10 万行,优化器可能认为全表扫描比索引扫描便宜,选择 FTS,结果实际跑了 10 分钟。这就是执行计划跳变的常见原因。

2 基数估算:CBO 的命门,也是最大的坑

基数(Cardinality)是优化器估算的"某个操作返回多少行"。比如 WHERE status = 'PAID',优化器估算 orders 表有 10% 的行满足这个条件,基数 = 总行数 × 10%。基数估算直接决定连接顺序、连接方式、访问路径。如果基数估错了,后续所有决策都可能错。比如优化器估算 JOIN 结果只有 100 行,选择了 NESTED LOOPS,但实际 JOIN 结果是 100 万行,NESTED LOOPS 就要循环 100 万次,性能暴跌。

基数估算的几种方法:等值谓词(=):基数 = 总行数 / NUM_DISTINCT。如果 status 有 5 个 distinct 值,总行数 100 万,基数 = 20 万。但如果数据分布不均匀(比如 80% 是 PAID,20% 是其他),没有直方图时优化器还是按 20 万估算,实际可能是 80 万,偏差 4 倍。范围谓词(BETWEEN、>、<):基数 = 总行数 × 选择率。选择率 = (HIGH_VALUE - 谓词值) / (HIGH_VALUE - LOW_VALUE)。这个公式假设数据均匀分布,如果数据是偏态的(比如时间戳集中在最近),估算会严重偏差。LIKE 谓词:选择率默认是 5%(LIKE 'ABC%')或 0.25%(LIKE '%ABC%'),完全不管实际分布。这也是很多模糊查询性能差的原因——优化器根本不知道你要查多少行。

我们那个从 2 秒变 2 分钟的案例,根因就是基数估算错误:SQL 是 SELECT * FROM orders WHERE create_time > SYSDATE - 30 AND status = 'PAID'。create_time 的统计信息是 3 个月前收集的,HIGH_VALUE 还是 3 个月前的时间。优化器按公式算:(HIGH_VALUE - SYSDATE-30) / (HIGH_VALUE - LOW_VALUE),结果选择率只有 2%,估算基数 2 万。但实际最近 30 天的数据占了 40%,实际基数 400 万。优化器选了 NESTED LOOPS + INDEX RANGE SCAN,实际应该 HASH JOIN + FULL TABLE SCAN。重新收集统计信息后,执行计划恢复,SQL 回到 2 秒。这个案例说明:统计信息不是"有了就行",是要"准"才行。

3 实验:用 DBMS_XPLAN 解剖执行计划

实验环境:Oracle 19c,测试表 orders(1000 万行)、customers(100 万行)。构造一个典型的 JOIN 查询,观察不同统计信息下的执行计划差异。

-- 1. 收集准确统计信息
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'ORDERS', METHOD_OPT => 'FOR ALL COLUMNS SIZE 254');
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'CUSTOMERS', METHOD_OPT => 'FOR ALL COLUMNS SIZE 254');

-- 2. 查看执行计划(准确统计信息)
EXPLAIN PLAN FOR
SELECT o.*, c.cust_name
FROM   orders o JOIN customers c ON o.cust_id = c.cust_id
WHERE  o.status = 'PAID'
  AND  o.create_time > SYSDATE - 30;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(FORMAT => 'ALLSTATS LAST +COST +BYTES +PREDICATE'));
-- 预期:HASH JOIN + FULL TABLE SCAN (orders) + INDEX RANGE SCAN (customers_pk)
-- Cost: 45000, Cardinality: 4000000

-- 3. 篡改统计信息,模拟过期场景
EXEC DBMS_STATS.SET_TABLE_STATS('APP', 'ORDERS', NUMROWS => 100000);
EXEC DBMS_STATS.SET_COLUMN_STATS('APP', 'ORDERS', 'CREATE_TIME',
    HIGH_VALUE => UTL_RAW.CAST_TO_RAW(TO_CHAR(SYSDATE - 90, 'YYYY-MM-DD')),
    NUM_DISTINCT => 100);

-- 4. 再次查看执行计划(错误统计信息)
EXPLAIN PLAN FOR
SELECT o.*, c.cust_name
FROM   orders o JOIN customers c ON o.cust_id = c.cust_id
WHERE  o.status = 'PAID'
  AND  o.create_time > SYSDATE - 30;

SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(FORMAT => 'ALLSTATS LAST +COST +BYTES +PREDICATE'));
-- 预期:NESTED LOOPS + INDEX RANGE SCAN (orders_status_idx) + TABLE ACCESS BY INDEX ROWID
-- Cost: 1200(低估!), Cardinality: 20000(严重低估!)

对比两个执行计划:准确统计信息时,优化器知道 orders 有 400 万行满足条件,HASH JOIN 的成本 45000,但实际执行 2 秒(因为 FTS 走多块读,效率高)。错误统计信息时,优化器以为只有 2 万行,选了 NESTED LOOPS,成本估算 1200,但实际执行 120 秒——因为回表次数 400 万次,每次单块读,磁盘随机 I/O 饱和。这就是"低成本计划实际很慢"的经典案例。DBA 如果只盯着 COST 看,就会被骗。必须结合实际基数、I/O 模式、数据分布来判断。

4 Hints 实战:不是炫技,是救命

很多 DBA 鄙视 Hints,认为"用 Hints 说明优化器不行"。这话在理想世界是对的,但在生产环境,当统计信息暂时无法修复(比如大表收集统计信息要 4 小时,业务等不起),Hints 是唯一的救命稻草。我们常用的 Hints 分三类:访问路径类:/*+ FULL(table) */、/*+ INDEX(table index) */、/*+ NO_INDEX(table) */。连接方式类:/*+ USE_HASH(t1 t2) */、/*+ USE_NL(t1 t2) */、/*+ USE_MERGE(t1 t2) */。连接顺序类:/*+ LEADING(t1 t2 t3) */、/*+ ORDERED */。还有并行类:/*+ PARALLEL(table 4) */。

Hints 的使用原则是:第一,只在核心 SQL 上用,别在 ad-hoc 查询上滥用。核心 SQL 是指每天跑几千次的报表、接口查询。第二,用 SQL Plan Baseline 固化 Hints,而不是改源代码。DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE 可以把带 Hints 的执行计划加载为 Baseline,即使应用代码不改,优化器也会优先用 Baseline 计划。第三,定期验证 Hints 是否还适用。数据分布变了,原来的 FULL SCAN 可能又变回 INDEX SCAN 更好,Hints 如果不及时清理,会从救命稻草变成绊脚石。我们每季度做一次 Hints 健康检查,用 DBMS_XPLAN 对比带 Hints 和不带 Hints 的计划,如果优化器自动生成的计划更好,就删除 Baseline。

-- 用 Hints 强制走 HASH JOIN
SELECT /*+ USE_HASH(o c) FULL(o) */ o.*, c.cust_name
FROM   orders o JOIN customers c ON o.cust_id = c.cust_id
WHERE  o.status = 'PAID';

-- 固化到 SQL Plan Baseline
DECLARE
    v_sql_id VARCHAR2(20) := 'a1b2c3d4e5f6';
BEGIN
    DBMS_SPM.LOAD_PLANS_FROM_CURSOR_CACHE(
        sql_id => v_sql_id,
        plan_hash_value => 1234567890,
        fixed => 'YES'
    );
END;
/

-- 查看 Baseline
SELECT sql_handle, plan_name, enabled, fixed, origin
FROM   dba_sql_plan_baselines
WHERE  sql_text LIKE '%orders%';

5 总结:执行计划调优的三板斧

执行计划调优没有银弹,但有套路:第一板斧,看基数。用 DBMS_XPLAN 的 +PREDICATE 格式,对比 E-Rows(估算行数)和 A-Rows(实际行数,需要 GATHER_PLAN_STATISTICS)。如果 E-Rows 和 A-Rows 差 10 倍以上,统计信息一定有问题,先收集统计信息。第二板斧,看访问路径。FTS 还是 INDEX?单块读还是多块读?如果 SQL 返回行数 > 表总行数的 5%,FTS 通常比 INDEX 快;如果 < 1%,INDEX 更好。中间地带(1%-5%)看 Clustering Factor。第三板斧,看连接方式。NESTED LOOPS 适合小驱动表 + 大被驱动表;HASH JOIN 适合两个大表;MERGE JOIN 适合有序输入。如果连接顺序错了,用 LEADING Hints 纠正。三板斧砍完,80% 的执行计划问题都能定位。剩下 20% 是奇葩场景(比如绑定变量窥视、远程表、嵌套视图),需要个案分析。最后送一句话:执行计划是优化器的"猜测",统计信息是它的"眼镜",Hints 是它的"拐杖"。眼镜不准,猜测就错;拐杖用久了,腿就废了。定期验光(收集统计信息),适度锻炼(优化 SQL),别一上来就拄拐。


请使用浏览器的分享功能分享到微信等