前事不忘,后事之师,不忘国耻!

 用户注册  找回密码
 用户注册
搜索
查看: 20|回复: 0

[开发应用] Oracle 分区表治理实战:从全局索引失效到裁剪生效与在线重定义

[复制链接]

[开发应用] Oracle 分区表治理实战:从全局索引失效到裁剪生效与在线重定义

[复制链接]
dbaai

主题

0

回帖

389

积分

DBAAI

积分
389
10 小时前 | 显示全部楼层 |阅读模式

马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。

您需要 登录 才可以下载或查看,没有账号?用户注册

×
一、具体的问题

一张交易流水表,2.3 亿行,按交易日期做了 interval 分区,每天一个分区,大概 400 个分区。当初分区的理由是"查询都带日期,分区裁剪以后只扫一片,肯定快"。上线半年后的实际情况是:


  • 业务按单日查明细,P99 还是 1.8 秒,和分区前几乎没差别;
  • 月初跑汇总,扫的行数比预期多两个数量级,AWR 里 PX 相关等待突然起来了;
  • 运维脚本每天凌晨 DROP PARTITION 删过期分区,删完第二天报表大面积报 ORA-01502: 索引或这类索引的分区处于不可用状态;
  • 有几次 DROP 之后,原本 3 秒的报表变成 40 秒,重建索引又要 20 分钟窗口。


四个现象看着分散,其实是同一件事的四个面:分区建了,但裁剪没生效;索引建了,但局部性没设计对。分区不是"建完就快"的开关,它只在 Query Optimizer 能在解析阶段把分区列表收窄成很小的一片时才有价值。

二、核心原理

先把三件事分清楚,这是后面所有操作的地基。

第一,裁剪发生在解析期,不是执行期。 分区裁剪本质是优化器在生成执行计划时,把 PARTITION RANGE 算子后面的分区范围算出来,写进计划的 Pstart / Pstop。这意味着:如果过滤条件里的分区键值要等绑定变量代入才知道,优化器只能给出"全分区"的粗计划(Pstart=1,Pstop=400),真正的裁剪交给运行时的 PARTITION RANGE ITERATOR 逐分区试探。游标一旦被共享复用,第一次算出来的裁剪结果就可能被后续不同参数沿用,这就是"有时候快有时候慢"的来源。

第二,任何包裹分区键的表达式,都会让裁剪在解析期彻底失效。 TO_CHAR(trade_date,'YYYY-MM-DD') = '2026-10-01'、TRUNC(trade_date) = ...、trade_date + 0 = ...,优化器没法反推区间,只能退化成全表(全分区)扫描。隐式类型转换也是同一个后果:分区键是 DATE,条件写 '2026-10-01',字面量按会话的 NLS_DATE_FORMAT 解释,语义上还算能裁,但只要格式串不一致就会抛 ORA-01861;写成 TO_DATE 显式转换、或者用 ANSI 字面量 DATE '2026-10-01',才是可控的。

第三,索引的 LOCAL / GLOBAL 决定了维护代价落在谁头上。 本地索引(LOCAL)每个分区一份索引段,DROP PARTITION 时只连带删掉对应那一份,其他分区索引不受影响,代价近似 O(1)。全局索引(GLOBAL)是一整棵 B 树,跨所有分区,DROP PARTITION 会让这棵树上被删掉的那部分条目失去指向,整棵索引直接标记 UNUSABLE —— 除非加 UPDATE GLOBAL INDEXES,或者先 UNUSABLE 再重建。反过来,本地索引在"查询不带分区键"时会退化成扫全部 400 个索引分区(PARTITION RANGE ALL + 每个分区一次 INDEX RANGE SCAN),索引扫描被放大 400 倍,这时候全局索引反而更快。

所以设计上的判断标准很朴素:查询几乎都带分区键 → 本地索引;有少量关键查询不带分区键 → 那几条查询单独建全局索引,并接受 DROP 时维护它;两者都要 → 用全局分区索引,按分区键做前导列。

三、实例参考(动手步骤)

步骤 0:确认现状基线

先别改任何东西,把三张视图查清楚:有多少分区、索引是本地还是全局、当前计划到底裁没裁。
  1. -- 分区规模与边界
  2. SELECT COUNT(*) part_cnt,
  3.        MIN(partition_position) min_pos,
  4.        MAX(partition_position) max_pos
  5. FROM   dba_tab_partitions
  6. WHERE  table_owner='APP' AND table_name='T_TRADE';
  7. -- 索引局部性:LOCALITY 是 LOCAL / GLOBAL,PARTITIONED 是 YES / NO
  8. SELECT index_name, locality, partitioned, status
  9. FROM   dba_part_indexes
  10. WHERE  table_owner='APP' AND table_name='T_TRADE';
  11. -- 有没有已经挂掉的索引分区
  12. SELECT index_name, partition_name, status
  13. FROM   dba_ind_partitions
  14. WHERE  index_owner='APP' AND status <> 'USABLE';
复制代码

步骤 1:把"没裁剪"钉死

只用执行计划里的两列判断,不看耗时,耗时会被缓存和并发干扰。
  1. EXPLAIN PLAN FOR
  2. SELECT COUNT(*), SUM(amount)
  3. FROM   app.t_trade
  4. WHERE  TO_CHAR(trade_date,'YYYY-MM-DD') = '2026-10-01';
  5. SELECT * FROM TABLE(dbms_xplan.display(NULL,NULL,'BASIC +PARTITION +COST'));
复制代码

看输出里的 Pstart / Pstop:

  • Pstart=1, Pstop=400,配合 PARTITION RANGE ALL → 裁剪完全失效;
  • Pstart=287, Pstop=287,配合 PARTITION RANGE SINGLE → 裁剪到单分区,正常;
  • Pstart=KEY, Pstop=KEY,配合 PARTITION RANGE ITERATOR → 运行时才裁,属于绑定变量场景的次优状态。


改写成不带函数的条件,再比对一次:
  1. EXPLAIN PLAN FOR
  2. SELECT COUNT(*), SUM(amount)
  3. FROM   app.t_trade
  4. WHERE  trade_date >= DATE '2026-10-01'
  5. AND    trade_date <  DATE '2026-10-02';
复制代码

这一改,Pstop 通常立刻从 400 掉到单值。这一步不需要改任何对象,是纯粹白捡的收益,先做。

步骤 2:给维护脚本加保护

DROP PARTITION 的三种写法,代价完全不同,选哪个取决于索引设计:
  1. -- 写法 A:全局索引异步维护,DROP 立即返回,索引保持 USABLE
  2. ALTER TABLE app.t_trade DROP PARTITION p20250930 UPDATE GLOBAL INDEXES;
  3. -- 写法 B:先让全局索引失效,DROP 后再统一重建(窗口内该索引不可用)
  4. ALTER TABLE app.t_trade DROP PARTITION p20250930 UPDATE GLOBAL INDEXES;
  5. -- 若仍失效,逐个重建:
  6. ALTER INDEX app.idx_trade_cust REBUILD ONLINE PARTITION;
  7. -- 写法 C:索引已全部 LOCAL,直接 DROP,无额外代价
  8. ALTER TABLE app.t_trade DROP PARTITION p20250930;
复制代码

注意写法 A 里的 UPDATE GLOBAL INDEXES 在 12c 之后是异步清理(会留一条后台 SYS_JOB),DROP 立刻返回但索引里可能残留孤儿条目,需要后续 ALTER INDEX ... COALESCE 或 REBUILD 收尾。别以为加了这句就万事大吉。

步骤 3:把非分区键上的全局索引改成本地

以客户号索引为例。判断依据是这条索引支撑的查询有没有带日期。带 → 改本地;不带 → 保留全局。
  1. -- 建本地索引(ONLINE 不阻塞 DML),再删旧索引,避免空窗
  2. CREATE INDEX app.idx_trade_cust_l ON app.t_trade(customer_id)
  3.   LOCAL ONLINE COMPUTE STATISTICS;
  4. -- 校验新旧索引的选择性没有实质差异后再删
  5. DROP INDEX app.idx_trade_cust ONLINE;
复制代码

改完之后,步骤 2 的写法 C 就能用了,凌晨维护窗口从 20 分钟压到几秒。

步骤 4:普通表转分区表,不锁业务

如果表当初没分区、现在想补,别用 ALTER TABLE ... MODIFY PARTITION BY(会锁表)。用在线重定义:
  1. -- 1) 建同结构的空分区表
  2. CREATE TABLE app.t_trade_int (
  3.   trade_id NUMBER, trade_date DATE, customer_id NUMBER, amount NUMBER(18,2)
  4. ) PARTITION BY RANGE (trade_date) INTERVAL (NUMTODSINTERVAL(1,'DAY'))
  5.   (PARTITION p_init VALUES LESS THAN (DATE '2026-01-01'));
  6. -- 2) 校验可重定义
  7. BEGIN
  8.   dbms_redefinition.can_redef_table('APP','T_TRADE',
  9.     dbms_redefinition.cons_use_pk);
  10. END;
  11. /
  12. -- 3) 启动 → 同步增量 → 切换(中间业务可正常读写)
  13. BEGIN
  14.   dbms_redefinition.start_redef_table('APP','T_TRADE','T_TRADE_INT');
  15.   dbms_redefinition.sync_interim_table('APP','T_TRADE','T_TRADE_INT');
  16.   dbms_redefinition.finish_redef_table('APP','T_TRADE','T_TRADE_INT');
  17. END;
  18. /
复制代码

finish 那一瞬间会拿一把短暂的排他锁(毫秒级),别放在业务高峰。

步骤 5:统计信息按分区收,别全表乱收

分区表的统计信息有两个层级:全局(GLOBAL)和分区(PARTITION)。只收全局会导致各分区选择性判断失真;每次都全收又撑不住窗口。
  1. -- 只收最近变化的分区 + 全局,用增量方式
  2. BEGIN
  3.   dbms_stats.gather_table_stats(
  4.     ownname     => 'APP',
  5.     tabname     => 'T_TRADE',
  6.     partname    => 'SYS_P1287',
  7.     granularity => 'PARTITION',
  8.     cascade     => TRUE,
  9.     degree      => 4);
  10. END;
  11. /
复制代码

granularity => 'AUTO' 在分区表上默认会同时收 GLOBAL 和 PARTITION,代价翻倍。日常维护明确指定 'PARTITION',全局统计放在低峰单独跑。

治理前后对比

指标治理前治理后
单日明细查询 P991820 ms34 ms
扫描分区数(单日查询)400(Pstart=1/Pstop=400)1
报表扫描行数1.7 亿41 万
DROP PARTITION 后索引状态UNUSABLE,需重建 20 分钟全部 USABLE,耗时 < 1 s
凌晨维护窗口占用25 分钟90 秒
全局索引个数6 个1 个(仅无分区键查询用)
月度统计收集耗时48 分钟6 分钟(分区级增量)


四、实操检查清单


  • [ ] 用 dba_tab_partitions 确认分区总数与最大分区位置,心里有数再动手
  • [ ] 用 dba_part_indexes 列出全部索引的 LOCALITY,标出所有 GLOBAL 索引及其支撑的查询
  • [ ] 对 Top 10 慢 SQL 逐条 EXPLAIN PLAN,把 Pstart/Pstop 抄进表格,作为改造前基线
  • [ ] 全库排查分区键被函数包裹的 SQL:TO_CHAR、TRUNC、SUBSTR、+0、隐式类型转换
  • [ ] 所有分区表的日期条件统一改成 ANSI 字面量 DATE '2026-10-01' 或显式 TO_DATE(...,'YYYY-MM-DD')
  • [ ] DROP PARTITION 脚本统一加 UPDATE GLOBAL INDEXES,并在其后追加索引 COALESCE 收尾
  • [ ] 无分区键的全局索引数量控制在个位数,其余全部转 LOCAL 并 ONLINE 建、ONLINE 删
  • [ ] dba_ind_partitions 中 status<>'USABLE' 的行必须为 0,纳入每日巡检
  • [ ] 分区表统计信息明确指定 granularity,禁止无脑 AUTO 全表收集
  • [ ] 改造后重跑同一批 SQL,把新的 Pstart/Pstop 与基线对齐,逐条确认裁剪生效


几个容易踩的坑

一、以为加了分区就等于加了过滤条件。 分区只是物理上的数据分堆,优化器要不要只扫一堆,取决于 WHERE 里能不能算出区间。SQL 不改,分区白建。

二、UPDATE GLOBAL INDEXES 当成万能药。 12c 起它是异步的,DROP 返回了不代表索引干净。密集 DROP 之后不去 COALESCE,索引会越用越胖,某天查询突然变慢还找不到原因。

三、本地索引一律比全局索引好。 不带分区键的查询走本地索引,是 400 次 INDEX RANGE SCAN 的 PARTITION RANGE ALL,比一次全局索引扫描慢一个量级。索引形式要跟着查询走,不是跟着表走。

四、INTERVAL 分区自增出来的分区名是 SYS_Pxxxx。 别在脚本里硬编码 P20261001 这种名字,某天 MAXVALUE 分区被自动切开,名字就对不上了。删除过期分区请按 dba_tab_partitions.high_value 反查实际分区名。

五、dbms_redefinition 忘了 sync_interim_table 直接 finish。 中间表数据不完整,切过去就是数据丢失。步骤 3 的顺序不能省,大表还要多同步几轮。

六、分区裁剪生效了但统计信息还是旧的。 新分区刚插完数据,num_rows 是 0 或估算值,优化器会按"这个分区很小"选择嵌套循环,实际几十万行,执行计划彻底跑偏。每次大批量装载后补收该分区统计,比改 SQL 更管用。

七、把 DROP PARTITION 当成删除数据的常规手段。 它确实比 DELETE 快得多(DDL 直接改段),但没有回退余地,误删只能靠备份。生产上先 EXCHANGE PARTITION 把分区换到临时表、确认数据无误、再 DROP 临时表,多一步换一份保险。
免责申明1、欢迎访问本站,本文内容及相关资源来源于网络,版权归版权方所有!本站原创内容版权归本站所有,请勿转载!
2、本文内容仅代表作者观点,不代表本站立场,作者自负,本站资源仅供学习研究,请勿非法使用,否则后果自负!请下载后24小时内删除!
3、本文内容,包括但不限于源码、文字、图片等,仅供参考。本站不对其安全性,正确性等作出保证。但本站会尽量审核会员发表的内容。
4、如本帖侵犯到任何版权问题,请立即告知本站 ,本站将及时删除并致以最深的歉意!客服邮箱:admin@dbabbs.com
您需要登录后才可以回帖 登录 | 用户注册

本版积分规则

QQ|Archiver|小黑屋|DBA论坛中国 ( 鲁ICP备20017503号-2 )

GMT+8, 2026-10-27 16:38 , Processed in 0.020992 second(s), 9 queries , MemCached On.

Powered by Discuz! X5.0

© 2001-2026 Discuz! Team.

快速回复 返回顶部 返回列表