做DBA这些年,碎片处理算是绕不开的老话题。尤其是攒了几千万行的大表,平时增删改查都还凑合,一旦做完批量清理或者大版本维护,性能突然就拉胯。群里一聊,往往第一反应都是:是不是碎片太多了?说实话,这问题既简单又复杂。简单在于处理动作就那几个:表用 MOVE 或 SHRINK,索引用 REBUILD 或 COALESCE;复杂在于碎片率怎么量化判断、什么时候该动、怎么动不影响业务、动完还要做什么,这一串做不严谨,很容易弄巧成拙。这篇文章把我实际运维中的排查经验、处理脚本和一些踩坑教训整理出来,适合DBA、运维开发以及需要自己维护Oracle库的朋友参考。
1. 碎片到底从哪来:从存储结构说起
1.1 段、区、块:碎片的第一层来源
Oracle 表数据物理上以段(Segment)为单位管理,段下面分区(Extent),区再往下才是数据块(Block)。每张表和每个索引都有自己独立的段。建表初期系统只分配少量区,数据增长时再批量追加。问题就出在这个“追加”上:一张表反复经历插入、清理、再插入,它的区分布会变得七零八落,相邻区不再连续,原本可以顺序扫描的路径被迫变成跳跃式访问,物理读自然上去。
可以把这个结构类比成仓库货架:区是一排连续的货架,块是单个货位。理想情况下表数据整整齐齐码在相邻几排,搬运工顺着一条路线走完就行。碎片化之后,同一张表的数据散布在仓库不同角落,搬运工每次都得来回跑腿,效率肉眼可见地下降。更麻烦的是,统计信息与实际存储结构差距拉大后,优化器算出来的成本是错的,执行计划也会跟着跑偏。
1.2 高水位线:比想象中更坑的存在
高水位线(HWM)指的是段内曾经使用过的最高块位置。它才是表碎片最典型、也最容易被忽略的表现。DELETE 大量行之后,块里的数据被清空,但高水位线不会自动回落。全表扫描依然会扫描到高水位线为止,哪怕这些块已经全是空壳。这就好比胡同口的路灯,沿街的房子都拆平了,路灯还杵在原地,每晚巡逻依然要走到胡同底再折返。
与高水位线相伴的还有行迁移和行链接。UPDATE 导致行长度变大、原块放不下时,Oracle 会把整行搬到新块,并在原位置留一个指针,这叫行迁移;如果行大到单个块都装不下,就会被拆成多段存放,这叫行链接。不管哪种,访问一行数据可能需要多次 I/O,性能损耗非常直接。碎片处理的核心目标说穿了就两件事:压低高水位线、压缩段空间,同时把行迁移和行链接的数量降下来。
2. 表碎片怎么查、怎么处理
2.1 用数据说话:怎么判断表碎不碎
不能光看段大小,也不能凭感觉“这表该整理了吧”。最省力的经验是先查dba_segments和dba_tables,用统计信息估算数据实际占用:真实数据量约等于num_rows × avg_row_len,把估算值和段实际字节数一对比,碎片率基本就出来了。
SELECT t.owner, t.table_name, ROUND(s.bytes / 1024 / 1024, 2) seg_mb, t.num_rows, ROUND(t.avg_row_len * t.num_rows / 1024 / 1024, 2) est_mb, ROUND((1 - (t.avg_row_len * t.num_rows) / s.bytes) * 100, 2) waste_pct FROM dba_tables t, dba_segments s WHERE t.owner = s.owner AND t.table_name = s.segment_name AND s.segment_type = 'TABLE' AND t.num_rows > 100000 ORDER BY waste_pct DESC;waste_pct超过 30% 到 50% 就值得排期处理。有个前提必须强调:这个脚本依赖统计信息准确度,所以最好先对目标表跑一次dbms_stats再评估。想更精细的话,可以用ANALYZE TABLE ... LIST CHAINED ROWS配合UTL_CHAINED_ROWS脚本建一张 chained_rows 表,直接查出哪些行发生了迁移和链接。
2.2 ALTER TABLE MOVE:最直接的主方案
处理方案的取舍主要看表的规模和可停机时间。能接受写阻塞的话,ALTER TABLE MOVE是最干净利落的手段:
ALTER TABLE orders MOVE TABLESPACE users; -- 大表可以加并行和 NOLOGGING ALTER TABLE orders MOVE PARALLEL 8 NOLOGGING;MOVE 会新建一个段,把数据重新紧凑排列,再删掉旧段。高水位线在操作完成后自动落到实际数据末尾,空间也能回收到表空间。有几个关键点务必记牢:第一,MOVE 会让该表上的普通索引全部变成 UNUSABLE,执行完必须重建索引,否则业务 SQL 直接走全表扫描,比整理前还慢;第二,MOVE 前要预留足够表空间,大约等于目标段大小再加上原段空间,别让操作执行到一半报 ORA-01654;第三,并行度不要贪,个人经验控制在 8 比较稳,32 的并行很容易把 CPU 打满拖垮整个实例;第四,NOLOGGING 能减少大量 redo,但前提是数据库没有开启 force logging,否则这个参数不生效。
2.3 SHRINK SPACE:不重建段也能压缩
如果不想让索引失效、也不想让表长时间不可用,SHRINK 是更温和的方案。它有两个硬性前提:表所在表空间必须是自动段空间管理(ASSM),表本身要开启 row movement。基本语法如下:
ALTER TABLE orders ENABLE ROW MOVEMENT; ALTER TABLE orders SHRINK SPACE CASCADE;SHRINK 同样可以拉低高水位线,而且是在线操作,执行期间允许 DML 等待通过,对业务的影响比 MOVE 小很多。代价是它也会改变行的 ROWID,任何依赖 ROWID 的物化视图、应用临时表、JDBC 缓存都可能被影响,动手前必须检查清楚依赖关系。分区表建议逐个分区收缩,别一把梭,否则一个大操作拖太久,不但占资源,中途失败回滚也麻烦。
选 MOVE 还是 SHRINK,我的习惯是:能接受写窗口,选 MOVE,速度快且彻底;必须在线、表又特别大,选 SHRINK,但要接受执行时间更长、执行期间资源占用更持续。
3. 索引碎片处理的完整路径
3.1 索引碎片的判定标准
索引碎片和表碎片成因类似,但表现形式不一样。B 树索引在频繁 DELETE 之后,叶子块占用率会下降,甚至残留大量删除标记。扫描索引时逻辑读虚高,实际却不产生多少有效数据,这就是典型的索引碎片。
经验判定方法有两个。最直接的是ANALYZE INDEX VALIDATE STRUCTURE,然后查INDEX_STATS:
ANALYZE INDEX orders_idx1 VALIDATE STRUCTURE; SELECT name, lf_rows, del_lf_rows, ROUND(del_lf_rows / lf_rows * 100, 2) del_pct FROM index_stats WHERE name = 'ORDERS_IDX1';del_lf_rows占比超过 20%,基本就值得处理了。另一个方法是用dba_indexes里的BLEVEL和LEAF_BLOCKS做趋势判断:如果 BLEVEL 长期偏高,或者 LEAF_BLOCKS 的增长速度和表行数严重脱节,说明索引结构已经比较虚胖,同样需要关注。
3.2 REBUILD、COALESCE、SHRINK 的选择
索引处理三种手段各有适用场景。REBUILD 是最推荐的方式,相当于用同样的字段结构重新构建一棵紧凑的 B 树,存储参数可以重设,还能在线执行:
ALTER INDEX orders_idx1 REBUILD ONLINE; -- 大索引可以配合并行和指定表空间 ALTER INDEX orders_idx1 REBUILD ONLINE TABLESPACE idx_ts PARALLEL 4 NOLOGGING;COALESCE 不重建段,只在现有块内尝试合并相邻叶子。它不会把索引搬到新的表空间,碎片压缩效果也比较有限,适合不想动索引物理结构、只想稍微清理一下的场景。SHRINK SPACE 和表的 SHRINK 类似,在线压缩段空间,同样适合轻量维护。
实操中我认为 REBUILD ONLINE 基本是默认答案。低峰期做重建,注意观察归档日志增长速度和 CPU 使用率,必要时分几批执行,每批重建一两个索引后歇一会儿,把资源消耗摊平。
3.3 索引表空间规划与避免回表的误区
独立索引表空间的好处不只在管理层面。把表数据和索引放在不同数据文件上,可以减少 I/O 竞争,重建索引时也可以顺手把索引迁到规划好的idx_ts表空间,后续容量管理更清晰。
有一个观念需要纠正:很多人以为重建索引能“优化回表”。这是两码事。回表次数由索引选择性和查询字段决定,碎片整理只让索引扫描本身更快,物理读更少,并不会减少需要回表的行数。要真正避免回表,得靠复合索引覆盖查询列,这不是碎片整理能替代的。碎片该整理还是要整理,但别指望它能解决错误的索引设计问题。
4. 实战案例:几千万行订单表的碎片处理全过程
4.1 案例背景与诊断
之前在生产库碰到一张 orders 表,大约 4800 万行,业务按月份清理过两次历史数据,清理后只剩 1600 万行左右,但段大小还是顶着 6.8GB。现象是报表跑批越来越慢,全表扫描类的 SQL 经常超过预期时间。我用检测脚本一查,waste_pct 约 65%,属于典型的高水位线虚高;再查dba_segments,表有 1200 多个 extents,两个二级索引的 del_lf_rows 占比也超过了 30%。
处理窗口安排在周六凌晨,大约有 3 小时可停机,归档空间剩余 10GB 左右。我的计划是:表用 MOVE 到新数据文件,索引全部 REBUILD ONLINE,整个过程控制在 2 小时内完成,留 1 小时给验证和兜底。
4.2 执行过程与参数选择
先重新收集统计信息,然后按顺序执行关键语句:
ALTER TABLE orders MOVE TABLESPACE users PARALLEL 8 NOLOGGING; ALTER INDEX orders_pk REBUILD ONLINE TABLESPACE idx_ts PARALLEL 8 NOLOGGING; ALTER INDEX orders_created_idx REBUILD ONLINE TABLESPACE idx_ts PARALLEL 8 NOLOGGING; ALTER INDEX orders_status_idx REBUILD ONLINE TABLESPACE idx_ts PARALLEL 8 NOLOGGING;MOVE 实际耗时约 26 分钟,期间 DML 被阻塞,我在值班群提前打了招呼,业务侧没受到太大影响。索引重建一个接一个执行,总耗时大约 40 分钟。并行度全部选 8,没有提到 16 或更高,怕的是并行进程和业务进程抢资源。所有 DDL 执行完毕后,马上对表重新收集统计信息,这一步不能省,否则优化器还在用旧的高水位线数据做成本估算,执行计划依然不准。
4.3 效果验证与在线重定义备选
处理后的验证结果挺直观:orders 段从 6.8GB 降到 2.1GB,extents 从 1200 多个降到 40 个;三个索引的 leaf_blocks 平均下降 30% 以上,del_lf_rows 全部归零;全表扫描 SQL 从 40 秒回到 12 秒。这个结果其实在意料之中,因为压缩掉的大部分空间本来就在高水位线以下,属于典型的虚胖。
万一遇到不能接受长时间锁的场景,DBMS_REDEFINITION在线重定义是更稳妥的备选。它通过中间表同步数据,可以在业务运行期间把表搬到新结构、新表空间。核心步骤是:先调用can_redef_table检查可行性,再start_redef_table启动在线重定义,接着copy_table_dependents复制依赖对象、sync_interim_table做增量同步,最后finish_redef_table完成切换。代价是实施复杂度明显更高,需要更严密的验证和回滚预案,不建议作为日常碎片整理方案,只在大表彻底搬迁且不允许停机时才用。
5. 常见问题与排查技巧实录
5.1 踩坑速查表
| 现象 | 原因 | 处理方式 |
|---|---|---|
| MOVE 后查询反而变慢 | 普通索引全部失效,查询走了全表扫描 | 执行完 MOVE 后必须重建所有索引 |
| SHRINK 报 ORA-10631 | 未开启 ROW MOVEMENT | 先执行ALTER TABLE ... ENABLE ROW MOVEMENT |
| MOVE 报 ORA-01654 | 目标表空间剩余空间不足 | 预留表大小 1 倍以上空间,或扩容表空间 |
| REBUILD ONLINE 长时间不结束 | 大量未提交事务或快照过旧 | 先查v$session和v$undostat,选低峰执行 |
| 并行度过高导致 CPU 打满 | 并行度设置到 16 或更高 | 控制在 4 到 8,同时观察归档日志增长 |
| 分区表 move 后全局索引不可用 | 全局索引跨分区失效 | 重建全局索引,或评估改用本地索引 |
5.2 让脚本自动干活的思路
手工维护几十张表和上百个索引太费劲。可以利用数据字典生成标准 DDL,再用DBMS_SCHEDULER定时跑。核心逻辑其实就是一个动态语句生成器:
BEGIN FOR c IN ( SELECT 'ALTER INDEX ' || owner || '.' || index_name || ' REBUILD ONLINE NOLOGGING;' sql_text FROM dba_indexes WHERE table_owner = 'APP' AND status = 'VALID' AND blevel >= 2 AND leaf_blocks > 5000 ) LOOP dbms_output.put_line(c.sql_text); END LOOP; END;在此基础上加一张执行日志表、异常捕获逻辑和失败告警,就是一个能用的自动碎片整理工具。我的习惯是先在小表上试跑几次,观察每一轮的 redo 生成量和等待事件,确认脚本不会冲击业务,再逐步放开到所有目标表。
5.3 我个人的几条实操习惯
碎片处理前一定先记录段大小、行数、统计信息采集时间,处理后再做一次对比,这样给领导汇报时手里有数据,也方便判断这次操作到底值不值。有一次我在周五下午手滑对核心表执行了 MOVE,虽然操作本身很快完成了,但业务方正好在跑批,结果被投诉到值班群。从那以后,所有碎片整理脚本都放进调度系统,只允许窗口期执行。
窗口充足时用 MOVE,窗口紧张用 SHRINK,但无论哪种方式,处理完之后的统计信息收集绝对不能省。最后说一句:如果某张表每隔一两个月就严重碎片化,别只想着定期维护,更要从应用层排查是不是存在频繁 DELETE 加 INSERT 的写法,治理源头永远比被动维护更有效。