☰
Oracle表与索引碎片治理:从成因判断到收缩重建全攻略
2026/10/5 17:14:50 网站建设 项目流程

干Oracle DBA这些日子,表碎片和索引碎片是我在生产环境里处理频率最高的一类问题。不管是几百万行的业务表,还是几千万行的流水大表,只要经过大量DELETE和UPDATE,段内部就开始出现空洞,查询变慢、空间报表难堪,更麻烦的是后面还会带出索引失效、重建空间不足这类衍生事故。这篇文章是我个人多年处理Oracle表与索引碎片的完整实践记录,从碎片成因、量化判断、具体操作到常见坑,尽量一次讲透。适合刚入门的DBA照着排查,也适合遇到大表收缩迟迟不敢动手的同行参考。

1. 先搞清楚:碎片到底是怎么产生的

1.1 表碎片的本质:空间闲置与行迁移

Oracle里的普通表是堆表,你INSERT数据时,服务器进程会找有空闲空间的块把行放进去。DELETE并不是把行从物理块里“擦掉”,而是给行打上删除标记,块里那块空间被保留起来。后续的INSERT如果分配到同一个块,新行可以复用这些空洞,但问题是块内的旧行和新行密度都不一样,反复地删、插、改之后,一个8KB的块里可能只住了几行,块数量越来越多,段被白白撑大,这就是最常见的表碎片。

还有个更隐蔽的东西叫行迁移。当UPDATE把一行的长度改得比较大,比如把VARCHAR2从10个字符改成200个字符,原来的块放不下了,Oracle会把整行搬到另一个块,原块只留下一个“指向新块”的地址。以后每次读这行,都需要先访问原块再跳转新块,两次I/O起步,这就是CHAIN_CNT上涨的根源。它既是碎片的一种,也是SQL变慢的重要原因。

另一个绕不开的概念是高水位线HWM。段里已经使用过的块,其范围由HWM标记,HWM以上的块从来没有被写过数据。你DELETE掉大量行甚至DELETE全表,HWM都不会自动降下来,段空间依然占着。只有把HWM以下的空洞重新填满,或者主动重排段结构,才是真正的收缩。

打个比方:停车场原来画了100个车位,你租了整个停车场,后来车走得只剩20辆,剩下80个空位管理员也不会把停车场面积退给你。新来的车会往空位停,但空位分布得越零散,进出找位越费劲,租金还一分不少。表段碎片就是这么个状态。

1.2 索引碎片:叶子块分裂与死条目

索引的结构是B-Tree,叶子块存放键值和对应的ROWID。当你不断插入新键值,叶子块一旦放满,Oracle会做一次块分裂,把一个满块拆成两个半满的块。分裂本身就会把块内空间打散,这是索引碎片的第一个来源。最典型的是按序列或时间戳生成主键的表,每次插入几乎都打在最右侧的叶子块上,主键索引分裂频率远高于普通普通字段索引,经验上主键索引的LEAF_BLOCKS膨胀率也最吓人。

第二个来源是删除。索引里的删除条目并不会在事务提交的瞬间物理移除,Oracle有延迟清理机制,删除标记一直攒到后续访问或凌晨清理任务才逐步回收。如果业务是白天大批量DELETE、晚间跑批再大量插入,索引叶子块里经常混着大量“死条目”,查询本来定位到一个块就能扫完100条记录,结果要跨好几个块去跳过那些已经删除的键值。

索引碎片的危害和表碎片不同。表碎片主要浪费空间、拖慢全表扫描;索引碎片除了浪费空间,还会增加B-Tree的层级和扫描成本。一个本应三层就能访问到的索引,如果叶子块之间支离破碎,实际访问路径可能要多跨几个块,数据量大的时候差异非常明显。

2. 动手前先定量:碎片程度怎么判断

2.1 从统计信息和段信息看硬数据

我一直反对“每月无脑全库重建索引”这种操作,大库这么干既浪费时间又触发大量I/O。判断碎片,第一步是收集统计信息,让数据说话。

EXEC DBMS_STATS.GATHER_TABLE_STATS('SCOTT', 'BIG_TABLE', CASCADE => TRUE);

然后查表的统计信息:

SELECT table_name, blocks, empty_blocks, avg_row_len, chain_cnt FROM dba_tables WHERE owner = 'SCOTT' AND table_name = 'BIG_TABLE';

这里的BLOCKS是该表分配的数据块数,EMPTY_BLOCKS是HWM以上从来没用过的块数。如果EMPTY_BLOCKS占BLOCKS的比例很高,说明当初表被撑得很大,数据删了但段没缩回来。CHAIN_CNT大于0说明存在行迁移,值得关注。

再查段大小和实际有行的块数:

SELECT segment_name, segment_type, bytes/1024/1024 AS size_mb FROM dba_segments WHERE owner = 'SCOTT' AND segment_name = 'BIG_TABLE'; SELECT COUNT(DISTINCT dbms_rowid.rowid_block_number(rowid)) AS used_blocks FROM SCOTT.BIG_TABLE;

USED_BLOCKS是当前实际含有行数据的块数,拿它去对比DBA_SEGMENTS里的段块数,比值一下子就能看出空洞有多大。我习惯把这三个查询拼进一个巡检脚本,每次跑批完直接输出比率,省得反复拼SQL。

2.2 用DBMS_SPACE看块内部空闲分布

段大小的对比只能说明整体空间利用率,块内部是不是碎得一塌糊涂,还要用DBMS_SPACE包的SPACE_USAGE过程来看。它会按空闲率把块分成几个区间:FS1表示块内空闲空间在0到25%,FS2是25%到50%,FS3是50%到75%,FS4是75%到100%。

SET SERVEROUTPUT ON DECLARE v_unformatted_blocks NUMBER; v_unformatted_bytes NUMBER; v_fs1_blocks NUMBER; v_fs1_bytes NUMBER; v_fs2_blocks NUMBER; v_fs2_bytes NUMBER; v_fs3_blocks NUMBER; v_fs3_bytes NUMBER; v_fs4_blocks NUMBER; v_fs4_bytes NUMBER; v_full_blocks NUMBER; v_full_bytes NUMBER; BEGIN DBMS_SPACE.SPACE_USAGE( 'SCOTT', 'BIG_TABLE', 'TABLE', NULL, v_unformatted_blocks, v_unformatted_bytes, v_fs1_blocks, v_fs1_bytes, v_fs2_blocks, v_fs2_bytes, v_fs3_blocks, v_fs3_bytes, v_fs4_blocks, v_fs4_bytes, v_full_blocks, v_full_bytes ); DBMS_OUTPUT.PUT_LINE( 'U=' || v_unformatted_blocks || ' FS1=' || v_fs1_blocks || ' FS2=' || v_fs2_blocks || ' FS3=' || v_fs3_blocks || ' FS4=' || v_fs4_blocks || ' FULL=' || v_full_blocks ); END; /

如果跑出来FS3、FS4数量占比很高,说明大量块里只有少量行甚至几乎没有有效数据,这种块内部碎片就是SHRINK的打击目标。索引段也能用类似方式查,只是SEGMENT_TYPE传'INDEX',不过索引一般更建议直接看LEAF_BLOCKS和BLEVEL综合判断。

2.3 我的经验阈值:什么时候该动手

碎片处理没有官方“绝对值”标准,我这些年总结的参考阈值是这么定的:

  • 表段:FS4和FS3的块合计占比超过30%,或者EMPTY_BLOCKS超过段总块的20%,就值得处理;
  • 行迁移:CHAIN_CNT超过总行数的2%,优先处理,因为它直接影响查询I/O;
  • 索引:LEAF_BLOCKS膨胀到理论值(相当于DISTINCT_KEYS数)的1.5倍以上,或者BLEVEL超过3,考虑REBUILD;
  • 高频DELETE表:哪怕看起来空间不大,只要有定期大量删除,也要纳入周期性维护清单。

这些阈值不是拍脑袋,是结合生产环境实测效果得出的。低于这个比例你去动表,回收的空间有限,却要承担锁表和索引失效风险,不划算;高于这个比例还不动,碎片红利白白浪费,还可能让SQL执行计划劣化。

3. 表碎片的三种处理方案

3.1 ALTER TABLE MOVE:离线重排,干净利落

最简单的重排语句:

ALTER TABLE SCOTT.BIG_TABLE MOVE;

MOVE会在段内部重新组织行,把HWM降到实际数据位置,清掉所有空洞,效果最彻底。但代价是表在MOVE期间有DDL锁,业务读写会阻塞,所以只能在停机窗口或者低峰期操作。还有一个最关键的点:MOVE会改变表的ROWID,表上的所有索引失效,必须重建。MOVE时加UPDATE INDEXES可以让Oracle顺手维护索引:

ALTER TABLE SCOTT.BIG_TABLE MOVE UPDATE INDEXES;

这个语法确实省事,但我的习惯是大表不加。因为大索引太多时,UPDATE INDEXES阶段耗时很长,而且一旦半途失败,索引状态变UNUSABLE,恢复起来很麻烦。不如MOVE之后单独生成REBUILD脚本,分步处理,每步都能看到进度。

MOVE还有一个空间前提:目标表空间需要约等于表当前段大小的额外空间。如果剩余空间不够,可以先MOVE到空间充裕的新表空间,再MOVE回来,等于借道中转。大表移动时可以配合PARALLEL和NOLOGGING提升速度:

ALTER TABLE SCOTT.BIG_TABLE PARALLEL 8 NOLOGGING; ALTER TABLE SCOTT.BIG_TABLE MOVE UPDATE INDEXES; ALTER TABLE SCOTT.BIG_TABLE NOPARALLEL LOGGING;

注意,NOLOGGING如果表空间是FORCE LOGGING模式则不会生效,而且并行会拉高I/O,别选在业务高峰硬跑。LOB段也不能靠MOVE重排,需要单独处理LOB分区。

3.2 SHRINK SPACE:在线的折中方案

如果表不能接受长时间离线,可以用SHRINK:

ALTER TABLE SCOTT.BIG_TABLE ENABLE ROW MOVEMENT; ALTER TABLE SCOTT.BIG_TABLE SHRINK SPACE;

SHRINK的本质是重写段内部的行,把行往段前部挪,然后逐步降低HWM。它的特点是可以分段执行,先把行挪好,再降HWM:

ALTER TABLE SCOTT.BIG_TABLE SHRINK SPACE COMPACT; -- 观察业务稳定后,再执行 ALTER TABLE SCOTT.BIG_TABLE SHRINK SPACE;

COMPACT阶段做行迁移但不降HWM,对业务的锁影响小;真正降HWM的第二个阶段窗口更短。这种两段式思路非常适合白天还有少量读写的表。

但SHRINK有几个硬限制:开启ROW MOVEMENT后会改变ROWID,如果表上有基于ROWID的逻辑或物化视图、外部依赖,要提前评估;系统表、IOT、含物化视图日志的表、部分LOB场景不支持SHRINK,操作前必须翻官方限制清单。还有一点我踩过坑的:SHRINK本质是写UNDO的在线操作,如果UNDO表空间小,并发DML一多,很容易撑爆UNDO,直接报ORA-01555或者ORA-13756。所以SHRINK建议错峰、调小UNDO压力,必要时候拆成COMPACT和最终收缩两步走。

提示:执行SHRINK之前一定要确认 ROW MOVEMENT 已开启,否则直接报ORA-10631之类错误,这在生产上很常见。

3.3 EXPDP/CTAS重导:超大表终极大招

对于几百GB甚至TB级的表,MOVE和SHRINK都可能因为空间、时间、锁问题干不下去。这时候最稳的方案是逻辑重导:EXPDP导出、DROP原表、重建表、IMPDP导入。虽然步骤多,但效果最好——新表段从头分配,空间利用率和存储参数都能重新规划。

如果不想做全库级DP,我常用CTAS手工重建思路:

ALTER TABLE SCOTT.BIG_TABLE RENAME TO BIG_TABLE_OLD; CREATE TABLE SCOTT.BIG_TABLE AS SELECT * FROM SCOTT.BIG_TABLE_OLD WHERE 1=0; -- 分批按范围插入,控制每批事务和UNDO INSERT INTO SCOTT.BIG_TABLE SELECT * FROM SCOTT.BIG_TABLE_OLD WHERE id BETWEEN 1 AND 10000000; COMMIT; -- 循环处理剩余区间,最后补索引、触发器、权限

CTAS方案的优点是可以自己控制并行度、批量提交节奏、甚至顺带调整PCTFREE和压缩选项,比一条MOVE在调度上更灵活。缺点是停机时间通常比MOVE更长,且外键、触发器、同义词、授权这些都要人工对接。我只在MOVE和SHRINK都评估不行时才用这招,一般用在核心大表换存储结构的场景。

4. 索引重建和合并那些细节

4.1 REBUILD和COALESCE怎么选

索引碎片处理有两个基础操作:REBUILD和COALESCE。

REBUILD是重新生成一棵B-Tree,新段按逻辑顺序紧凑排列,碎片率大幅下降,段大小也可能减小,这是最彻底的手段:

ALTER INDEX SCOTT.IDX_BIG_T_ID REBUILD;

生产库优先用ONLINE方式,让DML在重建期间不中断:

ALTER INDEX SCOTT.IDX_BIG_T_ID REBUILD ONLINE;

而COALESCE是原地把相邻的半空叶子块合并,不重新分配段空间,因此不会减少段占用,锁竞争也小得多。它的定位是“碎片不严重、空间不敏感、希望快速整理”时使用:

ALTER INDEX SCOTT.IDX_BIG_T_ID COALESCE;

我的选择原则很简单:索引碎片率极高、BLEVEL异常高或者段空间必须回收,用REBUILD;只是日常整理、块内空间离散,用COALESCE。REBUILD ONLINE虽然好,但需要几乎等量的表空间,临时表空间也有额外消耗,空间紧张时先评估再加文件。

4.2 一套稳妥的索引重建步骤

少走弯路的重建流程,我自己固定在脚本里:

-- 1. 先查目标索引现状 SELECT index_name, blevel, leaf_blocks, distinct_keys, status FROM dba_indexes WHERE owner = 'SCOTT' AND index_name = 'IDX_BIG_T_ID'; -- 2. 在线重建,按服务器CPU情况定并行度 ALTER INDEX SCOTT.IDX_BIG_T_ID REBUILD ONLINE PARALLEL 4; -- 3. 务必恢复NOPARALLEL,否则索引一直被并行扫描,反而拖慢查询 ALTER INDEX SCOTT.IDX_BIG_T_ID NOPARALLEL; -- 4. 重建后收集索引统计信息 EXEC DBMS_STATS.GATHER_INDEX_STATS('SCOTT', 'IDX_BIG_T_ID');

这里我要多说一句PARALLEL的坑:REBUILD时加PARALLEL能显著提速,但如果重建完忘了NOPARALLEL,这个索引后续查询会被优化器当成并行索引来用,小查询反而变慢。而且并行重建是否真的值得,取决于服务器的CPU核数和IO能力,我一般只在4核以上、IO有余量的机器上用。

还有民间操作是DROP INDEX再CREATE INDEX,除非索引已经烂到无法REBUILD,否则我不建议。DROP瞬间查询计划会失效,优化器可能走全表扫描,加上CREATE阶段的排他锁,风险比REBUILD大很多。

4.3 分区表和全局索引的坑

分区表场景比普通表复杂得多。移动分区表时,如果一个分区被MOVE,分区索引或者全局索引可能直接变成UNUSABLE,这是生产事故的高发区。

ALTER TABLE SCOTT.BIG_PART_TABLE MOVE PARTITION P_2024 TABLESPACE NEW_TS; -- 移动完必须检查索引状态 SELECT index_name, partition_name, status FROM dba_ind_partitions WHERE index_owner = 'SCOTT' AND index_name = 'IDX_PART_T_ID';

如果状态是UNUSABLE,需要单独重建受影响的索引分区或全局索引:

ALTER INDEX SCOTT.IDX_PART_T_ID REBUILD PARTITION P_2024;

重建全局索引时要注意它不像分区索引可以直接指定分区,一个大全局索引重建可能耗时数小时,最好评估是不是有别的方案避免频繁分区移动。我处理EBS这类大量分区表的经验是:在脚本里把“移动分区”和“索引重建”绑定成一个原子任务,宁可多等一会,不要留半拉子状态。

5. 现场实录:常见问题和排查清单

5.1 MOVE之后索引失效,应用秒报错

这是所有碎片处理里最经典的翻车现场。MOVE表后忘了UPDATE INDEXES或者漏了重建脚本,应用立刻报ORA-01502“索引处于不可用状态”之类的错误,查询计划直接崩。

解决思路是MOVE前先导出全表索引清单:

SELECT 'ALTER INDEX ' || index_name || ' REBUILD ONLINE;' FROM dba_indexes WHERE table_owner = 'SCOTT' AND table_name = 'BIG_TABLE';

MOVE完成后批量执行这段脚本。操作顺序上,先确保核心索引重建成功,再处理次要索引。重建期间先放读流量、再放写流量,避免并发DML干扰。

5.2 SHRINK等待、UNDO膨胀和报错

SHRINK遇到并发DML时,会频繁出现事务等待甚至ORA-13756这类错误。最常见的两个前置条件没满足:忘开ROW MOVEMENT,或者UNDO表空间太小。我的建议是生产库跑SHRINK前,先确认UNDO剩余空间,再单独看目标表是否有长事务在跑。

另一个容易忽略的点:SHRINK执行期间会产生大量UNDO和REDO,SHRINK完成后段是紧凑了,但数据库日志量可能暴涨。所以不要在业务高峰跑,也不要在ARCHIVELOG空间吃紧的时候跑。真扛不住就用两段式SHRINK,把最耗时的COMPACT阶段放到白天低峰,把降HWM阶段安排在停机窗口。

5.3 重建索引空间不足,怎么救火

REBUILD ONLINE最怕空间不够,报ORA-01654或者ORA-01653的现场我处理过不少次。解决顺序一般是:

  • 先看索引所在表空间的剩余空间和段大小,算一算是否够重建一倍需求;
  • 空间不够就加一个数据文件,扩完之后重建;
  • 如果加文件也不行,改用COALESCE先整理一些空间,观察效果;
  • 或者把索引重建到别的表空间,成功后再改回原表空间,借壳周转。

还有一种更省空间的做法,把索引先DROP,再重建。虽然我前面说一般不推荐,但在空间完全不足以支撑ONLINE REBUILD的极端条件下,这可能是唯一能落地的方案。真到了这一步,必须在维护窗口执行,并提前通知所有依赖该索引的报表和批处理任务。

5.4 周期性碎片的监控和维护建议

碎片不是处理一次就一劳永逸,我习惯在巡检脚本里加四个固定检查项:

  • 每周扫描DBA_TABLES中EMPTY_BLOCKS比例超过20%的表;
  • 每周扫描索引LEAF_BLOCKS与DISTINCT_KEYS比值异常的对象;
  • 每月对删除量大的表计划一次SHRINK或MOVE;
  • 每季度对全库做一次段顾问,参考OUTCOME为RECLAIM的表再人工复核。

现在Oracle有Segment Advisor,EM界面或者命令行都能跑,会给出“是否需要收缩以及采用哪种方式”的建议。但我不会盲信它,工具建议只是初筛,最终动手前我还是会自己跑一遍SPACE_USAGE和行块统计SQL交叉验证。多做这一步,比事后救火强太多。

最后再分享一个小经验:碎片处理这件事,核心不在“用哪条命令”,而在于理解业务DML模式。那些每天跑批大量DELETE再INSERT的表,如果你不针对它的写入节奏安排碎片整理窗口,任何方案都是治标不治本。搞清楚数据怎么进、怎么出、什么时段最安静,比记住几条ALTER语法值钱得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询