☰
FlinkCDC对接达梦数据库:日志解析与断点续传实践
2026/10/28 10:54:13 网站建设 项目流程

简介:面向使用达梦数据库并希望接入Flink实时链路的开发者与数据工程师,这份资料包聚焦基于日志解析的CDC同步方案,可用于数据仓库增量同步、实时报表与监控告警等场景的原型搭建。包内共5个文件,由2个jar、1个zip、1个sql脚本和1个docx文档组成,压缩包约35.48MB;其中jar涉及CDC连接器与JDBC驱动,zip提供连接器及参考程序,sql用于SQL Client初始化,docx则整理使用说明。已有2091人学习下载。读者可据此了解达梦数据库变更数据捕获的连接参数配置、插入更新删除事件的实时同步,以及JAVA作业与SQL两种接入方式,快速复现从达梦到Flink的流处理链路,并为后续排错与扩展提供参照。

1. 一条日志到一行数据的距离,比想象中远

很多人第一次接 FlinkCDC 和达梦数据库做实时同步,都会以为配好连接串、选好表,剩下就是“把日志读出来转发一下”。真正跑起来才发现,达梦数据库的归档日志是页级别的物理重做记录,里面没有表名、没有主键、也没有事务边界,必须先经过一次逻辑解析,才能变成 FlinkCDC 能消费的行级变更。基于日志解析的实时同步,难点从来不在 Flink 侧,而在“日志到行”这一段翻译。下面就把这条链路拆开:解析器读了什么、位点为什么能保证断点续传、哪些配置会让同步悄无声息地停掉。

2. FlinkCDC 的读取骨架:快照和增量怎么被缝成一条流

FlinkCDC 这个名字容易让人产生一种错觉,好像它是一个“连接器”。实际上它更像一套约定:上游要能提供全量快照、要能提供增量变更、还要能把这两段拼成一条不间断的流,并且让 Flink 的 checkpoint 有机会把“读到哪了”记下来。理解了这套约定,才看得懂达梦这种日志解析型数据源该怎么接进来。

2.1 为什么 CDC Source 不能是普通 SourceFunction

早期写 Flink 自定义 Source,大家习惯继承RichSourceFunction,在run()方法里一个while(true)循环往外collect。这套写法在 CDC 场景下几乎是必然出问题的,原因有三个。

一是并行度。RichSourceFunction的每个并行子任务都要自己决定读哪部分数据,而 CDC 的全量阶段需要按主键区间切分,增量阶段又只能有一个实例去读同一段日志,两个阶段对并行的诉求完全相反。二是位点。collect出去就算完事,Flink 并不知道这条数据对应日志里的哪个位置,checkpoint 时没有东西可存,作业失败重启只能从头再来。三是背压。日志解析往往是推模式,解析器源源不断产出,下游反压传不回来,内存里堆的变更事件很快会把 TaskManager 撑爆。

FLIP-27 引入的新 Source API 正是为了解决这类问题。它把“发现有哪些分片”(SplitEnumerator)和“读取某个分片”(SourceReader)拆开,枚举器跑在 JobManager 侧统一决策,读取器跑在 TaskManager 侧只负责消费。对应到 CDC,全量阶段的每个主键区间是一个分片,增量阶段的日志流也是一个(或一组)分片,两者都用同一套分片生命周期管理,位点就是分片的当前进度。

示意性的骨架长这样:

// 枚举器:负责把表切成主键区间,并在快照结束后追加增量分片 public class DmCdcEnumerator implements SplitEnumerator<DmSplit, DmCheckpoint> { @Override public void start() { // 1. 读取上次 checkpoint 里的位点,没有则从快照开始 // 2. 按主键上下界把每张表切成 N 个区间分片 // 3. 通过 addSplits 分发给空闲的 reader } @Override public void handleSplitRequest(int subtaskId, String requesterHostname) { // reader 主动要活干时,从待分配队列里取一个分片给它 // 快照分片发完之后,才把增量分片发出去,保证顺序 } }

DmSplit这个类型通常设计成一个带状态的联合体:快照分片里存表名、主键下界、主键上界;增量分片里存起始 SCN、当前日志文件名。枚举器不需要知道日志怎么解析,它只负责“什么时候该发哪种分片”,这个职责边界划清楚之后,整个 Source 才不至于变成一坨。

2.2 快照切分、水位线与增量交接

全量和增量的交接是 CDC 最容易出错的地方,也是最容易被忽略的地方。如果先做全量、做完再从“当前时刻”开始读增量,那么全量期间产生的变更就永久丢了;如果先记一个位点再做全量,全量期间同一条记录被改多次,回放时又要靠主键去重。业界比较通行的做法是先记位点、再做全量、最后从位点回放增量,用下游的幂等写入吸收重复。

具体到达梦这个场景,交接逻辑大致是这样几条:

第一步,枚举器启动时先向数据库问一次当前的 SCN(或者等价的日志序列号),把它作为增量起点记下来,这个动作必须在任何全量读取之前完成。

第二步,按主键区间并发拉全量。这里有个细节值得说:分片边界不能简单用min(id)和max(id)均分,因为主键可能有大量空洞,均分会导致某些分片几乎没数据、某些分片撑死。常见的实现思路是先采样一批边界值,或者用ROW_NUMBER()按行数切,保证每个分片的实际数据量接近。

第三步,全量分片全部读完之后,枚举器把增量分片发出去,起点就是第一步记下的那个 SCN。此时增量日志里既有全量期间发生的变更,也可能包含全量已经读到的最新值,重复部分靠主键覆盖解决。

-- 切分快照区间的典型写法:按行数而非主键值域均分 SELECT MIN(id) AS lo, MAX(id) AS hi FROM ( SELECT id, NTILE(8) OVER (ORDER BY id) AS bucket FROM t_order ) g GROUP BY bucket ORDER BY lo;

NTILE(8)表示切成 8 个桶,对应 8 个快照分片,桶内再取上下界。这样切出来的边界在数据分布倾斜时也不会太离谱,代价是要扫一遍索引,对大表来说这一次扫描的成本得算进去。参数上,分片数一般跟 Source 并行度对齐,或者略大于并行度,留一点动态调度的余量。

提示:位点必须在全量读取之前取,而且取位点和开始读全量之间不能有别的 DDL 操作,否则增量回放阶段会遇到表结构对不上的情况。

3. 达梦日志解析:物理重做记录怎么变成行级变更

搞清楚 Flink 侧的骨架之后,真正硬的部分才浮出水面:达梦数据库的日志本身不是给人看的。它记录的是“某个数据页的某个偏移量被改成了什么”,而不是“订单表 id=1001 这一行的状态从 A 变成了 B”。要把它翻译成行级变更,中间隔着一层逻辑解析。

3.1 归档日志、联机日志与解析入口

达梦的日志体系跟大多数关系型数据库一样,分联机日志和归档日志。联机日志是循环写的,写满一圈就覆盖最老的;归档日志是把联机日志切换出来的历史副本,只有开了归档才能长期保留。做 CDC 同步,必须开归档,这一点没有商量余地,理由很直接:解析器要按顺序从头往后读,联机日志随时可能被覆盖,读到一半发现文件没了,位点就断了。

光开归档还不够。达梦的默认重做日志里只有物理信息,解析器要靠额外的逻辑信息才能还原出行级变更,所以还有一个控制“日志里记不记逻辑信息”的开关。常见的实现思路是把它设成记录全部字段(包括大字段),代价是日志量会明显上涨,redo 文件切换更频繁,磁盘 IO 压力也跟着上去。这是一个典型的取舍:日志越详细,解析越容易,但数据库本身的写入开销越大。

# dmarch.ini 里的归档配置示意(参数名以实际版本为准) [ARCHIVE_LOCAL1] ARCH_TYPE = LOCAL ARCH_DEST = /dmdata/arch ARCH_FILE_SIZE = 1024 ARCH_SPACE_LIMIT = 0 # 0 表示不限制,实际生产必须设上限

ARCH_SPACE_LIMIT = 0这行看着省事,其实是很多线上事故的源头。归档目录不设上限,磁盘写满之后数据库会直接挂起,同步任务跟着一起停。生产环境一定要给归档留足空间并设置清理策略,而且清理策略的保留时长必须大于“同步任务可能的最长中断时间”,否则任务停机两天再起来,位点对应的日志已经被删了,只能重做全量。

解析入口一般是一组系统包,调用顺序是:先把要解析的日志文件加进去,然后指定解析的起止范围,接着启动解析,最后逐条取结果。这套接口的形态跟 Oracle 的 LogMiner 非常接近,熟悉那一套的人上手会很快。关键在于“取结果”这一步是游标式的,一次取一批,取完要主动关闭,否则会话会一直挂着占用资源。

3.2 事务重组与 SCN 推进

单个日志记录翻译出来只是“某行某列变了”,还不能直接下发。原因是事务还没提交——如果这时候就把变更发给下游,事务回滚了怎么办?所以解析器必须做事务重组:把同一个事务 ID 的变更先攒在内存里,直到读到该事务的提交记录,才把整批变更按顺序吐出去。

// 事务重组缓冲区的核心逻辑示意 Map<String, List<ChangeEvent>> txBuffer = new HashMap<>(); void onLogRecord(LogRecord r) { if (r.isCommit()) { List<ChangeEvent> events = txBuffer.remove(r.getXid()); if (events != null) { events.forEach(this::emit); // 只有提交了才向下游发 } committedScn = r.getScn(); // 位点只在提交后推进 } else if (r.isRollback()) { txBuffer.remove(r.getXid()); // 回滚直接丢弃,一条都不发 } else { txBuffer.computeIfAbsent(r.getXid(), k -> new ArrayList<>()).add(r.toChange()); } }

这段逻辑里最关键的一行是committedScn = r.getScn()。位点只能在事务提交之后推进,如果按每条记录推进,作业在事务中途失败重启,那半个事务的变更就永久丢了。反过来说,位点推进得太保守也不好——如果一直不推进,checkpoint 里存的永远是老位点,重启后重复回放的数据量会非常大。

还有一个容易被忽略的问题:长事务。某个事务开了半小时还没提交,这半小时里所有变更都堆在txBuffer里,内存会一直涨。工程上的处理办法通常有两条:一是设一个事务超时阈值,超了就告警;二是把缓冲区落到磁盘或者 Flink 的 state backend 里,别放纯堆内存。前者治标,后者治本,正经的生产实现一般两个都做。

SCN 的推进还有一个隐含约束:解析器读日志的速度必须跟得上数据库产生日志的速度。跟不上就会出现“日志堆积”,表现是同步延迟越来越大,而任务本身看起来一切正常,没有任何报错。这种沉默的劣化比直接报错难查得多。

3.3 解析进程和 Flink 作业的边界划分

这里有一个架构上的选择:日志解析是放在 Flink 的 Source 里做,还是单独跑一个解析服务,Flink 只负责消费解析结果?

放在 Source 里做,好处是链路短、部署简单,坏处是解析本身是 CPU 和内存密集型操作,跟 Flink 的算子抢资源,而且解析器的连接是独占的,Source 并行度上去了,数据库那边可能扛不住一堆解析会话。单独跑解析服务,好处是解析能力可以独立扩缩容、独立重启,Flink 侧就是一个普通的消息消费;坏处是多了一个组件要运维,而且解析服务和 Flink 之间的传输层又引入了一次至少一次语义,端到端一致性要多考虑一层。

我一般会这么理解这个取舍:如果同步的表不多、变更量中等,把解析塞进 Source 是完全够用的;如果同步的是核心交易库、变更量很大的场景,解析服务和 Flink 作业分开更稳,因为解析器挂掉重启不应该影响 Flink 作业的 checkpoint,反过来 Flink 作业升级也不应该打断日志解析的连续性。

# 解析服务独立部署时的资源规划示意 parser: replicas: 2 # 一主一备,主挂备接 cpu: 4 # 解析是 CPU 密集型,核数给足 memory: 8Gi # 事务缓冲区占大头 checkpoint_dir: /data/pos # 位点独立持久化,不依赖 Flink 状态

replicas: 2这种主备模式的关键在于位点要存在解析服务自己那边,而不是存在内存里。主节点挂掉,备节点从同一个位点文件接上,Flink 侧几乎无感知。这和把位点存在 Flink checkpoint 里是两种哲学:前者把“读到哪”当成解析服务的责任,后者当成流处理框架的责任。选哪种取决于你更信任哪一侧的可用性。

4. 位点与容错:断点续传靠的到底是什么

实时同步任务跟批任务最大的区别是它要 7×24 跑,跑得越久,遇到故障的概率越接近 1。所以“失败之后从哪继续”这个问题,比“怎么跑得快”重要得多。位点机制就是这里的后悔药。

4.1 Checkpoint 里存的位点长什么样

Flink 的 checkpoint 是一个全局一致的状态快照,对 CDC Source 来说,它要存的就是“当前每个分片读到哪了”。这个状态不需要很大,但必须足够精确,精确到重启之后一条不多、一条不少地接上。

{ "snapshotFinished": true, "incrementalOffset": { "scn": 88421930, "logFile": "ARCHIVE_LOCAL1_0x5A3F_20240612_0003.log", "logOffset": 1048576 }, "pendingSplits": ["t_order:1-200000", "t_order:200001-400000"] }

这个结构里三个字段各有讲究。scn是事务提交序号,代表逻辑上的时间点;logFile和logOffset是物理位置,代表文件里的字节偏移。为什么两个都要存?因为日志文件可能被归档切换、可能被清理重命名,只存 SCN 的话重启时得先扫一遍所有文件找位置,慢;只存文件偏移的话,文件被轮转之后就废了。两个一起存,正常情况用物理位置直接定位,物理位置失效时用 SCN 兜底重新找。

pendingSplits保存的是还没读的快照分片。这个字段让“全量做到一半失败”变得可恢复:重启后不用重头再切一遍,直接接着没做完的分片继续读。如果没有这个字段,一个几亿行的表做到 90% 挂掉,下次还得从 0 开始,这在生产上是不能接受的。

还有一个实践细节:位点的持久化频率。每个 checkpoint 间隔存一次是标配,但如果 checkpoint 间隔设成 10 分钟,故障时最多重复处理 10 分钟的数据。对重复敏感的链路,要么缩短 checkpoint 间隔,要么在下游做幂等,没有第三条路。

4.2 端到端一致性:两阶段提交与幂等下沉

Source 侧做到精确一次还不够,因为数据要写到下游去。从 Source 读到数据、处理完、写到目标库,这中间任何一步失败都可能造成重复或丢失。

Flink 提供了两阶段提交的 Sink 来实现端到端精确一次:checkpoint 时先写预提交,等所有算子都确认了再统一提交。这套机制对支持事务的目标库很友好,对不支持事务的目标库就没辙了。达梦作为目标库时,能不能用两阶段提交取决于目标端的事务支持情况和驱动实现,很多团队最终选的是更朴素的路子——幂等写入。

-- 幂等下沉:靠主键覆盖,重复来几次结果都一样 MERGE INTO t_order_target t USING (SELECT ? AS id, ? AS status, ? AS amount FROM dual) s ON (t.id = s.id) WHEN MATCHED THEN UPDATE SET t.status = s.status, t.amount = s.amount WHEN NOT MATCHED THEN INSERT (id, status, amount) VALUES (s.id, s.status, s.amount);

这条MERGE语句的语义是“有则更新、无则插入”,重复执行同一条变更,结果不变。代价是每条变更都要走一次主键查找,写入吞吐比纯INSERT低不少。对更新密集的表,这个代价可以接受;对纯日志追加型的表,其实用INSERT加唯一约束冲突忽略更划算。

有一点必须说清楚:幂等写入的前提是变更里带着完整的主键,而且下游表的主键定义跟上游一致。如果上游是联合主键、下游只建了单列主键,那幂等就成了玄学——看起来没报错,实际数据已经被覆盖得面目全非。这种问题排查起来极其痛苦,因为任务日志里什么异常都没有。

注意:幂等写入只能吸收重复,吸收不了乱序。如果同一个主键的两条变更被并发写到下游,先后顺序错了,最终状态就是错的。所以下游写入的并行度要么设成 1,要么按主键做哈希分区。

5. 避坑与排查:四个让同步静默停摆的现场

CDC 任务最讨厌的地方是它很少痛快地报错,更多时候是“看着在跑,其实不动了”。下面这几条都是踩过的坑,按现象、原因、解决三段写。

5.1 现象是延迟曲线一路向上,原因是归档被清理了

现象:任务状态显示 RUNNING,checkpoint 也正常,但同步延迟从几秒涨到几小时,下游查不到新数据,任务日志里没有任何 ERROR。

原因:解析器按位点去读某个归档文件,文件已经被清理策略删掉了。有些实现会安静地跳过缺失文件继续往后读,读到的却是一个不连续的时间段,中间的变更永久丢失。更糟的是位点还在正常推进,从监控上完全看不出来。

解决:一是把归档保留时长设成明显大于任务可能的最长中断时间,比如保留 7 天,任务中断超过 7 天就必须重做全量;二是加一个“位点连续性校验”,每次启动时检查位点对应的日志文件是否还存在,不存在直接失败告警,绝不静默跳过;三是监控归档目录的磁盘使用率,在写满之前就要有动作。

5.2 现象是表名对不上,原因是大小写与字符集在捣鬼

现象:配置里写的是t_order,建 Source 时报“表不存在”;或者能连上,但解析出来的列名全是乱码。

原因:达梦对标识符大小写的处理策略跟建库参数有关,有的实例默认把未加引号的标识符转成大写,有的保持原样。再加上字符集不是 UTF-8 时,中文字段名解析出来就是一堆问号。

解决:先把实例的大小写敏感策略确认清楚,配置里的表名、列名跟库里实际存储的保持一致,拿不准就加双引号强制精确匹配。字符集方面,建库时就该定成 UTF-8,已经建好的改不了,只能在解析层做一次映射,把乱码列名手工对应回去。这种事越早发现越好,等到同步了几亿条数据再发现字段错位,回补的成本高到离谱。

5.3 现象是某张表突然不再同步,原因是 DDL 让解析器失配了

现象:其他表都正常,只有一张表在某天之后再也没有新数据,重启任务也没用。

原因:这张表被加了一列或者改了一个字段类型。解析器启动时拿到的表结构是缓存的旧版本,按旧结构去解析新日志,字段偏移全错,解析到一半抛异常,异常被吞掉之后这张表的解析就卡在那里了。

解决:解析器要能感知 DDL 变更,至少要做到“发现表结构变了就重建解析上下文并重新拉一次元数据”。更进一步的做法是把 DDL 事件也当作一种变更向下游传递,让目标端跟着改。退一步说,就算不支持自动同步 DDL,也必须做到“遇到 DDL 就明确报错并停止这张表的解析”,而不是假装没看见。

5.4 现象是内存缓慢上涨最后 OOM,原因是大字段和大事务

现象:任务跑三五天之后 TaskManager 内存一路涨,最后 OOM 重启,重启之后又慢慢涨。

原因:有两个来源。一是大字段,一张表里有 BLOB 或者超长文本,解析出来的每条变更都带着完整的大字段内容,堆内存里积压一批就爆了。二是大事务,一个批量更新几百万行的事务,全部变更都要攒到提交才能下发,缓冲区直接撑爆。

解决:大字段方面,配置里通常有一个“是否解析大字段”的开关,不需要同步大字段的表就关掉,需要的话考虑把大字段单独走一条链路。大事务方面,给事务缓冲区设上限,超了就落盘,同时把事务超时阈值调低并加告警,让长事务尽早暴露。这两个都不是纯配置能解决的,需要在设计阶段就想到。

6. 设计取舍:为什么“基于日志”更贵却更值得

把整条链路走完,会发现基于日志解析的同步比基于查询的同步贵得多:要开归档、要涨日志量、要额外的解析算力、要处理事务重组和位点管理,还得时刻盯着归档磁盘。而基于查询的方案——定时扫一遍update_time增量拉取——实现起来简单得多,几乎不占数据库什么资源。那为什么还要选难走的那条路?

关键在于查询方案有三个绕不过去的硬伤。第一,物理删除捕获不到,一行数据被DELETE之后,按时间戳增量查询永远查不出“它没了”,只能靠全表比对,成本极高。第二,中间态丢失,一行记录在一个周期内被改了五次,查询方案只能看到最终状态,那四次中间变化对下游的风控、审计、缓存失效这些场景恰恰是有价值的。第三,update_time依赖业务代码自觉维护,只要有一个写入方忘了更新这个字段,数据就永久漏同步,而且这种漏是静默的,谁也不知道。

日志解析方案把这三个问题一次性解决了:删除变成一条 DELETE 事件、每次修改都是一条独立事件、完全不依赖业务字段。代价是复杂度和资源开销,但这个代价是一次性付出、长期收益的。

在一致性上也有取舍。两阶段提交能给出严格的端到端精确一次,但要求目标端支持事务,链路里任何一环不支持就得降级;幂等写入牺牲了一点写入吞吐,换来对目标端几乎零要求,而且天然抗重复。实际项目里我会优先选幂等,原因是它出问题时表现是可预期的(重复写,结果一样),而两阶段提交出问题时的表现往往是事务悬挂,排查难度高一个量级。

还有一个容易被低估的取舍是解析放在哪。塞进 Flink Source 里省了一个组件,但解析器和 Flink 作业的生命周期绑死了,升级 Flink 版本要停解析,解析器出问题要重启整个作业。独立成服务多了一份运维负担,但两边可以各自演进。规模小的时候前者明显划算,规模一上来,后者的价值就体现出来了。

验证这套链路是否真的可靠,我会做三件事:一是造一批包含增删改、大事务、大字段、DDL 的测试数据跑一遍,看下游最终状态和上游是否完全一致;二是任务跑到一半手动 kill 掉 TaskManager,重启后核对有没有丢数据或重复;三是故意把归档目录的保留时间调短,观察任务是不是按预期报错而不是静默跳过。第三个测试最能暴露问题,很多实现就是在这里翻车的。

从那以后我每次接新的数据库做 CDC,都会先把“归档保留多久、任务最长能停多久、停了之后怎么恢复”这三个问题问清楚再动手,而不是先写代码。希望帮到你。

本文还有配套的精品资源,点击获取

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

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

立即咨询