做数据同步这些年,MySQL到各类目标库的活儿接了不少,但真正接到“MySQL实时同步到达梦”这个需求的时候,我还是愣了一下。倒不是技术上有多难,而是达梦这个国产数据库的生态工具链,和MySQL、PostgreSQL这些主流选手比起来确实“冷”不少,网上能查到的资料大多停留在“能不能连”的层面,真正把整条实时同步链路跑通、跑稳的案例很少。
我最终选定的方案是 Flink CDC 做数据捕获,配合 Flink 的 JDBC Sink 写入达梦。Flink CDC 算是我手边最顺手的实时同步工具,它直接在 SQL 层定义 Source 和 Sink 就能跑,不需要额外部署 Canal,也不需要维护一堆同步脚本。这篇文章就把整个项目的细节拆开讲清楚,包括为什么选 Flink CDC、表结构怎么映射、同步代码怎么写、批量写入怎么调优,还有我实际踩过的一些坑。
1. 为什么选 Flink CDC 做 MySQL 到达梦的同步
1.1 同步需求从哪来
先说需求背景。我这边的情况是:业务库一直是 MySQL,跑了好几年,数据量不算小。最近因为项目合规要求,需要把部分核心业务数据实时同步到本地的达梦数据库,供另一个系统做查询和分析使用。这个“实时”不是准实时,而是要求延迟控制在秒级以内,业务一变更,目标库要能马上看到。
如果是一次性的历史数据迁移,直接用 DataX、Kettle 这类工具就够了,全量导一次,跑完收工。但“实时同步”完全是另一回事,它要求源端的插入、更新、删除操作能持续地被捕获,并尽快反映到目标端。这就意味着我需要一个能持续监听 MySQL binlog 的机制。
1.2 三个候选方案怎么选
当时摆在面前的有三条路。
第一条路是传统 ETL 工具轮询,比如定期用 JDBC 查 MySQL 变更表,再写入达梦。实现简单,但延迟高、增量字段要求苛刻,而且对删除操作基本无能为力,一般只适合做周期性的离线同步。
第二条路是 Debezium + Kafka + 消费程序。这是目前比较主流的一套 CDC 架构,Debezium 监听 binlog 后把变更事件发到 Kafka,下游消费写入达梦。优点是组件解耦、可扩展性强;缺点是组件太多,运维成本高,光 Kafka 集群就是一笔不小的开销,对于这种单一同步场景来说有点杀鸡用牛刀。
第三条路就是 Flink CDC。Flink CDC 底层同样基于 Debezium 解析 binlog,但它把整套链路压缩在了 Flink 内部,不需要额外引入消息队列。用 Flink SQL 定义一张 Source 表、一张 Sink 表,一条 INSERT INTO 语句就能跑起来。整个链路的监控、checkpoint、重启恢复都由 Flink 统一管理,这对数据同步这种长稳任务来说非常关键。
1.3 Flink CDC 的独家优势
抛开组件数量不谈,Flink CDC 最强的点是“全量 + 增量一体化”。传统方案要做全量+增量,通常得先离线导一次全量,记录时间点,再从那个时间点开启增量监听,中间夹着的变更很容易丢或重复。Flink CDC 2.x 之后使用的增量快照算法把这两个阶段合并了,任务启动时先给源表数据做一致性快照,然后无缝衔接到 binlog 增量读取。整个过程对于下游来说就是一条连续的数据流,不需要关心切换点。
另外,Flink 天然的分布式能力也让同步任务的吞吐量可以横向扩展。单表数据量大时,可以把大表按主键切分成多个 chunk 并行读取,而不是像 Canal 那样默认单线程消费,这在实际业务中能省下大量时间。
提示:如果你只需要同步几张表、几十 GB 的数据,而且不允许引入太重的基础设施,Flink CDC 基本是这个场景下的最优解。如果目标是构建企业级的数据湖/数仓,再考虑上 Kafka 那套架构不迟。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 整体架构与同步链路设计
2.1 数据流转链路
整个同步链路由四个环节组成,按数据流向依次是:
- 源端:MySQL 8.0,开启 binlog,且 binlog 格式必须为 ROW。
- 捕获端:Flink CDC 的 mysql-cdc connector,负责解析 binlog 并输出变更数据。
- 计算端:Flink 集群,这里我直接用了 Flink 1.16 的 SQL Client 跑任务,没有写 Java 代码。
- 目标端:达梦 DM8,通过 JDBC Sink 写入。
流水线用一句话描述就是:MySQL binlog → Flink CDC Source → Flink 内部处理 → JDBC Sink → 达梦表。
2.2 增量快照算法与同步一致性
在介绍具体实现之前,有必要把 Flink CDC 的同步一致性机制讲清楚,不然你会在“数据到底丢了没”这个问题上纠结很久。
Flink CDC 2.x 默认使用了增量快照算法。任务启动时,并不是先全量后增量,而是把源表的全量数据按主键范围切成多个 chunk,同时记录一个 binlog 偏移量作为低水位线。每一个 chunk 在导出快照数据之后,会再从 binlog 里补上快照导出期间产生的变更,最终得到一个与 binlog 位点对齐的、一致性的全量数据视图。
等所有 chunk 都处理完,任务就自然进入纯增量阶段,持续消费 binlog。因为 Flink 会周期性做 checkpoint,binlog 的消费位点被保存在状态后端中,所以任务在任何时刻挂掉,重启后都能从最近一次 checkpoint 继续,不会丢数据、也不会重复读太多。
这意味着同步任务的下游必须支持“幂等写入”。Flink 的 exactly-once 在 JDBC Sink 这一层并不能完全保证,因为 JDBC Sink 天然不支持跨批次事务,所以我们要在 SQL 和表结构设计上尽量保证重复写入不会产生脏数据。
2.3 达梦建表与 MySQL 的表结构映射要点
异构数据库同步,最大的坑就是表结构不一致。MySQL 和达梦在字段类型、大小写规则、索引策略上都有差异。我设计了一张映射表,下面这几类是最常见的:
| MySQL 类型 | 达梦类型 | 说明 |
|---|---|---|
| INT / INTEGER | INT | 兼容良好 |
| BIGINT | BIGINT | 兼容良好 |
| VARCHAR(n) | VARCHAR(n) | n 需检查,超过达梦上限要改 CLOB |
| TEXT / LONGTEXT | CLOB | MySQL 的 TEXT 不能直接映射成 VARCHAR |
| DATETIME | TIMESTAMP | 达梦没有 DATETIME,用 TIMESTAMP |
| TIMESTAMP | TIMESTAMP | 注意时区处理 |
| DECIMAL(p,s) | DECIMAL(p,s) | 达梦默认精度要调 |
| TINYINT(1) | SMALLINT | 建议映射成 SMALLINT 或 NUMBER(1) |
| BLOB | BLOB | 兼容 |
达梦库初始化时的参数直接影响建表行为,尤其是大小写敏感这一项。如果初始化实例时设置了 CASE_SENSITIVE=Y(默认),那建表语句里的表名和字段名都会被转成大写,后续 SQL 查询也要用大写,否则会报“无效的对象名”。如果你用 Flink SQL 同步时写的是小写表名,就必须在 JDBC URL 里指定 schema,并且确认表名与实际一致。
我在达梦侧建的测试表结构大致是这样:
sql复制CREATE TABLE BIZDB.BIZ_ORDER (
ID BIGINT NOT NULL,
ORDER_NO VARCHAR(64),
USER_ID BIGINT,
AMOUNT DECIMAL(10, 2),
STATUS INT,
CREATE_TIME TIMESTAMP,
UPDATE_TIME TIMESTAMP,
PRIMARY KEY (ID)
);
3. 环境准备与前置配置
3.1 MySQL 侧:开 binlog 与账号授权
Flink CDC 要读 binlog,第一步是确认 MySQL 已经开启 binlog 并且格式是 ROW。如果你的 MySQL 是云厂商托管的,通常在控制台就能直接改参数;自建的话,修改 my.cnf 后重启:
ini复制[mysqld]
server-id = 1
log_bin = mysql-bin
binlog_format = ROW
binlog_row_image = FULL
expire_logs_days = 7
注意 binlog_row_image 要设置成 FULL,这样 binlog 里会同时记录变更前后的完整行数据。如果设置成 MINIMAL,一些更新操作只会记录被修改的字段,Flink CDC 在构造完整行时会缺列,非常麻烦。
改完配置重启 MySQL,登录验证一下:
sql复制SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
然后创建一个专供同步用的账号,别直接用 root。这个账号至少需要 SELECT、REPLICATION SLAVE、REPLICATION CLIENT 三个权限:
sql复制CREATE USER 'cdc_user'@'%' IDENTIFIED BY 'cdc_pass';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'cdc_user'@'%';
FLUSH PRIVILEGES;
3.2 达梦侧:初始化实例与建库
达梦的安装过程这里就不展开了,网上教程很多。值得提醒的是初始化实例时几个关键参数:页大小、字符集、大小写敏感。页大小建议用 16 或 32,字符集选 UTF-8,大小写敏感看你们业务规范。如果拿不准,就按默认来,但后续写 SQL 时要注意大小写。
Docker 快速起一个达梦实例做验证也很方便:
bash复制docker run -d -p 5236:5236 \
--name dm8 \
--privileged=true \
-e PAGE_SIZE=16 \
-e CASE_SENSITIVE=Y \
-e CHARSET=1 \
-v /data/dm8:/opt/dmdbms/data \
dm8_single:v8.1.2.128
我用的是图形化安装,装完用达梦管理工具连上去建了一个业务库(schema),名称为 BIZDB,然后创建了上面的 BIZ_ORDER 表。达梦默认端口是 5236,默认用户名 SYSDBA,默认密码在安装时设置,连接工具用达梦自带的“达梦数据库管理工具”或 DBeaver 都可以。
3.3 依赖 Jar 包与 Flink 版本选型
同步任务要跑起来,Flink 侧需要提前准备好几个依赖包。首先是 flink-sql-connector-mysql-cdc,Flink CDC 是独立于 Flink 主版本发布的,要选择兼容版本。比如 Flink 1.16 对应 Flink CDC 2.3.0,Flink 1.17 对应 2.4.0。
其次是 JDBC connector 和 MySQL 驱动、达梦驱动。Flink 自带的 flink-connector-jdbc 支持通过 SQL 方式创建 JDBC Sink,但它本身不包含具体数据库的驱动,需要手动把驱动 jar 放入 Flink 的 lib 目录。达梦的驱动是 DmJdbcDriver18.jar,从达梦安装目录下的 drivers/jdbc 里就能找到,注意要选 JDK 1.8 对应的版本。
需要在 Flink lib 目录下放的文件列出来:
- flink-sql-connector-mysql-cdc-2.3.0.jar
- flink-connector-jdbc-1.16.0.jar
- mysql-connector-java-8.0.27.jar
- DmJdbcDriver18.jar
我的实验环境是单机 Flink Standalone 模式,这些都丢到 lib 目录后重启了 Flink 集群。如果你用 SQL Client,也可以启动时用 -C 参数指定依赖,但这只对当前会话生效,提交到集群跑长任务时还是放 lib 目录最省心。
4. 核心代码实现:SQL 同步与自定义 Sink
4.1 SQL 方式:十分钟跑通全量加增量
依赖准备好之后,最直接的方式是用 Flink SQL。打开 SQL Client,依次执行三条语句。
第一步,定义 MySQL 源表:
sql复制CREATE TABLE mysql_biz_order (
id BIGINT PRIMARY KEY NOT ENFORCED,
order_no STRING,
user_id BIGINT,
amount DECIMAL(10, 2),
status INT,
create_time TIMESTAMP(3),
update_time TIMESTAMP(3)
) WITH (
'connector' = 'mysql-cdc',
'hostname' = '192.168.1.100',
'port' = '3306',
'username' = 'cdc_user',
'password' = 'cdc_pass',
'database-name' = 'biz_db',
'table-name' = 'biz_order',
'scan.startup.mode' = 'initial'
);
第二步,定义达梦目标表:
sql复制CREATE TABLE dm_biz_order (
id BIGINT PRIMARY KEY NOT ENFORCED,
order_no STRING,
user_id BIGINT,
amount DECIMAL(10, 2),
status INT,
create_time TIMESTAMP(3),
update_time TIMESTAMP(3)
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:dm://192.168.1.101:5236?schema=BIZDB',
'table-name' = 'BIZ_ORDER',
'username' = 'SYSDBA',
'password' = 'SYSDBA',
'sink.buffer-flush.max-rows' = '1000',
'sink.buffer-flush.interval' = '2s'
);
第三步,执行同步:
sql复制INSERT INTO dm_biz_order
SELECT id, order_no, user_id, amount, status, create_time, update_time
FROM mysql_biz_order;
到这一步,任务会先拉取 MySQL 里 biz_order 表的全量数据,然后实时监听 binlog。你往 MySQL 里插一条、改一条、删一条,达梦那边会跟着变。
不过,你马上会发现一个问题:删除操作同步不过去。
4.2 关键坑:JDBC Sink 不支持 DELETE
Flink 的 JDBC Sink 本质上是一个 append-only 的写入器。什么意思呢?Flink CDC 产生的每一条变更记录都带一个 RowKind 标记,+I 表示插入、-U 表示更新前、+U 表示更新后、-D 表示删除。JDBC Sink 只处理 +I 和 +U,遇到 -D 会直接抛异常,任务重试几次后进入失败状态。
这个问题在做 MySQL 到 MySQL 同步时通常不明显,因为很多人会配合 upsert-kafka 或者直接把 JDBC Sink 的表设为主键,靠数据库侧去重。但达梦不是 MySQL,JDBC Sink 自动生成的方言 UPSERT 语句对达梦的兼容性并不稳定,实测最稳妥的方式是放弃 JDBC Sink 的自动批处理,改为自定义 Sink,自己控制插入、更新、删除三条路。
注意:如果你只做“插入+更新”的增量同步,不要求同步删除操作,那可以直接用 JDBC Sink 跑。但凡业务里涉及删数据,就必须换方案。
4.3 自定义 Sink 实现完整 CDC 语义
自定义 Sink 的思路是:用 Table API 把变更流转换成 DataStream<RowData>,然后在 Sink 函数里根据 RowKind 执行不同 SQL。
核心代码长这样:
java复制import org.apache.flink.api.common.typeinfo.TypeInformation;
import org.apache.flink.configuration.Configuration;
import org.apache.flink.streaming.api.functions.sink.RichSinkFunction;
import org.apache.flink.table.data.RowData;
import org.apache.flink.types.RowKind;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
public class DmCdcSink extends RichSinkFunction<RowData> {
private Connection conn;
private PreparedStatement insertPs;
private PreparedStatement updatePs;
private PreparedStatement deletePs;
@Override
public void open(Configuration parameters) throws Exception {
Class.forName("dm.jdbc.driver.DmDriver");
conn = DriverManager.getConnection(
"jdbc:dm://192.168.1.101:5236?schema=BIZDB",
"SYSDBA", "SYSDBA");
conn.setAutoCommit(false);
insertPs = conn.prepareStatement(
"INSERT INTO BIZ_ORDER(ID, ORDER_NO, USER_ID, AMOUNT, STATUS, CREATE_TIME, UPDATE_TIME) " +
"VALUES (?, ?, ?, ?, ?, ?, ?)");
updatePs = conn.prepareStatement(
"UPDATE BIZ_ORDER SET ORDER_NO=?, USER_ID=?, AMOUNT=?, STATUS=?, CREATE_TIME=?, UPDATE_TIME=? " +
"WHERE ID=?");
deletePs = conn.prepareStatement(
"DELETE FROM BIZ_ORDER WHERE ID=?");
}
@Override
public void invoke(RowData row, Context context) throws Exception {
switch (row.getRowKind()) {
case INSERT:
insertPs.setLong(1, row.getLong(0));
insertPs.setString(2, row.getString(1).toString());
// 省略其余字段赋值
insertPs.addBatch();
break;
case UPDATE_AFTER:
updatePs.setLong(7, row.getLong(0));
// 省略其余字段赋值
updatePs.addBatch();
break;
case DELETE:
deletePs.setLong(1, row.getLong(0));
deletePs.addBatch();
break;
}
}
@Override
public void close() throws Exception {
if (insertPs != null) insertPs.close();
if (updatePs != null) updatePs.close();
if (deletePs != null) deletePs.close();
if (conn != null) conn.close();
}
}
然后在主程序里,把 SQL 的表定义好之后,将变更数据流转成 DataStream:
java复制StreamTableEnvironment tEnv = StreamTableEnvironment.create(env);
tEnv.executeSql("CREATE TABLE mysql_biz_order (...) WITH (...)");
tEnv.executeSql("CREATE TABLE dm_biz_order (...) WITH (...)");
Table result = tEnv.sqlQuery(
"SELECT id, order_no, user_id, amount, status, create_time, update_time FROM mysql_biz_order");
DataStream<RowData> stream = tEnv.toChangelogStream(result);
stream.addSink(new DmCdcSink());
有一点必须提醒:toChangelogStream 输出的 RowData 里既有 +I、+U,也有 -D,自定义 Sink 里必须把三种 RowKind 都处理到,漏掉任何一个都会导致数据不一致。
4.4 批量写入与性能调优参数
自定义 Sink 虽然灵活,但如果老老实实地一条数据一个事务提交,性能会很差。我实测单条 insert 的方式,插入 10 万行数据耗时超过 20 分钟,完全没法用。优化方向有两个。
第一个方向是批量提交。在 Sink 函数里维护一个计数器和 List,攒够一批后统一 executeBatch() 并 commit()。比如攒 1000 条提交一次,或者每隔 2 秒刷一次。这个逻辑看着简单,但能让吞吐量提升一个数量级。
第二个方向是并行度。默认 Sink 并行度是 1,所有写入都压在一个连接上。可以给 Sink 设置并行度,比如 stream.addSink(new DmCdcSink()).setParallelism(4),但要注意并行度变多之后,同一行数据的更新和删除可能落到不同线程,导致执行顺序改变。稳妥的做法是先用主键做 keyBy,再交给下游 Sink:
java复制DataStream<RowData> keyedStream = stream.keyBy(row -> row.getLong(0));
keyedStream.addSink(new DmCdcSink()).setParallelism(4);
keyBy 能保证同一主键的数据始终进入同一个 Sink 实例,避免并发乱序导致的更新覆盖问题。
如果你仍然希望用 SQL 方式并控制批量写入,可以在 JDBC Sink 的 WITH 参数里调大攒批阈值,这也是最省事的手段:
sink.buffer-flush.max-rows:攒多少条刷一次,默认 100,建议调到 1000 或 2000。sink.buffer-flush.interval:间隔多少毫秒刷一次,默认 1 秒,可以结合吞吐调整。sink.max-retries:写入失败重试次数,默认 3。
达梦驱动的 JDBC URL 里也可以追加一些参数,比如 connectTimeout=5000、socketTimeout=300000,防止网络抖动导致连接长期挂起。
5. 常见问题与排查实录
5.1 表结构字段类型不匹配
这个坑几乎每个异构同步都会遇到。我第一次同步时,MySQL 表里有个 TEXT 类型的 remark 字段,我在达梦建表时用了 VARCHAR(500),结果跑到一半任务报错,提示字符串截断。后来改成 CLOB 才正常。
排查思路:先检查源表和目标表的字段类型是否逐一对得上,尤其是 TEXT、LONGTEXT、JSON、ENUM 这些 MySQL 特有类型。建议在任务启动前,写个脚本把两边的元数据拉出来做一次 diff,不要等任务跑起来才报错。
达梦对长度超限的校验很严格,VARCHAR 超过上限直接报错,不会像 MySQL 那样静默截断(MySQL 新版也会报错,但旧版是直接截断)。
5.2 连接数超限与 JDBC 异常
Flink 的 JDBC Sink 默认会为每个并发创建一个连接,如果并行度设得高,连接数会快速上涨。达梦默认最大连接数是 100,同步任务一启动就可能把连接打满,其他业务连不上数据库。
排查时可以先看达梦的告警日志,如果出现 too many connections 之类的信息,基本就是连接数问题。解决办法有两个:一是调低 Sink 并行度;二是在达梦侧调大 MAX_SESSIONS 参数,但生产环境不建议为了同步任务无限调大连接数上限。
另外,Flink 任务空闲时间长了之后,达梦可能主动断掉无活动连接,任务再次写入时报 Connection is closed。这种情况需要在 JDBC URL 里配置连接保活参数,或者在自定义 Sink 里定期发送 SELECT 1 探活。
5.3 任务重启后数据一致性问题
Flink 任务启了 checkpoint 之后,理论上可以断点续跑。但如果你没有开启 checkpoint,任务重启时会重新走一遍全量快照,目标表里已有的数据就会报主键冲突。
建议生产环境一定开启 checkpoint,并且将 checkpoint 间隔设置为 10 到 30 秒之间:
java复制env.enableCheckpointing(10000);
同时把 JDBC Sink 设置为“先查重再插入”或者用缓存表做幂等。如果目标表是纯新增场景,直接 insert 没问题;如果有更新和删除,就要确保 UPDATE_AFTER 和 DELETE 的顺序不会因为并发而乱掉,这也是前面强调要用 keyBy 的主要原因。
5.4 排查技巧:从日志与监控入手
Flink CDC 任务排查问题,首选不是看业务日志,而是看两样东西:一是 Flink Web UI 的算子吞吐量指标,二是任务日志里有没有出现 binlog 解析相关的 WARN。
比如 The requested offset is outside the range of available binlog 这个报错,通常表示 MySQL binlog 已被清理,Flink 想要消费的位置不存在了。排查思路是看 MySQL 的 expire_logs_days 配置和当前 binlog 文件列表,确认是不是源库清理得太激进。
再比如中文乱码问题,通常不是 Flink 的问题,而是达梦数据库字符集设置不对。如果达梦初始化时字符集是 GBK,而源库是 UTF-8,写入后中文就全是问号。这种问题没有优雅的运行时解法,只能在初始化实例时把字符集统一成 UTF-8,或者在建表时指定 CHARACTER SET。
我个人的排查习惯是:先在 Flink 里用 print() 把 DataStream 的输出打到日志里,确认变更数据的 RowKind 和字段内容是否符合预期;再去达梦查数据比对;最后才看上下游的连接、权限和参数配置。这样能把“数据有问题”和“链路有问题”两类问题快速区分开。
5.5 一个容易忽视的权限细节
最后补充一个容易被忽视的点:达梦的 schema 权限。Flink 写入达梦时,如果 JDBC URL 里指定了 schema=BIZDB,那这个用户必须有 BIZDB 模式下的建表、增删改权限。我当时用的 SYSDBA 是超级管理员,权限足够;但如果生产环境用普通账号,记得先把权限授权到位,否则任务会时不时报“权限不足”。
MySQL 账号那边同理,如果 MySQL 开启了 binlog_do_db 或 replicate-do-db 之类的过滤规则,Flink CDC 可能读不到目标库的 binlog。我就遇到过源库只开启了部分库的 binlog,导致其他库的同步任务一直不出数。
结尾
这套链路我前前后后调了小两周,最难的不是 Flink 本身的配置,而是达梦各种“小脾气”。异构数据库实时同步,永远不要指望一条 SQL 搞定所有场景,尤其是删除操作、类型映射、批量写入这三大块,都要单独设计。
如果你们业务对延迟不敏感、数据量也不大,用 JDBC Sink 直接跑 SQL 是最省力的方案;如果严格需要删除同步和生产级稳定性,就老老实实写自定义 Sink。另外,无论选哪种方案,checkpoint 一定要开,这是 Flink 任务重启后不丢数据的前提。
后面我打算在这个基础上加一个任务监控看板,把 Flink 的延迟指标和达梦的写入速率接到告警里,做到分钟级感知异常。数据同步这条路上永远有坑,但只要链路清晰、日志可查,问题就都是时间问题。
