1. 为什么要在达梦上做数据自动迁移
数据迁移这件事,做过一次的人都知道,最怕的不是迁移本身,而是迁移完之后业务方追着你问"昨天的数据怎么没同步过来"。尤其是用达梦数据库的场景,很多团队是从Oracle或者MySQL转过来的,习惯了原来那套迁移工具链,到了达梦这边发现要么工具不趁手,要么权限卡得死,要么就是跨库访问的性能跟预期差了一大截。
我接手过好几个达梦的数据迁移项目,有同实例跨Schema的,有跨实例的,还有从Oracle往达梦迁的。这些项目里最让我头疼的不是迁移逻辑本身有多复杂,而是迁移任务的调度和容错。你写一个存储过程把数据从A表搬到B表,逻辑再复杂也就那样,但你怎么保证它每天凌晨准时跑?跑失败了怎么重试?跑了一半断了怎么续?这些才是真正消耗精力的地方。
达梦数据库本身提供了DBMS_SCHEDULER这个包,功能上对标Oracle的同名包,可以创建定时任务来周期性执行存储过程。这套组合拳打下来,基本上可以做到"设定好规则,剩下的交给数据库自己"。我实测下来,在达梦8上这套方案跑了大半年,每天定时迁移几百万行数据,稳定性是靠谱的。
这篇文章适合谁看?如果你是达梦数据库的运维或者开发,手头有定期数据迁移的需求,又不想引入额外的调度中间件(比如什么XXL-JOB、Quartz之类的),那这套"存储过程+DBMS_SCHEDULER"的方案就是为你准备的。不需要额外部署服务,不需要写Java代码,纯SQL就能搞定。当然,如果你对达梦的存储过程语法还不太熟,建议先补一下基础,不然有些地方可能会卡住。
下面我会从整体设计思路开始,把方案选型的考量、存储过程的编写要点、定时任务的配置细节、以及我踩过的坑,一个一个拆开来讲。
2. 整体方案设计与选型考量
2.1 为什么选存储过程而不是外部程序
做数据迁移,摆在面前的路无非几条:写Java/Python程序用JDBC连数据库搬数据、用ETL工具(比如Kettle、DataX)、或者在数据库内部用存储过程搞定。
我先说为什么没选外部程序。用Java写迁移逻辑,你得处理连接池、事务边界、批量提交、异常重试,代码量不小。而且一旦迁移逻辑有变化,你得重新打包部署,走一遍CI/CD流程。更麻烦的是,如果迁移涉及多张表的关联清洗,在应用层做意味着要把数据拉到内存里处理,数据量一大就容易OOM。
ETL工具呢?Kettle和DataX确实能干活,但引入它们意味着你要多维护一套工具链,而且达梦的JDBC驱动跟这些工具的兼容性偶尔会出幺蛾子。我之前用Kettle连达梦就遇到过驱动版本不匹配导致中文乱码的问题,排查了半天。
存储过程方案的核心优势在于数据不动、逻辑动。数据全程在数据库内部流转,不需要通过网络传输到应用层再传回来。对于大批量数据的迁移,这个性能差异是数量级的。而且存储过程天然支持事务控制,你可以精确控制每批提交多少行,失败了从哪个断点续传。
注意:存储过程方案适合的是"数据库内部或数据库之间"的数据迁移。如果你的源数据在文件系统、消息队列或者第三方API里,那还是得用外部程序。
2.2 DBMS_SCHEDULER相比操作系统的crontab强在哪
有人可能会想,我用Linux的crontab定时调用一个脚本去执行SQL不就行了?何必用DBMS_SCHEDULER?
这个思路不是不行,但有几个明显的短板。第一,crontab调用的脚本需要明文存储数据库密码,这是个安全隐患。第二,crontab的执行日志跟数据库日志是分离的,出了问题你得两头查。第三,crontab没法感知数据库的状态,比如数据库正在做备份、或者某个关键表被锁了,它照样会触发任务。
DBMS_SCHEDULER是数据库原生的调度器,任务定义、执行日志、状态查询都在数据库内部完成。你可以通过USER_SCHEDULER_JOB_LOG和USER_SCHEDULER_JOB_RUN_DETAILS这两个视图直接查到每次执行的详细情况,包括开始时间、结束时间、执行结果、错误信息。排查问题的时候,一条SQL就能定位。
另外,DBMS_SCHEDULER支持基于事件的调度。比如你可以定义一个任务,让它在一个特定的自定义事件被触发时才执行,而不是死板地按时间跑。这个在复杂的迁移场景里很有用。
2.3 整体架构长什么样
这套方案的整体结构其实很简单,就三层:
- 数据层:源表和目标表,可能在同一实例的不同Schema下,也可能在不同实例上(通过DBLINK访问)。
- 逻辑层:存储过程,负责具体的数据抽取、转换、加载逻辑,包含批次控制、异常处理、断点记录。
- 调度层:DBMS_SCHEDULER创建的定时任务,负责按计划触发存储过程,并记录执行日志。
这三层各司其职,逻辑层和调度层是解耦的。也就是说,你可以单独测试存储过程的逻辑,确认没问题了再挂到调度器上。反过来,如果迁移逻辑需要调整,你只需要修改存储过程,调度任务不用动。
我一般会在方案里额外加一张迁移日志表,用来记录每次迁移的批次、时间范围、影响行数、状态。这张表是存储过程自己维护的,跟DBMS_SCHEDULER的日志互补。DBMS_SCHEDULER告诉你任务有没有跑,迁移日志表告诉你跑了多少数据、跑到哪了。
3. 存储过程编写的核心细节
3.1 迁移日志表的设计
在写存储过程之前,先把日志表建好。这张表是整个迁移方案的基础设施,没有它,出了问题你两眼一抹黑。
CREATE TABLE MIG_LOG ( LOG_ID BIGINT IDENTITY(1,1) PRIMARY KEY, PROC_NAME VARCHAR(100) NOT NULL, BATCH_NO INT NOT NULL, START_TIME TIMESTAMP DEFAULT SYSDATE, END_TIME TIMESTAMP, MIN_ID BIGINT, MAX_ID BIGINT, ROW_COUNT INT DEFAULT 0, STATUS VARCHAR(20) DEFAULT 'RUNNING', ERR_MSG VARCHAR(2000), REMARK VARCHAR(500) );字段说明一下。PROC_NAME标识是哪个存储过程写的日志,方便多个迁移任务共用一张日志表。BATCH_NO是批次号,每次迁移递增。MIN_ID和MAX_ID记录这一批处理的主键范围,断点续传的时候靠它。STATUS有三个状态:RUNNING、SUCCESS、FAILED。
实操心得:日志表本身也会越来越大,建议加一个定期清理的机制。我一般会在迁移存储过程里顺手加一段逻辑,每次执行时删除30天前的日志记录。别小看这个,日志表膨胀到几千万行的时候,查询会明显变慢。
3.2 分批迁移的核心逻辑
一次性把几百万行数据从一个表搬到另一个表,最大的风险是undo表空间爆掉和锁等待超时。达梦的undo机制跟Oracle类似,一个大事务产生的undo数据如果超过阈值,会直接报错回滚。
所以必须分批。分批的策略通常是按主键范围切,每批处理固定行数。下面是一个典型的分批迁移存储过程骨架:
CREATE OR REPLACE PROCEDURE PROC_MIG_DATA( P_BATCH_SIZE IN INT DEFAULT 5000 ) AS V_MIN_ID BIGINT; V_MAX_ID BIGINT; V_CUR_ID BIGINT; V_ROW_CNT INT; V_BATCH_NO INT; V_TOTAL INT := 0; BEGIN -- 获取当前批次号 SELECT NVL(MAX(BATCH_NO), 0) + 1 INTO V_BATCH_NO FROM MIG_LOG WHERE PROC_NAME = 'PROC_MIG_DATA'; -- 获取待迁移数据的主键范围 SELECT MIN(ID), MAX(ID) INTO V_MIN_ID, V_MAX_ID FROM SRC_TABLE WHERE MIG_FLAG = 'N'; IF V_MIN_ID IS NULL THEN INSERT INTO MIG_LOG(PROC_NAME, BATCH_NO, STATUS, REMARK) VALUES('PROC_MIG_DATA', V_BATCH_NO, 'SUCCESS', '无待迁移数据'); COMMIT; RETURN; END IF; V_CUR_ID := V_MIN_ID; WHILE V_CUR_ID <= V_MAX_ID LOOP -- 插入目标表 INSERT INTO TGT_TABLE(ID, NAME, AMOUNT, CREATE_TIME) SELECT ID, NAME, AMOUNT, CREATE_TIME FROM SRC_TABLE WHERE ID >= V_CUR_ID AND ID < V_CUR_ID + P_BATCH_SIZE AND MIG_FLAG = 'N'; V_ROW_CNT := SQL%ROWCOUNT; -- 更新源表标记 UPDATE SRC_TABLE SET MIG_FLAG = 'Y' WHERE ID >= V_CUR_ID AND ID < V_CUR_ID + P_BATCH_SIZE AND MIG_FLAG = 'N'; -- 记录日志 INSERT INTO MIG_LOG(PROC_NAME, BATCH_NO, MIN_ID, MAX_ID, ROW_COUNT, STATUS, END_TIME) VALUES('PROC_MIG_DATA', V_BATCH_NO, V_CUR_ID, V_CUR_ID + P_BATCH_SIZE - 1, V_ROW_CNT, 'SUCCESS', SYSDATE); COMMIT; V_TOTAL := V_TOTAL + V_ROW_CNT; V_CUR_ID := V_CUR_ID + P_BATCH_SIZE; END LOOP; -- 更新最终状态 UPDATE MIG_LOG SET REMARK = '总计迁移' || V_TOTAL || '行' WHERE PROC_NAME = 'PROC_MIG_DATA' AND BATCH_NO = V_BATCH_NO AND STATUS = 'SUCCESS'; COMMIT; EXCEPTION WHEN OTHERS THEN ROLLBACK; INSERT INTO MIG_LOG(PROC_NAME, BATCH_NO, MIN_ID, MAX_ID, STATUS, ERR_MSG, END_TIME) VALUES('PROC_MIG_DATA', V_BATCH_NO, V_CUR_ID, V_CUR_ID + P_BATCH_SIZE - 1, 'FAILED', SQLERRM, SYSDATE); COMMIT; RAISE; END;这段代码有几个关键点值得展开说。
批次大小怎么定。P_BATCH_SIZE默认5000,这个值不是拍脑袋来的。太小了,比如100,那提交次数太多,每次提交都有开销,整体速度上不去。太大了,比如50000,单次事务的undo量可能超过限制。我的经验是,如果单行数据不大(几百字节),5000到10000是比较合适的。如果单行数据有几KB,那就降到1000到2000。你可以先跑一批试试,看看undo表空间的使用情况再调整。
为什么先插入再更新标记。有人可能会想,能不能先更新标记再插入?不行。如果先更新标记然后插入失败了,那这批数据就被标记成已迁移但实际没迁过去,数据就丢了。先插入再更新,最坏的情况是插入成功但更新标记失败,那下次会重复插入。重复插入的问题可以通过目标表的唯一约束或者MERGE语句来解决,但数据丢失是没法补救的。
异常处理里的ROLLBACK。注意异常块里先ROLLBACK再写日志。如果不回滚,当前批次的部分数据可能已经写入但标记没更新,会造成状态不一致。回滚之后写FAILED日志,然后RAISE把异常抛出去,让调度器知道任务失败了。
3.3 断点续传的实现
上面的代码其实已经隐含了断点续传的能力。因为每次循环都COMMIT,而且源表的MIG_FLAG字段标记了哪些数据已经迁过。如果存储过程在中间某次循环失败了,下次重新执行时,SELECT MIN(ID) FROM SRC_TABLE WHERE MIG_FLAG = 'N'会自动从上次失败的位置继续。
但这里有个细节要注意:如果失败发生在INSERT成功但UPDATE标记之前,那这批数据会被重复迁移。解决方法是给目标表加唯一约束,或者在INSERT时用WHERE NOT EXISTS做去重。
INSERT INTO TGT_TABLE(ID, NAME, AMOUNT, CREATE_TIME) SELECT S.ID, S.NAME, S.AMOUNT, S.CREATE_TIME FROM SRC_TABLE S WHERE S.ID >= V_CUR_ID AND S.ID < V_CUR_ID + P_BATCH_SIZE AND S.MIG_FLAG = 'N' AND NOT EXISTS (SELECT 1 FROM TGT_TABLE T WHERE T.ID = S.ID);加了NOT EXISTS之后,性能会稍微下降一点,但换来的幂等性是值得的。尤其是迁移任务需要反复重跑的场景,这个代价完全可以接受。
3.4 跨实例迁移的处理
如果源表和目标表不在同一个达梦实例上,就需要用到DBLINK。达梦支持通过CREATE LINK创建数据库链接,语法跟Oracle类似:
CREATE LINK LINK_TO_REMOTE CONNECT 'DM' WITH USERNAME "REMOTE_USER" IDENTIFIED BY "PASSWORD" USING '192.168.1.100:5236';创建好之后,就可以在SQL里用表名@LINK名的方式访问远程表了。
INSERT INTO TGT_TABLE(ID, NAME, AMOUNT) SELECT ID, NAME, AMOUNT FROM SRC_TABLE@LINK_TO_REMOTE WHERE ID >= V_CUR_ID AND ID < V_CUR_ID + P_BATCH_SIZE;注意:跨实例迁移的性能瓶颈通常在网络。如果源表和目标表之间的网络延迟较高,建议把批次大小调小,比如降到1000,减少单次网络传输的数据量。另外,DBLINK的查询是在远程实例上执行的,所以
WHERE条件里的索引能不能用上,取决于远程表的索引情况。
跨实例迁移还有一个坑:字符集。如果两个实例的字符集不一致,中文可能会出现乱码。达梦的字符集在实例创建时就确定了,后期修改很麻烦。所以在建库之前就要规划好,迁移之前先用SELECT UNICODE()或者查V$INSTANCE确认两边的字符集是否一致。
4. DBMS_SCHEDULER定时任务的配置
4.1 创建定时任务的完整步骤
存储过程写好了,接下来就是让DBMS_SCHEDULER按时触发它。达梦的DBMS_SCHEDULER用法跟Oracle基本一致,分三步:创建Program、创建Schedule、创建Job。
先创建Program,定义要执行什么:
BEGIN DBMS_SCHEDULER.CREATE_PROGRAM( PROGRAM_NAME => 'PROG_MIG_DATA', PROGRAM_TYPE => 'STORED_PROCEDURE', PROGRAM_ACTION => 'PROC_MIG_DATA', ENABLED => TRUE, COMMENTS => '数据迁移存储过程' ); END; /然后创建Schedule,定义什么时候执行:
BEGIN DBMS_SCHEDULER.CREATE_SCHEDULE( SCHEDULE_NAME => 'SCHED_MIG_DAILY', START_DATE => SYSTIMESTAMP, REPEAT_INTERVAL => 'FREQ=DAILY; BYHOUR=2; BYMINUTE=0; BYSECOND=0', END_DATE => NULL, COMMENTS => '每天凌晨2点执行' ); END; /最后创建Job,把Program和Schedule绑在一起:
BEGIN DBMS_SCHEDULER.CREATE_JOB( JOB_NAME => 'JOB_MIG_DATA', PROGRAM_NAME => 'PROG_MIG_DATA', SCHEDULE_NAME => 'SCHED_MIG_DAILY', ENABLED => TRUE, COMMENTS => '每日数据迁移任务' ); END; /这三步做完,任务就会在每天凌晨2点自动执行PROC_MIG_DATA存储过程。
4.2 REPEAT_INTERVAL的语法详解
REPEAT_INTERVAL用的是日历表达式语法(Calendaring Expression Syntax),跟Oracle的DBMS_SCHEDULER完全兼容。常用的几种写法:
| 需求 | 表达式 |
|---|---|
| 每天凌晨2点 | FREQ=DAILY; BYHOUR=2; BYMINUTE=0; BYSECOND=0 |
| 每小时执行一次 | FREQ=HOURLY; BYMINUTE=0; BYSECOND=0 |
| 每30分钟执行一次 | FREQ=MINUTELY; INTERVAL=30 |
| 每周一至周五早上6点 | FREQ=WEEKLY; BYDAY=MON,TUE,WED,THU,FRI; BYHOUR=6 |
| 每月1号凌晨3点 | FREQ=MONTHLY; BYMONTHDAY=1; BYHOUR=3 |
实操心得:如果迁移任务比较重,建议避开业务高峰期。我一般会跟业务方确认一个低峰时段,比如凌晨2点到4点之间。另外,如果迁移任务可能跑超过24小时,那就要考虑用
FREQ=DAILY会不会导致任务重叠。达梦的DBMS_SCHEDULER默认不允许同一个Job的多个实例并行执行,所以重叠的问题不用担心,但你要确保任务能在下一次触发之前跑完。
4.3 任务状态的查询与监控
任务创建好之后,怎么知道它有没有在跑、跑得怎么样?达梦提供了几个视图:
-- 查看Job的基本信息和状态 SELECT JOB_NAME, ENABLED, STATE, LAST_START_DATE, NEXT_RUN_DATE, RUN_COUNT, FAILURE_COUNT FROM USER_SCHEDULER_JOBS WHERE JOB_NAME = 'JOB_MIG_DATA'; -- 查看Job的执行日志 SELECT LOG_ID, JOB_NAME, LOG_DATE, STATUS, ACTUAL_START_DATE, RUN_DURATION, ADDITIONAL_INFO FROM USER_SCHEDULER_JOB_LOG WHERE JOB_NAME = 'JOB_MIG_DATA' ORDER BY LOG_DATE DESC; -- 查看Job执行的详细信息(包括错误信息) SELECT LOG_ID, JOB_NAME, STATUS, ERROR#, ACTUAL_START_DATE, RUN_DURATION, ADDITIONAL_INFO FROM USER_SCHEDULER_JOB_RUN_DETAILS WHERE JOB_NAME = 'JOB_MIG_DATA' ORDER BY LOG_DATE DESC;USER_SCHEDULER_JOB_RUN_DETAILS里的ADDITIONAL_INFO字段会记录错误信息,如果任务失败了,这里能看到具体的错误码和错误描述。我一般会写一个监控脚本,每天早上检查一下前一天的执行状态,如果有FAILED的就发告警。
4.4 任务的修改与删除
需要调整执行时间或者修改调用的存储过程时,不用删了重建,直接改就行:
-- 修改Job的调度频率 BEGIN DBMS_SCHEDULER.SET_ATTRIBUTE( NAME => 'JOB_MIG_DATA', ATTRIBUTE => 'REPEAT_INTERVAL', VALUE => 'FREQ=DAILY; BYHOUR=3; BYMINUTE=0; BYSECOND=0' ); END; / -- 暂停Job BEGIN DBMS_SCHEDULER.DISABLE('JOB_MIG_DATA'); END; / -- 启用Job BEGIN DBMS_SCHEDULER.ENABLE('JOB_MIG_DATA'); END; / -- 删除Job BEGIN DBMS_SCHEDULER.DROP_JOB('JOB_MIG_DATA', TRUE); END; /DROP_JOB的第二个参数TRUE表示如果Job正在运行也强制删除。一般建议先DISABLE,等当前执行完了再DROP。
5. 实操过程中踩过的坑与排查技巧
5.1 权限问题:DBMS_SCHEDULER用不了
这是新手最容易遇到的问题。创建Job的时候报错"权限不足"或者"DBMS_SCHEDULER不存在"。
原因通常是当前用户没有执行DBMS_SCHEDULER的权限。达梦里需要单独授权:
-- 用SYSDBA执行 GRANT CREATE JOB TO YOUR_USER; GRANT EXECUTE ON DBMS_SCHEDULER TO YOUR_USER;另外,如果存储过程里涉及跨Schema访问表,还需要相应的SELECT/INSERT/UPDATE权限。达梦的权限体系跟Oracle类似,但有些细节不一样,比如达梦默认的PUBLIC权限比Oracle要小,很多在Oracle里能直接用的东西在达梦需要显式授权。
5.2 任务执行了但数据没迁过去
这种情况一般是存储过程内部出了异常,但异常被吞掉了。检查USER_SCHEDULER_JOB_RUN_DETAILS的ADDITIONAL_INFO字段,如果显示"SUCCESS"但数据没动,那可能是存储过程里的异常处理块把错误吃掉了。
我建议在存储过程的异常处理里,除了写日志表,还要用RAISE把异常重新抛出去。这样DBMS_SCHEDULER才能感知到失败,在Job日志里标记为FAILED。如果只是写日志不抛异常,调度器会认为任务成功执行了。
5.3 迁移速度越来越慢
刚开始跑的时候很快,跑着跑着速度就降下来了。这个通常是因为源表的MIG_FLAG字段没有索引。每次查询WHERE MIG_FLAG = 'N'都要全表扫描,数据量越大越慢。
CREATE INDEX IDX_SRC_MIG_FLAG ON SRC_TABLE(MIG_FLAG, ID);注意索引的顺序,MIG_FLAG在前,ID在后。因为查询条件是MIG_FLAG = 'N' AND ID >= ? AND ID < ?,把等值条件放在前面,范围条件放在后面,索引的选择性更好。
还有一个可能的原因是目标表的索引太多。每次INSERT都要维护索引,索引越多写入越慢。如果目标表是专门用来做迁移的中间表,可以考虑先禁用索引,迁移完成后再重建。
5.4 常见问题速查表
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| Job创建失败,报权限错误 | 缺少CREATE JOB权限 | 查USER_SYS_PRIVS | GRANT CREATE JOB TO user |
| Job执行成功但数据未迁移 | 存储过程异常被吞 | 查MIG_LOG表的ERR_MSG | 异常处理中加RAISE |
| 迁移速度逐渐变慢 | 源表MIG_FLAG无索引 | EXPLAIN查看执行计划 | 建复合索引(MIG_FLAG, ID) |
| 中文乱码 | 源和目标字符集不一致 | 查V$INSTANCE的字符集 | 建库时统一字符集 |
| 单批次undo超限 | 批次大小设置过大 | 查undo表空间使用率 | 调小P_BATCH_SIZE |
| 跨实例迁移超时 | 网络延迟高或DBLINK配置不当 | 测试网络延迟 | 调小批次,检查LINK配置 |
| Job日志显示FAILED但无详细信息 | 错误信息被截断 | 查ADDITIONAL_INFO字段 | 扩大ERR_MSG字段长度 |
| 重复迁移同一批数据 | 断点续传逻辑不完善 | 对比MIG_LOG和TGT_TABLE | INSERT加NOT EXISTS |
5.5 一个真实的排查案例
有一次客户反馈说迁移任务连续三天都失败了,错误信息是"-3236: 无效的游标状态"。这个错误号在达梦的文档里描述得很模糊,网上也搜不到太多有用的信息。
我上去排查,先看了USER_SCHEDULER_JOB_RUN_DETAILS,发现失败时间都在凌晨2点03分左右,也就是任务刚启动不久。然后查了MIG_LOG表,发现最后一条SUCCESS日志的MAX_ID是某个值,之后的批次都是FAILED。
进一步分析发现,源表在那个时间点正好有一个批量删除操作在跑,删除了大量MIG_FLAG = 'Y'的历史数据。存储过程在循环过程中,游标指向的数据被其他会话删除了,导致游标状态失效。
解决方案有两个:一是调整迁移任务的执行时间,避开数据清理窗口;二是在存储过程里加一个异常捕获,遇到游标失效时重新打开游标继续。我选了第一个方案,因为改时间最简单,而且从业务逻辑上讲,迁移和数据清理本来就不应该同时跑。
这个案例告诉我们,迁移任务的时间窗口规划很重要。你要清楚在这个时间段内,还有哪些任务在操作同一批表。如果有冲突,要么调整时间,要么在存储过程里做好并发控制。
6. 性能调优与进阶技巧
6.1 批量提交的优化
前面代码里用的是INSERT INTO ... SELECT的方式,这是达梦里批量插入最快的方式之一。但如果你需要更精细的控制,比如在插入前做一些数据转换,可以用BULK COLLECT加FORALL:
DECLARE TYPE T_ID IS TABLE OF SRC_TABLE.ID%TYPE; TYPE T_NAME IS TABLE OF SRC_TABLE.NAME%TYPE; V_IDS T_ID; V_NAMES T_NAME; CURSOR C_DATA IS SELECT ID, NAME FROM SRC_TABLE WHERE MIG_FLAG = 'N' AND ROWNUM <= 5000; BEGIN OPEN C_DATA; LOOP FETCH C_DATA BULK COLLECT INTO V_IDS, V_NAMES LIMIT 5000; EXIT WHEN V_IDS.COUNT = 0; FORALL i IN 1..V_IDS.COUNT INSERT INTO TGT_TABLE(ID, NAME) VALUES(V_IDS(i), V_NAMES(i)); COMMIT; END LOOP; CLOSE C_DATA; END;BULK COLLECT一次性把数据拉到PL/SQL的内存集合里,FORALL批量执行DML。这种方式比逐行处理快很多,但内存消耗也更大。5000行的集合大概占几MB内存,一般没问题。
6.2 并行度的考虑
达梦支持并行查询和并行DML。如果服务器CPU核数多,可以在存储过程里开启并行:
ALTER SESSION FORCE PARALLEL DML PARALLEL 4; ALTER SESSION FORCE PARALLEL QUERY PARALLEL 4;但并行不是万能的。如果源表和目标表在同一个磁盘上,并行读写反而会因为I/O竞争导致性能下降。另外,并行DML会占用更多的undo和临时表空间。我一般只在数据量特别大(千万级以上)且服务器资源充足的情况下才开并行。
6.3 迁移任务的幂等性设计
幂等性是指同一个任务执行多次,结果是一样的。这在迁移场景里非常重要,因为任务可能因为各种原因重跑。
除了前面提到的NOT EXISTS去重,还有一种更彻底的方式:在目标表上建唯一索引,然后用MERGE语句:
MERGE INTO TGT_TABLE T USING (SELECT ID, NAME, AMOUNT FROM SRC_TABLE WHERE ID >= V_CUR_ID AND ID < V_CUR_ID + P_BATCH_SIZE AND MIG_FLAG = 'N') S ON (T.ID = S.ID) WHEN MATCHED THEN UPDATE SET T.NAME = S.NAME, T.AMOUNT = S.AMOUNT WHEN NOT MATCHED THEN INSERT (ID, NAME, AMOUNT) VALUES(S.ID, S.NAME, S.AMOUNT);MERGE的好处是既能插入新数据,又能更新已存在的数据。如果你的迁移场景是"增量同步"而不是"一次性搬迁",MERGE是更好的选择。
6.4 监控与告警的集成
DBMS_SCHEDULER本身不提供告警功能,但你可以通过查询Job日志来实现。我一般会写一个独立的监控存储过程,每天定时检查前一天的迁移状态:
CREATE OR REPLACE PROCEDURE PROC_MIG_MONITOR AS V_FAIL_CNT INT; BEGIN SELECT COUNT(*) INTO V_FAIL_CNT FROM USER_SCHEDULER_JOB_RUN_DETAILS WHERE JOB_NAME = 'JOB_MIG_DATA' AND STATUS = 'FAILED' AND LOG_DATE >= TRUNC(SYSDATE) - 1; IF V_FAIL_CNT > 0 THEN -- 这里可以插入告警表,或者调用发邮件/短信的接口 INSERT INTO MIG_ALERT(ALERT_TIME, ALERT_MSG) VALUES(SYSDATE, '数据迁移任务失败' || V_FAIL_CNT || '次'); COMMIT; END IF; END;然后给这个监控存储过程也创建一个定时任务,每天早上8点跑一次。这样即使半夜迁移失败了,第二天上班也能第一时间知道。
6.5 大数据量迁移的分区策略
如果源表是分区表,迁移的时候可以利用分区裁剪来提升性能。比如按时间分区的表,可以每次只迁移一个分区的数据:
-- 查看分区信息 SELECT TABLE_NAME, PARTITION_NAME, HIGH_VALUE FROM USER_TAB_PARTITIONS WHERE TABLE_NAME = 'SRC_TABLE' ORDER BY PARTITION_POSITION; -- 按分区迁移 INSERT INTO TGT_TABLE SELECT * FROM SRC_TABLE PARTITION(P202401); COMMIT;按分区迁移的好处是每次处理的数据量可控,而且如果某个分区迁移失败,不影响其他分区。缺点是分区多了之后,存储过程里的循环逻辑会复杂一些。
实操心得:达梦的分区表语法跟Oracle高度兼容,但有些细节不一样。比如达梦不支持
INTERVAL分区(自动分区),需要手动添加分区。另外,达梦的分区索引类型(LOCAL/GLOBAL)的选择也会影响迁移性能,LOCAL索引在分区迁移时维护成本更低。
7. 一些个人体会
这套方案我在三个项目里实际用过,最长的跑了将近一年,每天迁移200万到500万行数据,没有出过数据丢失的问题。中间因为业务调整改过几次迁移逻辑,都是只改存储过程,调度任务没动过,维护成本很低。
如果你刚开始接触达梦的存储过程和DBMS_SCHEDULER,我的建议是先在测试环境把整个流程跑通。建两张测试表,写一个最简单的迁移存储过程,创建一个每分钟执行一次的Job,观察日志表的记录情况。确认没问题了,再把批次大小、执行频率这些参数调到生产环境的值。
另外,达梦的文档虽然不如Oracle的详细,但基本的语法和示例都有。遇到报错的时候,先查达梦的错误码手册,大部分问题都能找到答案。实在找不到的,去达梦的社区论坛搜一下,或者直接提工单,他们的技术支持响应速度还可以。
最后说一个容易被忽略的点:迁移完成后的数据校验。不要以为存储过程跑完了就万事大吉,一定要写一个校验脚本,对比源表和目标表的行数、关键字段的汇总值(比如SUM、COUNT DISTINCT)。我一般会在迁移存储过程的最后加一段校验逻辑,如果行数对不上就写一条WARNING日志。这个习惯帮我发现过好几次数据不一致的问题,都是因为源表在迁移过程中有并发写入导致的。