Oracle 统计信息——DBMS_STATS 深度定制、直方图陷阱与动态采样

统计信息是 CBO 的"眼镜",眼镜不准,优化器就瞎。我见过太多执行计划跳变的案例,根因都是统计信息——不是没收集,而是收集的方式不对,或者收集的时机不对。比如一个客户,每天凌晨收集全库统计信息,但白天跑了一批大 ETL,表从 100 万行涨到 5000 万行,统计信息还是凌晨的 100 万行,优化器以为表很小,选了 NESTED LOOPS,结果查询跑了 2 小时。这篇文章把 DBMS_STATS 的参数定制、直方图的误区、以及动态采样的适用场景拆清楚。

1 统计信息不是越多越好,是越准越好

很多 DBA 以为统计信息就是把所有表的 NUM_ROWS、BLOCKS、列的 distinct 值都收集一遍。实际上,统计信息的"准"有三个维度:第一,表级统计信息准:NUM_ROWS 和 BLOCKS 要跟实际一致。如果表刚加载了 1000 万行新数据,但统计信息还是 10 万行,优化器会严重低估表大小,选择错误的连接顺序。第二,列级统计信息准:NUM_DISTINCT、密度(Density)、最小最大值要反映实际分布。对于倾斜列(比如 80% 的数据是某个值),必须有直方图,否则优化器按均匀分布估算,偏差巨大。第三,索引统计信息准:BLEVEL、LEAF_BLOCKS、CLUSTERING_FACTOR 要反映索引实际状态。如果索引刚重建过,CLUSTERING_FACTOR 可能变好,但统计信息没更新,优化器还以为索引很差,不走索引。

DBMS_STATS.GATHER_TABLE_STATS 的参数很多,但核心参数就几个:METHOD_OPT:控制列统计信息的收集方式。默认是 'FOR ALL COLUMNS SIZE AUTO',Oracle 自动判断哪些列需要直方图。但 AUTO 的判断依据是列的查询历史和数据分布变化,如果某个列从来没被 WHERE 过滤过,Oracle 就不会给它建直方图,即使这个列的数据高度倾斜。所以对于已知倾斜的列,应该手动指定 SIZE 254(最大桶数)。ESTIMATE_PERCENT:采样比例。默认是 DBMS_STATS.AUTO_SAMPLE_SIZE,Oracle 自动决定采样比例。对于大表(>10 亿行),AUTO 可能只采样 0.1%,如果数据分布不均匀,采样误差会很大。对于关键表,建议设 ESTIMATE_PERCENT => 100(全量收集),虽然慢,但准。GRANULARITY:分区表的收集粒度。默认是 AUTO,收集全局统计 + 分区统计。如果分区很多(>1000),收集所有分区统计很慢,可以只收集全局和最近变更的分区(GRANULARITY => 'GLOBAL AND PARTITION' + INCREMENTAL)。

2 直方图:救命的工具,也是挖坑的铲子

直方图(Histogram)用来描述列的数据分布,解决优化器"均匀分布假设"的问题。Oracle 支持两种直方图:频率直方图(Frequency Histogram):每个 distinct 值一个桶,适合 distinct 值少(<254)的列。高度平衡直方图(Height-Balanced Histogram):每个桶的行数相等,适合 distinct 值多(>254)的列。12c 以后还引入了混合直方图(Hybrid Histogram)和顶级频率直方图(Top-Frequency Histogram),处理更复杂的分布。

直方图的陷阱在于:第一,绑定变量窥视(Bind Peeking)+ 直方图 = 执行计划跳变。如果列有直方图,第一次执行时优化器 peek 绑定变量,根据 peek 的值选择执行计划。如果第一次 peek 的是高频值(比如 status='DONE',占 80%),优化器选 FTS;后续执行传入低频值(status='PENDING',占 1%),还是走 FTS,性能暴跌。这就是直方图和绑定变量的"死亡组合"。第二,直方图收集时机不对。如果数据在白天变化很大(比如 ETL 加载),但直方图是凌晨收集的,白天查询用的就是过时的分布信息。第三,直方图桶数不够。SIZE 254 是最大桶数,但如果列有 1000 个 distinct 值,254 个桶无法精确描述分布,某些值会被合并到同一个桶,优化器估算偏差。我们给一个客户做调优时,发现某个列有 500 个 distinct 值,但直方图只有 254 桶,导致中间 200 多个值的选择率被平均化,查询这些值时执行计划完全错误。最后改用分区表 + 本地索引,绕过直方图限制。

3 动态采样:统计信息的"急救包"

动态采样(Dynamic Sampling)是优化器在解析 SQL 时,临时采样表的少量数据块,估算基数和选择率。它发生在硬解析阶段,不是预先收集的统计信息。动态采样的级别从 0 到 11:0:禁用;1:对没有统计信息的表,采样 32 块;2:对所有表,采样 64 块(默认);4:采样所有块(对表做全扫描);11:自动决定采样级别和块数。动态采样的好处是"实时"——即使统计信息过期,优化器也能通过采样获得相对准确的估算。但代价是硬解析时间增加:采样 64 块可能需要 50-200ms,对于 OLTP 的短查询(执行 5ms),这 200ms 的解析开销不可接受。

动态采样的适用场景:第一,新创建的临时表,还没来得及收集统计信息;第二,数据变化极快的表(比如每分钟插入几万行的流水表),静态统计信息永远跟不上;第三,复杂查询涉及多表连接,优化器需要更准确的中间结果集估算。不适用场景:高频短查询的 OLTP 系统,解析开销会吃掉所有性能收益。我们给一个电商客户开了 OPTIMIZER_DYNAMIC_SAMPLING = 4,结果订单查询的硬解析时间从 5ms 涨到 250ms,高峰期 CPU 飙到 90%,因为大量新会话在做硬解析。最后改成级别 2(默认),只对没有统计信息的表采样,问题缓解。另外,12c 以后有自适应动态采样(Adaptive Dynamic Sampling),只在第一次执行时采样,后续重用结果,减少了重复开销。

4 实验:统计信息过期对执行计划的影响

实验环境:Oracle 19c,测试表 inventory,100 万行初始数据。模拟白天 ETL 加载 900 万行新数据,但统计信息还是凌晨的 100 万行。观察优化器的基数估算和执行计划变化。

-- 1. 初始环境:收集统计信息
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'INVENTORY', ESTIMATE_PERCENT => 100);

-- 2. 查看初始统计信息
SELECT num_rows, blocks, avg_row_len FROM dba_tables WHERE table_name = 'INVENTORY';
-- NUM_ROWS: 1000000, BLOCKS: 15000

-- 3. 加载 900 万行新数据(模拟 ETL)
INSERT INTO inventory
SELECT seq_inv.NEXTVAL, 'PROD_'||MOD(ROWNUM,1000), DBMS_RANDOM.VALUE(1,10000), SYSDATE
FROM dual CONNECT BY ROWNUM <= 9000000;
COMMIT;

-- 4. 不收集新统计信息,直接执行查询
EXPLAIN PLAN FOR
SELECT * FROM inventory WHERE prod_id = 'PROD_500';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(FORMAT => 'ALLSTATS LAST +COST +PREDICATE'));
-- 优化器估算基数:1000 行(基于旧的 100 万行 × 0.1% 选择率)
-- 实际基数:90000 行(新的 1000 万行 × 0.1% 选择率,但 prod_id 分布变了)
-- 执行计划:INDEX RANGE SCAN(以为只有 1000 行,走索引)
-- 实际执行:回表 90000 次,逻辑读 180000,时间 45 秒

-- 5. 重新收集统计信息
EXEC DBMS_STATS.GATHER_TABLE_STATS('APP', 'INVENTORY', ESTIMATE_PERCENT => 100);

-- 6. 再次执行查询
EXPLAIN PLAN FOR
SELECT * FROM inventory WHERE prod_id = 'PROD_500';
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(FORMAT => 'ALLSTATS LAST +COST +PREDICATE'));
-- 优化器估算基数:90000 行
-- 执行计划:FULL TABLE SCAN
-- 实际执行:逻辑读 120000,时间 3 秒

实验结果触目惊心:统计信息过期时,优化器估算 1000 行,实际 90000 行,偏差 90 倍。执行计划选了 INDEX RANGE SCAN,回表 90000 次,逻辑读 180000,时间 45 秒。重新收集统计信息后,优化器估算 90000 行,选 FULL TABLE SCAN,逻辑读 120000,时间 3 秒。15 倍的性能差异,根源只是统计信息过期。这个案例让我深刻认识到:对于数据变化快的表,统计信息的收集频率必须匹配数据变化频率。不是"每天收集一次"就够了,而是"数据变化 10% 以上就必须重新收集"。

【踩坑笔记】生产环境建议对核心表启用实时监控(DBMS_STATS.GATHER_TABLE_STATS 的 OPTIONS => 'GATHER STALE'),当表的数据变化超过 10%(STALE_PERCENT)时,自动标记为过时,下次收集时优先处理。另外,对于 ETL 加载的表,在 ETL 结束后立即收集统计信息,而不是等到凌晨统一收集。

5 总结:统计信息管理的 SOP

我们给赢禾科技内部定了一套统计信息管理 SOP:第一,核心表(订单、客户、账户)每天收集一次,且 ESTIMATE_PERCENT = 100,METHOD_OPT = 'FOR ALL COLUMNS SIZE 254',确保准确。第二,ETL 加载的表,在 ETL 作业最后一步加 GATHER_TABLE_STATS,不要等凌晨。第三,分区表启用增量统计(INCREMENTAL = TRUE),只收集变更分区的统计,减少全表扫描开销。第四,每月检查一次直方图的适用性:用 DBMS_STATS.REPORT_COL_USAGE 查看哪些列被频繁用于 WHERE 过滤,确认这些列是否有直方图,直方图桶数是否足够。第五,对于绑定变量 + 倾斜列的组合,考虑用 SQL Plan Baseline 或自适应游标共享(ACS)稳定执行计划,而不是依赖直方图。最后送一句话:统计信息是优化器的"眼镜",眼镜脏了,路都看不清。DBA 的责任不是配眼镜(收集统计信息),而是定期擦眼镜(验证准确性)。别等撞墙了才发现眼镜上有泥。


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