简介:面向需要在 Apache Flink 中接入达梦数据库的开发者与数据平台团队,这份压缩包围绕 FlinkCDC 基于日志的实时同步场景,提供可直接参考的工程样例与配置素材。包内共 315 个文件,以 263 个 jar 依赖、若干 xml 配置、class 与 java 源码为主,另含 sql 脚本、properties 参数文件及 docx 说明,压缩包约 341.71MB,便于还原连接器运行环境与项目结构。已有 776 人学习下载。读者可从中了解达梦日志捕获、插入更新删除变更同步到 Flink 流处理链路的基本组织方式,结合 Java 作业与 SQL 两种接入思路,查看反序列化、统计工具、CDC 主流程等模块的代码骨架,并据此排查连接参数、依赖版本与作业启动问题。适合具备一定 Flink 与数据库基础、需要搭建实时同步原型或课程实验环境的中高级读者参考。
1. 官方 connector 清单里翻不到达梦,FlinkCDC 的日志同步照样能落地
先说结论:想在 FlinkCDC 官方那套 connector 清单里找达梦数据库的条目,基本会扑空,照着抄一句connector = 'dm-cdc'的建表语句大概率直接报类找不到。但「基于日志实时同步」这件事在达梦上完全成立——达梦有归档日志、有行级逻辑日志、有DBMS_LOGMNR日志挖掘包,这三样串起来就是一条能用的变更流。真正要干的不是找现成 connector,而是把达梦日志翻译成 Flink 认识的 changelog,剩下的去重、分流、落仓交给 Flink。这篇内容适合正在做信创替换、存量 DM 库要实时入仓、又不想退回定时抽数的人,也适合已经被「按时间戳轮询漏更新」坑过一轮的团队。
2. 打开达梦的行级逻辑日志:归档、级别与可挖掘性验证
2.1 归档和逻辑日志是两个开关,缺一个都白搭
很多人第一次配达梦 CDC 会翻车在同一处:只改了RLOG_APPEND_LOGIC,以为日志里就有行了,结果归档目录里空空如也,或者归档里挖出来全是物理页号。
这两件事的职责要分清。归档负责「日志留不留、留多久」,由ARCH_INI加dmarch.ini控制;逻辑日志负责「redo 里带不带行级的前后值」,由RLOG_APPEND_LOGIC控制。前者是载体,后者是内容,少任何一个都做不成变更捕获。更麻烦的是这两个参数在多数小版本里都是静态参数,改完必须重启实例才生效,动态SP_SET_PARA_VALUE改完看着成功了、实际没落地,这是最常见的假象。
我一般在dm.ini里固定这么几项:
| 参数 | 默认 | 建议值 | 作用 | 是否需重启 |
|---|---|---|---|---|
ARCH_INI | 0 | 1 | 启用归档配置,读取dmarch.ini | 是 |
RLOG_APPEND_LOGIC | 0 | 2 | 行级逻辑日志级别,2 表示记录全部列 | 是 |
RLOG_APPEND_OPTIMIZE | 0 | 1 | 合并部分冗余逻辑记录,减小日志体积 | 是 |
LOG_BUFFER_SIZE | 8 | 64 | redo 缓冲区大小,单位 MB | 是 |
SVR_LOG | 0 | 视情况 | 服务端 SQL 日志,仅排错期开 | 否 |
dmarch.ini里至少要有本地归档一段,否则开了ARCH_INI也不会落盘:
[ARCHIVE_LOCAL1] ARCH_TYPE = LOCAL ARCH_DEST = /dmdata/arch ARCH_FILE_SIZE = 2048 ARCH_SPACE_LIMIT = 0 ARCH_RESERVE_TIME = 168ARCH_SPACE_LIMIT = 0表示不做空间上限硬限制,生产上我反而建议给个上限并配合监控,避免归档把盘吃满导致实例挂起;ARCH_RESERVE_TIME单位是小时,这里给 168 小时即七天,这个值必须大于「同步链路最长可能停机时间」,否则停机超过保留期就再也补不回来了,位点直接断档。改完这两处,重启实例,再回到数据库里确认参数真的落到内存里:
-- 确认参数已生效,注意 PARA_VALUE 是运行值 SELECT PARA_NAME, PARA_VALUE FROM V$DM_INI WHERE PARA_NAME IN ('ARCH_INI','RLOG_APPEND_LOGIC','RLOG_APPEND_OPTIMIZE','LOG_BUFFER_SIZE'); -- 确认归档配置被识别 SELECT ARCH_NAME, ARCH_TYPE, ARCH_DEST, ARCH_SPACE_LIMIT FROM V$DM_ARCH_INI;V$DM_INI里看到的是运行值,改完没重启的话PARA_VALUE还是旧值——这一行查询就是最省事的「后悔药」,别跳过。V$DM_ARCH_INI返回空行说明dmarch.ini没被加载,常见原因是文件放在实例目录但路径大小写不对,或者ARCH_INI没生效。
2.2 用 DBMS_LOGMNR 先把「能不能挖」验证掉
参数配好之后不要急着搭 Flink 作业,先证明日志挖得出来。达梦的DBMS_LOGMNR包用法和 Oracle 那套很像,但系统包默认不一定存在,需要先创建一次:
-- 系统包只建一次,重复执行会提示已存在,忽略即可 SP_CREATE_SYSTEM_PACKAGES(1);然后按「加载日志文件 → 启动挖掘 → 查内容 → 结束挖掘」四步走:
DECLARE BEGIN -- NEW 表示清空文件列表后重新加,一次挖掘只调一次 DBMS_LOGMNR.ADD_LOGFILE('/dmdata/arch/ARCHIVE_LOCAL1_20250101.log', DBMS_LOGMNR.NEW); -- DICT_FROM_ONLINE_CATALOG 用当前在线字典解析,不需要单独导入字典 DBMS_LOGMNR.START_LOGMNR(OPTIONS => DBMS_LOGMNR.DICT_FROM_ONLINE_CATALOG); END; / -- 只捞一张业务表看 20 行,重点看 UPDATE 的前后值是否完整 SELECT SCN, COMMIT_SCN, TIMESTAMP, OPERATION, SEG_OWNER, TABLE_NAME, SQL_REDO FROM V$LOGMNR_CONTENTS WHERE SEG_OWNER = 'BIZ' AND TABLE_NAME = 'T_ORDER' AND ROWNUM <= 20 ORDER BY COMMIT_SCN, SCN; EXEC DBMS_LOGMNR.END_LOGMNR();这段验证覆盖三个关键点:OPERATION里是否出现INSERT、UPDATE、DELETE三种;UPDATE的记录里能不能同时看到改前和改后的列值;COMMIT_SCN是不是非空且单调递增。三个都成立,说明日志侧准备工作完成,可以进下一步;只出现INSERT而没有UPDATE的完整列,八成是RLOG_APPEND_LOGIC还停在 1。
START_LOGMNR的OPTIONS参数我一般只用DICT_FROM_ONLINE_CATALOG。用在线字典解析性能好、不需要额外导入字典文件,代价是历史日志遇到 DDL 之后会解析失败——跨 DDL 的日志段必须换成导入字典的方式重挖,这个坑在避坑章节展开。另外包内过程的参数名在不同小版本之间有出入,写代码前先用SELECT TEXT FROM USER_SOURCE WHERE NAME='DBMS_LOGMNR'把本机定义确认一遍,比照文档抄稳妥得多。
2.3 日志级别 1/2/3 怎么选,以及主键这件事
RLOG_APPEND_LOGIC的取值不是越大越好,它是拿存储和写入开销换信息量:
| 级别 | 记录内容 | 日志增量 | 典型用途 |
|---|---|---|---|
| 0 | 不记录行级逻辑信息 | 无 | 不做 CDC |
| 1 | 仅主键/行标识 | 小 | 只判断「这行变过」,拿不到列值 |
| 2 | 全部列的前后值 | 中等 | 主流选择,能做 upsert |
| 3 | 全部列 + 附加行定位信息 | 较大 | 无主键表、需要精确定位的场景 |
多数同步场景我会选 2。选 3 的典型理由是无主键表要靠额外信息定位行,但更正确的做法是给业务表补主键或唯一约束——逻辑日志里没有稳定的行标识,下游的 upsert 就无从谈起,这属于用参数补数据模型欠账,迟早要还。
补主键这一步别想着「以后再说」。表没有主键时,V$LOGMNR_CONTENTS里 UPDATE 的记录缺少可靠定位字段,解析器只能退化成「整表按时间戳覆盖」,那就不是日志同步了。上线前把待同步表清单拉出来,逐张确认主键或唯一索引,比后面排查数据重复省事十倍。
3. 把日志挖成变更流:位点、事务边界与 Kafka 投递
3.1 位点用 COMMIT_SCN,不要用行级 SCN
这一步是整条链路最容易做错的地方。V$LOGMNR_CONTENTS里每条记录都带SCN,看起来很自然地想拿它当位点:读到哪条记哪条。问题在于行级 SCN 反映的是「这个块被改的时间」,不是「这个事务提交的时间」。一个大事务改了几万行、最后回滚,行级记录照样在日志里;另一个长事务先改后提交,行 SCN 早、提交晚。用行 SCN 做位点,下游就会出现「先看到后提交的数据」和「看到最终被回滚的数据」两种不一致。
正确做法是以COMMIT_SCN作为对外可见的位点,并且输出顺序按COMMIT_SCN, SCN排:
SELECT COMMIT_SCN, SCN, TIMESTAMP, OPERATION, SEG_OWNER, TABLE_NAME, SQL_REDO FROM V$LOGMNR_CONTENTS WHERE OPERATION IN ('INSERT','UPDATE','DELETE') AND COMMIT_SCN > ? -- 上次消费到的位点 AND COMMIT_SCN <= ? -- 本次挖掘上界 ORDER BY COMMIT_SCN, SCN;COMMIT_SCN为空的记录属于未提交事务,直接过滤掉,不要试图缓存起来等——解析器是短生命周期进程,缓存没有意义,下一次挖掘它们会带着真正的COMMIT_SCN重新出现。位点表我一般单独建一张SYNC_OFFSET,字段就TASK_NAME和LAST_SCN两个,用MERGE INTO写,保证多次执行只留一行:
CREATE TABLE SYNC_OFFSET ( TASK_NAME VARCHAR(64) PRIMARY KEY, LAST_SCN BIGINT, UPD_TIME TIMESTAMP DEFAULT SYSDATE );位点必须和消息投递在同一个事务语义下推进。常见做法是「先发 Kafka 并等 ack,再更新位点」,中间进程挂掉会重发一批消息,靠下游 upsert 的幂等性兜住;反过来「先更新位点再发」则会丢数据,丢数据比重复难查得多,我宁可重复。
3.2 一条变更消息长什么样
消息结构决定了 Flink 侧能不能直接用 upsert 语义,所以字段设计要一次到位。我一般拆成两个主题:dm_cdc_audit存完整的 before/after,用于审计和对账;dm_cdc_upsert只存变更后的整行,删除时发一条 value 为 null 的墓碑消息。后者是给 Flink 的upsert-kafka直接消费的,前者留着出问题时当黑匣子翻。
dm_cdc_audit的主题体:
{ "schema": "BIZ", "table": "T_ORDER", "op": "U", "scn": 88213344, "commit_scn": 88213390, "ts": "2025-01-01 10:00:00.123", "key": "BIZ.T_ORDER:10086", "before": {"ID": 10086, "AMOUNT": 10.00, "STATUS": "NEW"}, "after": {"ID": 10086, "AMOUNT": 12.00, "STATUS": "PAID"} }| 字段 | 含义 | 生成规则 |
|---|---|---|
op | 操作类型 | I/U/D三选一,由OPERATION映射 |
key | 全局行标识 | schema.table:主键值,多列主键用 ` |
commit_scn | 事务提交位点 | 直接取COMMIT_SCN,用于排序和断点 |
before | 变更前整行 | 仅U/D有,I时为 null |
after | 变更后整行 | 仅I/U有,D时为 null |
key的生成规则必须全局统一,大小写、前后空格、数值型主键的字符串化方式都要在解析器里固定死。下游 Flink 的去重和 upsert 全靠这个 key,一旦同一个主键生成了两种写法,就会变成两条独立的行,这种重复用肉眼几乎发现不了,只能靠对账。
3.3 解析器骨架:一个能跑起来的最小闭环
解析器不必上重型框架,一个定时循环加上位点表就够跑通。下面这段是骨架,用达梦官方 Python 驱动连库、kafka-python 投递:
import json, time import dmPython from kafka import KafkaProducer BATCH_SCN_SPAN = 200000 # 单次挖掘的 SCN 跨度,按日志量调 TASK = "biz_order_to_dw" producer = KafkaProducer( bootstrap_servers=["10.0.0.21:9092", "10.0.0.22:9092"], acks="all", # 至少等所有 ISR 落盘 enable_idempotence=True, # 开启幂等,避免重试产生乱序 key_serializer=lambda k: k.encode("utf-8"), value_serializer=lambda v: json.dumps(v, ensure_ascii=False).encode("utf-8") if v is not None else None, ) def read_offset(cur): cur.execute("SELECT LAST_SCN FROM SYNC_OFFSET WHERE TASK_NAME = ?", (TASK,)) row = cur.fetchone() return int(row[0]) if row else 0 def write_offset(conn, cur, scn): cur.execute( "MERGE INTO SYNC_OFFSET t " "USING (SELECT ? AS TASK_NAME, ? AS LAST_SCN FROM DUAL) s " "ON (t.TASK_NAME = s.TASK_NAME) " "WHEN MATCHED THEN UPDATE SET t.LAST_SCN = s.LAST_SCN, t.UPD_TIME = SYSDATE " "WHEN NOT MATCHED THEN INSERT (TASK_NAME, LAST_SCN) VALUES (s.TASK_NAME, s.LAST_SCN)", (TASK, scn), ) conn.commit() def mine_once(conn, cur, start_scn): end_scn = start_scn + BATCH_SCN_SPAN # 调用包内过程,参数顺序为 起始SCN、结束SCN、选项 cur.execute("{call DBMS_LOGMNR.START_LOGMNR(?, ?, ?)}", (start_scn, end_scn, 0)) cur.execute( "SELECT COMMIT_SCN, SCN, OPERATION, SEG_OWNER, TABLE_NAME, SQL_REDO " " FROM V$LOGMNR_CONTENTS " " WHERE OPERATION IN ('INSERT','UPDATE','DELETE') " " AND SEG_OWNER = 'BIZ' AND COMMIT_SCN IS NOT NULL " " ORDER BY COMMIT_SCN, SCN" ) max_scn, sent = start_scn, 0 while True: rows = cur.fetchmany(500) # 分批取,避免一次性拉爆内存 if not rows: break for commit_scn, scn, op, owner, table, redo in rows: key = f"{owner}.{table}:{scn}" # 示例:真实场景用主键值,此处仅占位 payload = { "schema": owner, "table": table, "op": op[0], "scn": int(scn), "commit_scn": int(commit_scn), "key": key, "sql_redo": redo, } producer.send("dm_cdc_audit", key=key, value=payload) sent += 1 max_scn = max(max_scn, int(commit_scn)) producer.flush() cur.execute("{call DBMS_LOGMNR.END_LOGMNR()}") return max_scn, sent if __name__ == "__main__": conn = dmPython.connect(user="SYNC_USER", password="******", server="10.0.0.10", port=5236) cur = conn.cursor() while True: offset = read_offset(cur) new_offset, sent = mine_once(conn, cur, offset) if sent: write_offset(conn, cur, new_offset) # 先发后记,重复由下游幂等兜住 time.sleep(1) # 无变更时歇一秒,别空转打满 CPU逻辑上有三处值得展开。START_LOGMNR的结束 SCN 我用start_scn + BATCH_SCN_SPAN推进,而不是挖到当前时刻,好处是每次工作量可控、失败重试只影响一小段,坏处是延迟取决于循环间隔,一秒一轮、跨度二十万 SCN,多数业务量下延迟在秒级,够用。acks="all"加enable_idempotence=True是 Kafka 生产端的基本配置,前者保证消息落到所有同步副本,后者保证重试不会产生乱序,两个一起开才能和「先发后记位点」的重复投递策略配套。producer.flush()放在更新位点之前,保证这一批要么全发出去,要么位点不动,不会出现「位点前进了但消息还压在客户端缓冲区」的情况。
参数上最需要按现场调的是BATCH_SCN_SPAN和fetchmany的批大小。SCN 跨度太小会导致挖掘调用过于频繁、每次都要重新加载日志文件,吞吐上不去;跨度太大会让单次事务变长、失败重试代价高。经验做法是先按 20 万跑,观察单次耗时,稳定在 1 秒以内就保持,超过 2 秒就减半。
4. Flink 侧接流:upsert-kafka 建表、按表分流与落仓
4.1 为什么用 upsert-kafka 而不是普通 kafka source
普通kafkaconnector 在 Flink SQL 里被当作 append 流处理,它不知道消息之间存在「同一个主键的后一次覆盖前一次」这层关系,你要自己去重就得开状态、写ROW_NUMBER(),而这在无界流上是没有正确语义的——ROW_NUMBER只是逐条处理,不会因为后到的同主键数据而撤回之前的结果。
upsert-kafka把这件事做进了 connector 内部:它要求声明主键,按主键把消息解释成+I、-U、+U、-D,value 为 null 时生成-D。配合解析器「只发变更后整行、删除发墓碑」的设计,链路上天然就是幂等的,同一条消息重复投递多少次,最终结果都一样。
这里有个前提必须对齐:upsert-kafka的主键字段和消息里 key 的生成规则要一致。我在三章里强调 key 统一,原因就在这里——key 格式一乱,upsert-kafka就认不出是同一行,去重直接失效。
4.2 建表与按表分流
源表声明三个字段组成主键,对应解析器输出的schema、table、pk:
CREATE TABLE dm_cdc_upsert ( `schema_name` STRING, `table_name` STRING, `pk_value` STRING, `row_json` STRING, -- 变更后整行的 JSON,删除时为 NULL `op` STRING, `commit_scn` BIGINT, `ts` TIMESTAMP(3) METADATA FROM 'timestamp', PRIMARY KEY (`schema_name`, `table_name`, `pk_value`) NOT ENFORCED ) WITH ( 'connector' = 'upsert-kafka', 'topic' = 'dm_cdc_upsert', 'properties.bootstrap.servers' = '10.0.0.21:9092,10.0.0.22:9092', 'key.format' = 'json', 'key.json.ignore-parse-errors' = 'true', 'value.format' = 'json', 'value.json.fail-on-missing-field' = 'false', 'properties.auto.offset.reset' = 'earliest', 'properties.enable.auto.commit' = 'false' );PRIMARY KEY ... NOT ENFORCED是声明式主键,Flink 不会真去校验,但 upsert 语义靠它建立。auto.commit关掉是必须的:让 Flink 自己用 checkpoint 管理 offset,否则作业重启时可能出现「offset 已提交但状态没做快照」的错位。auto.offset.reset = earliest只在新作业首次启动、没有 checkpoint 时生效,别指望它能解决位点回退。
分流我用视图做,每张业务表一个视图,把 JSON 里的字段摊平并转型:
CREATE VIEW v_order AS SELECT CAST(JSON_VALUE(`row_json`, '$.ID') AS BIGINT) AS id, CAST(JSON_VALUE(`row_json`, '$.AMOUNT') AS DECIMAL(18,2)) AS amount, JSON_VALUE(`row_json`, '$.STATUS') AS status, `commit_scn`, `ts` FROM dm_cdc_upsert WHERE `schema_name` = 'BIZ' AND `table_name` = 'T_ORDER';用视图而不是在 sink 里写过滤条件,好处是每张表的字段映射独立演进,新增一张表就是加一个视图加一个 INSERT,不用动源表定义。JSON_VALUE取不到值时返回 null 而不是报错,这一点比JSON_QUERY稳妥,字段增删不会把整个作业打挂。代价是每加一个字段都要改视图,这个人工成本我认为值得——字段类型从字符串转成 DECIMAL 这类事,让 Flink 在 SQL 层显式做,比在解析器里隐式猜类型可控。
4.3 写目标端与几个必调参数
目标端用 JDBC sink,主键声明后自动走 upsert:
CREATE TABLE sink_order ( id BIGINT NOT NULL, amount DECIMAL(18,2), status STRING, updated_at TIMESTAMP(3), PRIMARY KEY (id) NOT ENFORCED ) WITH ( 'connector' = 'jdbc', 'url' = 'jdbc:mysql://10.0.0.31:3306/dw', 'table-name' = 'dwd_order', 'username' = 'flink_rw', 'password' = '******', 'sink.buffer-flush.max-rows' = '2000', 'sink.buffer-flush.interval' = '2s', 'sink.max-retries' = '3' ); INSERT INTO sink_order SELECT id, amount, status, `ts` FROM v_order;sink.buffer-flush.max-rows和interval是一对:满足任一条件就刷一次。批量大能提吞吐,但会放大故障时的重复窗口,2000 行 / 2 秒是我在中等数据量下的常用组合,数据量再大就往上调,别一次调到几万,那样写入失败重试的代价太高。sink.max-retries = 3只覆盖可重试异常,主键冲突这类逻辑错误不会重试,会直接把作业打挂,这是好事,别为了「不挂」把重试次数调高。
作业级别还有两个参数要显式设置:execution.checkpointing.interval我一般给 30 秒到 1 分钟,太短会让 upsert-kafka 频繁做快照、拖慢吞吐;table.exec.state.ttl给 24 小时以上,去重和聚合状态都要靠它兜底,设太短会导致重启后状态丢失、重复计算。并行度按 Kafka 分区数对齐,源端并行度超过分区数会有空闲 subtask,白白占资源。
如果后续达梦的 source connector 生态补齐了,整库同步可以直接换成 Flink CDC pipeline 的 yaml 写法,route规则和今天这套「按表分流」是一一对应的,迁移成本主要在字段映射上。现阶段用 Kafka 承接反而多了一层缓冲,源库出问题时变更消息还在主题里,补数不用回源重挖日志。
5. 避坑:五类翻车场景的现象、原因与解决
5.1 开了逻辑日志,UPDATE 却只挖到主键
现象:V$LOGMNR_CONTENTS里能看到UPDATE操作,但SQL_REDO只有主键列,改动的业务字段完全缺失,下游更新后数据看起来「没变」。
原因:RLOG_APPEND_LOGIC停在 1。级别 1 只记录行标识,天然没有列值,参数改完没重启也可能停留在旧值。
解决:先查V$DM_INI确认运行值是 2 而不是配置文件里写了 2,再重启实例。重启后拿一笔测试更新重新挖一遍验证,别直接放开全量同步——日志级别变更只在变更之后的日志里生效,历史日志挖不出列值,位点要重新对齐。
5.2 无主键表把 UPDATE 变成了 INSERT
现象:下游目标表里同一业务行出现多条记录,主键列值相同但其他字段是不同时间点的快照,越跑越多。
原因:源表没有主键或唯一索引,逻辑日志缺少稳定的行定位信息,解析器退化成用SCN或ROWID拼 key,每次更新都生成新 key,upsert-kafka认为是新行。
解决:给源表补主键,这是唯一正确的路。实在不能改表结构,就在解析器里用「全部业务列」做联合 key,代价是同值多行无法区分、删除操作基本失效。这种妥协要写进交接文档,别让下一个人以为链路是完整的。
5.3 归档被清理,位点再也追不上
现象:解析器重启后报日志文件不存在,或者挖出来的记录从某个时间点之后突然空了一大段,对账发现中间少了几小时数据。
原因:归档保留策略太激进,ARCH_RESERVE_TIME设得比最长停机时间短,或者磁盘满导致归档写入失败被跳过。
解决:ARCH_RESERVE_TIME至少覆盖「同步链路可能停摆的最长时间 × 2」,同时给归档目录加容量告警。已经断档的情况只能重新做一次全量加增量对齐,没有别的办法——这也是为什么位点表要单独持久化,至少要能准确知道断在哪。
5.4 大事务把 Kafka 打爆、Flink 持续背压
现象:业务侧跑一次批量更新几十万行,Kafka 主题瞬时写入飙升,Flink 作业背压指标拉满,checkpoint 超时失败,然后作业反复重启。
原因:解析器按 SCN 区间一次性把整个事务的所有行都投出去,没有限速;同时下游 sink 的批量参数偏小,吞吐跟不上。
解决:解析器侧按批send并在每批后做一次flush加短暂 sleep,把瞬时压力摊平;Flink 侧调大 sink 批量、增加 Kafka 分区数并同步提高作业并行度。checkpoint 超时可以把execution.checkpointing.timeout适度放宽,但这是缓解不是根治,根本还是要控制单次事务的消息体量。
5.5 跨 DDL 之后解析全部失败
现象:源库做了一次加字段的变更,之后解析器报字典相关错误,或者新字段一直取不到值。
原因:START_LOGMNR用DICT_FROM_ONLINE_CATALOG,拿当前字典去解析 DDL 之前的日志,表结构对不上。DDL 之后的日志因为视图里没有新字段,JSON_VALUE取不到值返回 null。
解决:DDL 必须纳入发布流程,改表结构前先停同步、记录位点,改完再启动并同步更新 Flink 侧视图定义。跨 DDL 的历史日志要用导入字典的方式重挖,不能继续用在线字典。这条听起来很重,但比起数据错位后再去对账,停十分钟划算得多。
6. 上线前的校验清单与一个并行挖掘的提速技巧
链路跑通不等于可以上线。我习惯在切生产前走一遍下面这张表,每一项都要有明确的验证动作和通过标准,不能靠「看起来正常」:
| 校验项 | 验证动作 | 通过标准 |
|---|---|---|
| 日志级别 | 查V$DM_INI运行值 | RLOG_APPEND_LOGIC = 2且重启后未回退 |
| 主键覆盖 | 导出待同步表清单逐张核对 | 全部有主键或唯一索引 |
| 位点连续性 | 手工 kill 解析器再拉起 | 位点从上次COMMIT_SCN续上,无重复无丢失 |
| 顺序正确性 | 同一主键连续改三次后查目标表 | 目标表是最新值,中间态不残留 |
| 删除语义 | 源库删一行 | 目标表对应行消失,非逻辑删除残留 |
| 归档容量 | 观察三日归档日增量 | 保留期内不会被清理,磁盘余量大于 30% |
| 背压 | 压测期间看 Flink 背压指标 | 无持续 high 背压,checkpoint 全部成功 |
最后说一个提速技巧:单实例的DBMS_LOGMNR挖掘是串行的,日志量大时它会是瓶颈。可以把归档文件按时间切成若干段,每段起一个独立解析进程并行挖,各自产出到不同的 Kafka 分区,但位点表必须按「段」独立记录,合并时以段内最大COMMIT_SCN为准。这么做的代价是跨段的事务顺序无法保证,所以只适合按主键分区、下游本来就是 upsert 的场景;如果业务要求全局严格有序,就别拆,老老实实单进程跑,把 SCN 跨度调大比并行更安全。
这套东西我从第一次配达梦日志同步时踩到「参数改了没重启」开始,前后翻车过好几轮,最惨的一次是归档被清导致断档两天,对账对了整整一周。从那以后我每次上线前都强制走一遍上面那张表,尤其是位点连续性那一项,宁可手工 kill 两次进程,也不赌它「应该没问题」。希望帮到你。
本文还有配套的精品资源,点击获取