Oracle 在线重定义实战:不停业务,把普通表改造成分区表
大家好,我是睿。
最近遇到一个比较典型的表结构改造需求:一张普通业务日志表,数据量已经接近 4000 万行,表段约 34GB,并且里面还有 CLOB 字段。随着数据持续增长,后续查询、归档和空间维护都会越来越麻烦,所以这次要把它改造成按时间字段分区的分区表。
如果直接停业务、建新表、导数据、改名,思路很简单,但生产环境往往不允许这么干。表越大,停机窗口越难谈,风险也越高。
这类场景我更倾向于用 Oracle 自带的 DBMS_REDEFINITION 做在线重定义:先建一张目标结构的中间表,Oracle 在线把数据和增量同步过去,最后在业务低峰期做一次短暂切换。
今天把这次普通表在线转分区表的完整流程整理出来,所有用户名、表名、字段名和业务含义都已经做了脱敏处理,大家可以按自己的环境替换。
一、场景和目标
本次示例对象如下:
-- 原表
APP_USER.BIZ_LOG
-- 在线重定义中间表
APP_USER.BIZ_LOG_TMP
-- 分区键
LOG_TIME
目标很明确:把普通表 APP_USER.BIZ_LOG 在线改造成按 LOG_TIME 做 RANGE 分区的分区表。
本次表的特点:
- 表段约 34GB,实际操作时还要考虑 LOB 段、索引段、UNDO、REDO 和归档空间。
- 表中包含 CLOB 字段,重定义耗时和日志量都不能按普通小表估算。
- 数据主要分布在 2022 年到 2026 年。
- 业务希望尽量不停机,只接受最终切换阶段的短暂锁表。
先说结论:在线重定义的命令本身不复杂,真正要花时间的是前期检查。主键有没有、分区键能不能用、空间够不够、依赖对象能不能复制,这些比执行命令更关键。
二、改造前先看表到底有多大
第一步先看表段大小:
select owner,
segment_name,
segment_type,
round(sum(bytes) / 1024 / 1024 / 1024, 2) gb
from dba_segments
where owner = 'APP_USER'
and segment_name = 'BIZ_LOG'
group by owner, segment_name, segment_type;
本次示例结果:
OWNER SEGMENT_NAME SEGMENT_TYPE GB
APP_USER BIZ_LOG TABLE 34.39
这里有个细节一定要注意:这个 SQL 只看到了表段本身。如果表里有 CLOB、BLOB 之类的大字段,还要继续看 LOB 段和 LOB 索引段。
可以通过下面的 SQL 补充确认:
select s.owner,
s.segment_name,
s.segment_type,
round(s.bytes / 1024 / 1024 / 1024, 2) gb
from dba_lobs l
join dba_segments s
on s.owner = l.owner
and s.segment_name in (l.segment_name, l.index_name)
where l.owner = 'APP_USER'
and l.table_name = 'BIZ_LOG'
order by s.segment_type, s.segment_name;
我的习惯是:凡是做在线重定义,不只看原表大小,还要把中间表、索引、LOB、UNDO、REDO 和归档空间一起算进去。否则执行到一半空间爆了,后面处理起来会很被动。
三、确认是否有主键
DBMS_REDEFINITION 常见有两种方式:按主键方式和按 ROWID 方式。
生产环境里,如果原表具备主键条件,我一般优先选择主键方式,也就是 DBMS_REDEFINITION.CONS_USE_PK。
先检查原表有没有主键或唯一约束:
select owner,
table_name,
constraint_name,
constraint_type,
status
from dba_constraints
where owner = 'APP_USER'
and table_name = 'BIZ_LOG'
and constraint_type in ('P', 'U');
本次初始检查没有发现主键和唯一约束。
这种情况下不要马上开干,先看是否有字段能作为主键候选列。比如这张表里有一个业务日志 ID 字段 LOG_ID,先检查是否重复:
select log_id
from APP_USER.BIZ_LOG
group by log_id
having count(*) >= 2;
如果没有返回行,说明 LOG_ID 没有重复值。
再继续检查是否存在空值:
select count(*) total_rows,
count(log_id) not_null_log_id,
count(*) - count(log_id) null_log_id,
count(distinct log_id) distinct_log_id
from APP_USER.BIZ_LOG;
判断标准很简单:
null_log_id = 0
并且
total_rows = distinct_log_id
如果这两个条件都满足,LOG_ID 就具备主键条件。
生产环境中我更建议先建唯一索引,再用这个索引加主键约束:
create unique index APP_USER.UK_BIZ_LOG_LOG_ID
on APP_USER.BIZ_LOG(LOG_ID)
tablespace APP_IDX_TS
online;
alter table APP_USER.BIZ_LOG
add constraint PK_BIZ_LOG
primary key (LOG_ID)
using index APP_USER.UK_BIZ_LOG_LOG_ID;
注意,是否能使用 online、是否会产生明显锁等待,要结合数据库版本、业务并发和对象状态测试确认。生产上不要把这一步当成一个无感操作。
四、检查分区字段是否适合做分区键
这次准备用 LOG_TIME 作为分区键。分区字段一定要先查清楚最小值、最大值和空值情况:
select count(*) total_rows,
min(log_time) min_log_time,
max(log_time) max_log_time,
sum(case when log_time is null then 1 else 0 end) null_log_time
from APP_USER.BIZ_LOG;
本次示例结果:
TOTAL_ROWS : 约 3900 万
MIN_LOG_TIME : 2022-01-01
MAX_LOG_TIME : 2026-07-30
NULL_LOG_TIME : 0
再按年份看一下数据分布:
select nvl(to_char(log_time, 'YYYY'), 'NULL') yyyy,
count(*)
from APP_USER.BIZ_LOG
group by nvl(to_char(log_time, 'YYYY'), 'NULL')
order by yyyy;
示例结果如下:
YYYY COUNT(*)
2022 约 667 万
2023 约 758 万
2024 约 848 万
2025 约 975 万
2026 约 700 万
这里可以得出几个判断:
- LOG_TIME 没有空值,可以直接按时间范围分区。
- 历史数据从 2022 年开始,第一个分区从 P2022 开始即可。
- 当前最大时间在 2026 年,可以预建 2027 年到 2030 年分区。
- 最后保留一个 PMAX 分区,承接未来没有提前建分区的数据。
如果你的表里分区键存在空值或者异常历史日期,一定要提前处理。不要让脏数据悄悄落到第一个正常业务分区里,后面维护时会很难受。
五、检查是否支持在线重定义
主键处理完后,先用 CAN_REDEF_TABLE 做可行性检查:
begin
dbms_redefinition.can_redef_table(
uname => 'APP_USER',
tname => 'BIZ_LOG',
options_flag => dbms_redefinition.cons_use_pk
);
end;
/
如果执行无报错,说明这张表可以按主键方式进行在线重定义。
这个检查不要省。它可以提前暴露一些对象限制问题,比你执行到 START_REDEF_TABLE 再失败要舒服得多。
六、创建分区中间表
接下来创建中间表 APP_USER.BIZ_LOG_TMP。
这里的关键点是:中间表字段结构要和原表对齐,包括字段类型、默认值、CLOB 字段、表空间参数等。实际生产中建议先通过 DBMS_METADATA.GET_DDL 抽取原表 DDL,再在这个基础上增加分区定义,不建议纯手写。
下面是脱敏后的示例 DDL:
create table APP_USER.BIZ_LOG_TMP
(
log_id number(12),
operator_id number(6),
log_time date default sysdate,
log_type number(1) default 1,
log_content varchar2(2000),
business_id number(12) default 0,
detail_type number(3),
detail_text clob,
archive_count number(2),
client_host varchar2(32),
access_channel varchar2(32),
node_user_id number(9),
node_time date
)
partition by range (log_time)
(
partition P2022 values less than (date '2023-01-01') tablespace APP_TAB_TS,
partition P2023 values less than (date '2024-01-01') tablespace APP_TAB_TS,
partition P2024 values less than (date '2025-01-01') tablespace APP_TAB_TS,
partition P2025 values less than (date '2026-01-01') tablespace APP_TAB_TS,
partition P2026 values less than (date '2027-01-01') tablespace APP_TAB_TS,
partition P2027 values less than (date '2028-01-01') tablespace APP_TAB_TS,
partition P2028 values less than (date '2029-01-01') tablespace APP_TAB_TS,
partition P2029 values less than (date '2030-01-01') tablespace APP_TAB_TS,
partition P2030 values less than (date '2031-01-01') tablespace APP_TAB_TS,
partition PMAX values less than (maxvalue) tablespace APP_TAB_TS
)
tablespace APP_TAB_TS
pctfree 10
initrans 1
maxtrans 255
enable row movement;
我会保留 ENABLE ROW MOVEMENT。后续如果业务更新了分区键,并且新值需要跨分区移动,没开行移动就容易报错。
字段注释也建议补齐,尤其是后续需要把这张表交给开发或运维同事继续维护时:
comment on column APP_USER.BIZ_LOG_TMP.log_type
is '日志类型';
comment on column APP_USER.BIZ_LOG_TMP.detail_text
is '日志明细文本';
comment on column APP_USER.BIZ_LOG_TMP.client_host
is '客户端主机信息';
comment on column APP_USER.BIZ_LOG_TMP.node_time
is '节点业务时间';
七、开始在线重定义
中间表建好后,开始执行在线重定义:
begin
dbms_redefinition.start_redef_table(
uname => 'APP_USER',
orig_table => 'BIZ_LOG',
int_table => 'BIZ_LOG_TMP',
options_flag => dbms_redefinition.cons_use_pk
);
end;
/
这一步会把原表数据复制到中间分区表。因为本次表约 34GB,而且包含 CLOB 字段,所以执行时间可能比较长。
这里不要只盯着会话有没有跑完,还要同步观察表空间、UNDO、REDO、归档和等待事件。尤其是归档空间,很多重定义任务不是 SQL 写错了,而是归档把磁盘打满了。
八、复制依赖对象
数据复制完成后,需要复制索引、触发器、约束、权限和统计信息等依赖对象:
set serveroutput on
declare
l_errors number;
begin
dbms_redefinition.copy_table_dependents(
uname => 'APP_USER',
orig_table => 'BIZ_LOG',
int_table => 'BIZ_LOG_TMP',
copy_indexes => dbms_redefinition.cons_orig_params,
copy_triggers => true,
copy_constraints => true,
copy_privileges => true,
ignore_errors => true,
num_errors => l_errors,
copy_statistics => true
);
dbms_output.put_line('copy errors=' || l_errors);
end;
/
执行后一定要看 l_errors。如果不是 0,就查 DBA_REDEFINITION_ERRORS:
select object_name,
base_table_name,
ddl_txt
from dba_redefinition_errors
where base_table_name in ('BIZ_LOG', 'BIZ_LOG_TMP');
这里的错误不能一眼带过。索引、约束、触发器、权限任何一个没复制好,切换后都可能变成生产事故。
九、同步增量数据
在线重定义期间,原表仍然可能有业务 DML。切换前需要同步增量:
begin
dbms_redefinition.sync_interim_table(
uname => 'APP_USER',
orig_table => 'BIZ_LOG',
int_table => 'BIZ_LOG_TMP'
);
end;
/
这个过程可以执行多次。
我的习惯是:正式切换前先执行一次同步,确认没有异常;临近切换时再执行一次同步,尽量缩短 FINISH_REDEF_TABLE 阶段的锁表时间。
十、完成在线重定义
最后一步建议放在业务低峰期执行:
begin
dbms_redefinition.finish_redef_table(
uname => 'APP_USER',
orig_table => 'BIZ_LOG',
int_table => 'BIZ_LOG_TMP'
);
end;
/
这一步会短暂锁表。它不是完全无锁,只是把长时间的数据复制过程放到了在线阶段,最终切换仍然需要一个很短的窗口。
执行完成后,原表 APP_USER.BIZ_LOG 就已经变成分区表。
十一、验证结果
切换后不要急着宣布成功,先做验证。
查看原表是否已经变成分区表:
select owner,
table_name,
partitioned
from dba_tables
where owner = 'APP_USER'
and table_name = 'BIZ_LOG';
查看分区信息:
select table_owner,
table_name,
partition_name,
tablespace_name
from dba_tab_partitions
where table_owner = 'APP_USER'
and table_name = 'BIZ_LOG'
order by partition_position;
查看索引状态:
select owner,
index_name,
status,
partitioned
from dba_indexes
where owner = 'APP_USER'
and table_name = 'BIZ_LOG';
查看约束状态:
select owner,
table_name,
constraint_name,
constraint_type,
status
from dba_constraints
where owner = 'APP_USER'
and table_name = 'BIZ_LOG';
查看对象是否失效:
select owner,
object_name,
object_type,
status
from dba_objects
where owner = 'APP_USER'
and status <> 'VALID';
如果发现索引失效,需要及时重建:
alter index APP_USER.IDX_BIZ_LOG_01 rebuild online;
具体是否能在线重建,也要结合版本、索引类型和现场业务情况确认。
十二、收集统计信息
结构变化完成后,建议重新收集统计信息:
begin
dbms_stats.gather_table_stats(
ownname => 'APP_USER',
tabname => 'BIZ_LOG',
cascade => true
);
end;
/
分区表上线后,统计信息很重要。否则优化器还拿着旧的统计信息做判断,可能出现执行计划波动。
十三、清理中间表
业务验证无误后,再清理中间表:
drop table APP_USER.BIZ_LOG_TMP purge;
注意,我不建议刚切换完就立刻删除。最好等业务侧验证、核心 SQL 验证、对象状态检查都通过后,再做清理。
十四、异常处理:遇到 ORA-23539 怎么办
如果在线重定义过程中因为空间不足、会话异常或其他原因中断,再次执行时可能会报:
ORA-23539: table is currently being redefined
这种情况说明 Oracle 认为这张表仍处于重定义状态。需要先中止本次重定义:
begin
dbms_redefinition.abort_redef_table(
uname => 'APP_USER',
orig_table => 'BIZ_LOG',
int_table => 'BIZ_LOG_TMP'
);
end;
/
中止之后,先把空间、权限、对象状态等问题处理干净,再重新执行在线重定义流程。
这里不要急着重复跑命令。很多问题如果不先定位原因,只会从失败一次变成失败两次。
十五、总结与实战建议
这次普通表在线转分区表,流程可以概括成一句话:
检查主键和分区键 -> 创建分区中间表 -> 开始在线重定义 -> 复制依赖对象 -> 同步增量 -> 低峰切换 -> 验证和收尾
我的实战建议:
- 不要只看表段大小,LOB、索引、UNDO、REDO 和归档空间都要一起评估。
- 能用主键方式就优先用主键方式,前提是主键列必须唯一且非空。
- 分区键一定要查最小值、最大值、空值和数据分布,不要凭感觉设计分区。
- 中间表 DDL 尽量从原表 DDL 改造,避免漏字段、默认值、LOB 参数和注释。
- COPY_TABLE_DEPENDENTS 执行后一定要查错误表,不要只看 PL/SQL 执行成功。
- SYNC_INTERIM_TABLE 可以多执行几次,最终切换前再同步一次。
- FINISH_REDEF_TABLE 仍然会短暂锁表,生产环境建议放在业务低峰期。
- 切换完成后要验证分区、索引、约束、失效对象和统计信息。
在线重定义不是为了让变更"没有风险",而是把风险拆开:前面大部分工作在线完成,最后只留下一个尽量短的切换窗口。
表分区改造这类工作,看起来是 DDL,实际考验的是 DBA 对对象结构、空间、业务窗口和回退方案的整体把控。
好了,今天这篇就到这里。你在生产环境中有没有做过普通表在线转分区表?是用 DBMS_REDEFINITION,还是通过 CTAS、导入导出或者停机窗口处理?欢迎在评论区聊聊你的经验。
------ 睿 | Oracle性能优化老司机
专注硬核干货,欢迎一起卷技术!
