上一篇,我们把 Paimon 的四种 RowKind 和 Changelog Producer 拆开看了一遍。
现在订单变化已经从 Paimon 读进 Flink,最后一站是 MySQL。
很多人第一次做这条链路时,都会卡在一个很具体的问题上:
Flink 从 Paimon 读到十万条变化,写 MySQL 时到底是一条一条执行,还是攒成一批再执行?UPDATE 会发
UPDATE,还是INSERT ... ON DUPLICATE KEY UPDATE?任务失败重启以后,已经写过的那一批会不会重复?
先给出一句话答案:
记录是一条一条进入 JDBC Sink 的,但不会默认每条都立刻访问 MySQL。每个 Sink 子任务先在自己的缓冲区中按主键保留最后一次变化,达到行数、时间、Checkpoint 或关闭条件时,再通过 JDBC
addBatch()、executeBatch()批量提交。新增和更新走 MySQL UPSERT,删除走按主键 DELETE;普通 Flink SQL JDBC Sink 仍是 At-Least-Once,失败后可能重放,主键 UPSERT 和 DELETE 负责把重复执行收敛成相同的最终结果。
所以,“一条一条”和“一批一批”都对,只是说的不是同一层。
这一篇,我们就跟着订单 1001 从 Paimon 走到 MySQL,再故意让任务在 Checkpoint 中间失败一次,看看批量写入和幂等恢复到底怎样配合。
一、今天的现场:Paimon 订单表同步到 MySQL
Paimon 中有一张主键表:
CREATE TABLE orders (
id BIGINT,
amount DECIMAL(10, 2),
status STRING,
PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'paimon',
'path' = 'hdfs:///warehouse/orders',
'changelog-producer' = 'input'
);
MySQL 中先建好目标表:
CREATE TABLE orders_rt (
id BIGINT NOT NULL,
amount DECIMAL(10, 2),
status VARCHAR(32),
PRIMARY KEY (id)
) ENGINE = InnoDB;
再在 Flink SQL 中注册 JDBC Sink:
CREATE TABLE mysql_orders (
id BIGINT,
amount DECIMAL(10, 2),
status STRING,
PRIMARY KEY (id) NOT ENFORCED
) WITH (
'connector' = 'jdbc',
'url' = 'jdbc:mysql://mysql-host:3306/serving',
'table-name' = 'orders_rt',
'username' = 'flink_writer',
'password' = '******',
'sink.buffer-flush.max-rows' = '100',
'sink.buffer-flush.interval' = '1s',
'sink.max-retries' = '3'
);
最后启动出仓任务:
INSERT INTO mysql_orders
SELECT id, amount, status
FROM orders /*+ OPTIONS(
'scan.mode' = 'latest-full',
'consumer-id' = 'mysql-orders-v1'
) */;
这里有两个主键,作用完全不同:
| 主键在哪里 | 它告诉谁 | 实际作用 |
|---|---|---|
Flink JDBC DDL 的 PRIMARY KEY ... NOT ENFORCED | Flink Planner 和 JDBC Connector | 这是一条 Upsert 流,可以按 id 处理 UPDATE 和 DELETE |
MySQL orders_rt 的真实 PRIMARY KEY | MySQL | 真正阻止重复 Key,并触发 ON DUPLICATE KEY UPDATE |
NOT ENFORCED 的意思正是 Flink 不替你检查约束。注册一张 JDBC 表,也不会替目标 MySQL 创建主键。
如果 Flink DDL 写了主键,MySQL 物理表却没有对应的 PRIMARY KEY 或 UNIQUE KEY,那么 SQL 看起来进入了 Upsert 分支,MySQL 却找不到重复键,恢复重放时仍可能插出重复行。
二、先把“一条一条还是一批一批”说准确
把整个链路分成四层,答案就清楚了:

| 层次 | 实际动作 | 是逐条还是批量 |
|---|---|---|
| Flink 算子 | 每来一个 RowData,调用一次 Sink invoke() | 逐条 |
| JDBC Sink 缓冲区 | 记录进入当前子任务的 Buffer;主键表还会按 Key 合并 | 先逐条收集 |
| JDBC API | 多组参数通过 addBatch() 放入 PreparedStatement,再调用 executeBatch() | 批量 |
| MySQL Driver 和网络 | Driver 可以重写 Batch、合并报文、减少往返 | 由 Driver 决定具体发送形态 |
因此,不要把下面两句话当成矛盾:
Flink 是一条记录一条记录调用 Sink
JDBC Sink 是攒到触发条件后批量访问 MySQL
默认配置下,每个 JDBC Sink 子任务最多累计 100 条输入记录,或者等待 1 秒,哪个先到就先 Flush。
如果显式配置:
'sink.buffer-flush.max-rows' = '1'
每来一条都会触发 Flush,才更接近真正的“逐条写 MySQL”。不过它仍然走 executeBatch() 这套接口,只是这一批通常只有一条。
三、有没有主键,决定 Sink 走哪条路
JdbcDynamicTableSink.getChangelogMode() 对主键 Upsert Sink 声明自己接收:
return ChangelogMode.newBuilder()
.addContainedKind(RowKind.INSERT)
.addContainedKind(RowKind.DELETE)
.addContainedKind(RowKind.UPDATE_AFTER)
.build();
这里没有 UPDATE_BEFORE。
JDBC Sink 真正需要的是:
+I:把这个 Key 的新值写进去
+U:把这个 Key 的新值写进去
-D:把这个 Key 删除
-U 只是 UPDATE 的旧值。对于按主键覆盖的 MySQL 表,拿到 +U 新值就够了,没必要先用旧值再删一次。
源码还会校验:如果上游查询包含 UPDATE 或 DELETE,Sink DDL 就必须声明主键。
checkState(
ChangelogMode.insertOnly().equals(requestedMode)
|| dmlOptions.getKeyFields().isPresent(),
"please declare primary key for sink table when query contains update/delete record.");
到了 JdbcOutputFormatBuilder.build(),分支更直接:
if (dmlOptions.getKeyFields().isPresent()
&& dmlOptions.getKeyFields().get().length > 0) {
// upsert query
return new JdbcOutputFormat<>(
new SimpleJdbcConnectionProvider(jdbcOptions),
executionOptions,
() -> createBufferReduceExecutor(...));
} else {
// append only query
return new JdbcOutputFormat<>(
new SimpleJdbcConnectionProvider(jdbcOptions),
executionOptions,
() -> createSimpleBufferedExecutor(...));
}
两条路可以这样理解:
| Sink DDL | Connector 模式 | 能处理什么 | 恢复重放风险 |
|---|---|---|---|
| 有主键 | Upsert | +I、+U、-D | 可用主键收敛重复写 |
| 无主键 | Append | 只适合 INSERT ONLY | 重放可能重复插入;更新流通常在规划期就失败 |
Paimon 主键表出仓到 MySQL,通常不应该省略 JDBC Sink DDL 中的主键。
四、第一步仍然是逐条:invoke() 每次只接一条记录
Flink 运行时调用的是 GenericJdbcSinkFunction.invoke():
public void invoke(T value, Context context) throws IOException {
outputFormat.writeRecord(value);
}
JdbcOutputFormat.writeRecord() 再把这一条放进 Buffer:
addToBatch(record, getExtractor().apply(recordCopy));
batchCount++;
if (executionOptions.getBatchSize() > 0
&& batchCount >= executionOptions.getBatchSize()) {
flush();
}
这个过程没有拿一个 List<RowData> 作为输入。Paimon Source 发出一条,Flink 算子处理一条,Sink 的 batchCount 加一。
所谓 Batch,是 Sink 在多次 invoke() 之间自己积累出来的。
而且这个 Buffer 不是整个作业共享的。假设:
sink.parallelism = 4
sink.buffer-flush.max-rows = 100
实际会有四个 Sink 子任务、四条 JDBC 连接和四份独立 Buffer。不是整个作业统一攒够 100 条,而是每个子任务各自判断是否到 100 条。
低流量时,其中一个子任务可能每秒只有 3 条,也会被 1 秒定时器 Flush;热点子任务可能几十毫秒就攒满 100 条,提前 Flush。
五、主键表不是简单 List,而是先按 Key 合并
主键路径使用 TableBufferReducedStatementExecutor。它内部不是一个普通的 List,而是一张 Map:
// the mapping is [KEY, <+/-, VALUE>]
private final Map<RowData, Tuple2<Boolean, RowData>> reduceBuffer =
new HashMap<>();
每来一条记录,源码先提取主键,再直接 put:
RowData key = keyExtractor.apply(record);
boolean flag = changeFlag(record.getRowKind());
reduceBuffer.put(key, Tuple2.of(flag, record));
同一个 Key 后来的记录会覆盖前面的记录。
假设一个 Flush 周期内,订单 1001 连续经历:
+I[1001, 80]
-D[1001, 80]
+I[1001, 100]
+U[1001, 120]
batchCount 会增加四次,因为 Sink 确实接收了四条输入。
但 reduceBuffer 中,id=1001 最后只剩:
+U[1001, 120]
真正 Flush 到 MySQL 时,这个 Key 只需要做一次 UPSERT。
这说明 sink.buffer-flush.max-rows=100 更准确的含义是:
收到 100 条输入记录就触发 Flush
而不是:
一定向 MySQL 执行 100 条互不相同的 DML
如果这 100 条都在反复修改同一个订单,Map 里可能只剩一个 Key;如果是 100 个不同订单,才更接近 100 组 JDBC 参数。
六、什么时候真正 Flush:四个触发器谁先到听谁的

标准 Flink SQL JDBC Sink 至少有四种 Flush 时机。
1. 达到最大输入行数
sink.buffer-flush.max-rows
本文使用的 JDBC Connector 默认是 100。达到这个计数时,当前调用 writeRecord() 的线程直接执行 flush()。
配置为 0 可以关闭“按行数 Flush”。
2. 到达时间间隔
sink.buffer-flush.interval
默认是 1 秒。JdbcOutputFormat.open() 会启动一个定时线程,周期性调用 flush()。
配置为 0 可以关闭“按时间 Flush”。
3. Flink 做 Checkpoint
GenericJdbcSinkFunction.snapshotState() 中只有一行关键代码:
public void snapshotState(FunctionSnapshotContext context) throws Exception {
outputFormat.flush();
}
也就是说,即使 Buffer 没达到 100 条、1 秒定时器也还没触发,Checkpoint 到来时仍会先把当前 Buffer 写入 MySQL。
4. Sink 关闭
JdbcOutputFormat.close() 发现 batchCount > 0 时,也会再做一次 Flush,避免有界任务结束时遗留尾批。
四个触发器放在一起就是:
| 触发条件 | 适合解决什么 | 需要注意什么 |
|---|---|---|
| 最大行数 | 控制单批大小和吞吐 | 统计的是输入条数,不是去重后的 Key 数 |
| 时间间隔 | 控制低流量时的可见延迟 | 每个 Sink 子任务有自己的定时器 |
| Checkpoint | 在保存 Flink 状态前清空 JDBC Buffer | Flush 成功不代表整个 Checkpoint 已经成功 |
| Close | 处理任务结束时的尾批 | 不能把异常退出寄托在 Close 上 |
如果行数和时间都配置为 0,常规运行中就主要依赖 Checkpoint 或 Close Flush。流任务又没有正常 Checkpoint 时,Buffer 可能长时间增长,延迟和内存风险都会变得很难控制。
七、Flush 时,MySQL 收到的不是普通 UPDATE
对 MySQL 来说,新增和更新共用一条 UPSERT 模板。
MySqlDialect.getUpsertStatement() 生成的是:
INSERT INTO `orders_rt` (`id`, `amount`, `status`)
VALUES (?, ?, ?)
ON DUPLICATE KEY UPDATE
`id` = VALUES(`id`),
`amount` = VALUES(`amount`),
`status` = VALUES(`status`)
它的语义是:
id 不存在:INSERT
id 已存在:UPDATE 成本次传入的新值
为什么不用“先 SELECT,再决定 INSERT 还是 UPDATE”?
因为那会多一次数据库往返,而且 SELECT 和后续写入之间还存在并发竞争。MySQL 的 ON DUPLICATE KEY UPDATE 可以把“新增或覆盖”交给一条数据库语句处理。
删除则使用另一条 PreparedStatement:
DELETE FROM `orders_rt`
WHERE `id` = ?
TableBufferReducedStatementExecutor.executeBatch() 会遍历 Buffer:
if (entry.getValue().f0) {
upsertExecutor.addToBatch(entry.getValue().f1);
} else {
deleteExecutor.addToBatch(entry.getKey());
}
upsertExecutor.executeBatch();
deleteExecutor.executeBatch();
所以一次 Flush 内部仍分成两组:
一组 UPSERT Batch
一组 DELETE Batch
源码先执行 UPSERT Batch,再执行 DELETE Batch。由于同一个主键在 Buffer 中只保留最后一个动作,它不会同时出现在这两组里。
八、executeBatch() 是否等于“一条超长 SQL”
不一定。
Connector 做出的明确保证是:
多次设置 PreparedStatement 参数
多次 addBatch()
一次 executeBatch()
到了 MySQL Driver,它还可以决定怎样发送。
本文使用的 MySqlDialect.appendDefaultUrlProperties() 会在 URL 没有显式配置时自动追加:
rewriteBatchedStatements=true
这个参数允许 MySQL Connector/J 重写批处理,以减少网络往返。在适用场景下,多组 INSERT 参数可能被改写成更高效的批量形式。
但不要据此写死下面这种监控假设:
Flink 一批 100 条 = MySQL 慢日志里一定出现一条 100 VALUES 的 SQL
Driver 版本、语句类型、返回值要求和重写条件都会影响最后的协议形态。排查性能时,应该同时看:
- Flink 每个 Sink 子任务的输入和 Flush 频率;
- JDBC Driver 是否启用了 Batch Rewrite;
- MySQL 的执行次数、网络往返、锁等待和提交延迟。
一句话记忆:
Flink JDBC 层确定是批量 API,MySQL 最终收到几条协议命令,要看 Driver 怎样实现这批请求。
九、上一篇的四种 RowKind,到了 MySQL 分别怎样落地
把两篇内容接起来:
| Paimon / Flink Changelog | JDBC Sink 的目标动作 | MySQL DML |
|---|---|---|
+I | Upsert | INSERT ... ON DUPLICATE KEY UPDATE |
+U | Upsert | INSERT ... ON DUPLICATE KEY UPDATE |
-D | Delete by key | DELETE ... WHERE pk = ? |
-U | 旧值撤回 | 标准 JDBC Sink 不需要;Planner 通常只保留对应的 +U 新值 |
这也解释了为什么 JDBC Sink 的 ChangelogMode 只声明 +I/+U/-D。
input Producer 的 -D/+I 会执行两条 SQL 吗
不一定,关键看它们有没有跨过 Flush 边界。
同一个 Buffer 内收到:
-D[1001, 80]
+I[1001, 100]
第二条会覆盖第一条,最终只剩一次 UPSERT:
id=1001 -> +I[100]
MySQL 不需要真的先删再插。
但如果 -D 到达后恰好触发了 Flush,+I 在下一批才到:
第一批:DELETE id=1001
第二批:UPSERT id=1001, amount=100
两批之间,查询 MySQL 的业务可能短暂看到订单不存在。
所以 input Producer 的 -D/+I 最终能得到正确状态,却不天然保证外部读者永远看不到中间空窗。要减少这种现象,可以优先让上游提供 +U 语义,或者评估 Paimon Producer、Flink Normalize、批次边界与目标端读取要求。
lookup 和 full-compaction 的 -U/+U 怎么办
JDBC Sink 只需要 +U 新值。Flink Planner 会根据 Sink 接受的 ChangelogMode 调整更新类型,不把无用的 -U 旧值作为最终写入动作。
即使从内部执行器看,UPDATE_BEFORE 也会被归为撤回、UPDATE_AFTER 会被归为加入;标准 SQL 计划的目标仍是让 JDBC Sink 收到可按主键落库的 +I/+U/-D。
十、DELETE 为什么也能幂等
假设订单 1001 已经被删除,任务恢复后又执行一次:
DELETE FROM orders_rt WHERE id = 1001;
第一次影响 1 行,第二次可能影响 0 行,但最终状态相同:
orders_rt 中不存在 id=1001
这就是结果幂等。
DELETE 只需要主键,不依赖旧记录的 amount 和 status。即使 Paimon 的 Delete Changelog 只保留 Key,JDBC Sink 也能生成正确的 WHERE 条件。
不过,数据库中的“最终行状态相同”不代表所有副作用都相同。
如果目标表上有:
- DELETE Trigger;
- 审计流水表;
- 级联删除;
- 外部消息通知;
- 按执行次数累加的统计逻辑;
重复 DELETE 是否安全,就不能只看 orders_rt 最终有没有这行,还要检查副作用本身是否幂等。
十一、Checkpoint 为什么会主动 Flush
假设 Checkpoint 41 已经成功,之后 Sink Buffer 中又积累了 60 条订单变化。
Checkpoint 42 到来时,snapshotState() 先调用:
outputFormat.flush();
这样做是为了避免出现:
Flink 已经把 Source 进度保存到 Checkpoint 42
JDBC Buffer 却还只存在 TaskManager 内存里
如果先保存进度、后写 MySQL,TaskManager 在两者之间故障,恢复后 Source 可能认为这些数据已经处理,内存 Buffer 又已经丢失,最终造成漏数。
因此正确顺序是:
先 Flush 当前 JDBC Buffer
Flush 成功后,再完成当前算子的状态快照
但这只能避免“Checkpoint 把未写出的内存数据越过去”,还没有实现 MySQL 和整个 Flink Checkpoint 的原子提交。
十二、为什么 Flush 成功了,恢复后还会再写一遍
看一条故障时间线:

1. Checkpoint 41 成功
2. Paimon Source 继续发出 Batch A
3. JDBC Sink 把 Batch A Flush 到 MySQL
4. Checkpoint 42 开始,但没有全局成功
5. TaskManager 故障
6. 作业从 Checkpoint 41 恢复
7. Paimon Source 再次发出 Batch A
8. JDBC Sink 再执行一次 Batch A
第 3 步时,MySQL 已经看到了 Batch A。
但第 4 步的 Checkpoint 42 没成功,Flink 只能回到 Checkpoint 41。站在 Flink 状态视角,Batch A 仍属于需要重放的范围。
标准 SQL JDBC Sink 没有在 MySQL 中开启一笔“等 Checkpoint 42 全局成功后再提交”的 XA 事务,所以不能阻止这次重放。
它采取的是另一条路:
允许重复送达
通过目标端主键操作让重复执行得到相同结果
这就是 At-Least-Once Delivery 加 Idempotent Result。
十三、UPSERT 为什么能让重放收敛
假设 Batch A 中有:
+U[1001, 100.00, PAID]
第一次执行:
INSERT INTO orders_rt ... VALUES (1001, 100.00, 'PAID')
ON DUPLICATE KEY UPDATE ...;
MySQL 中订单 1001 变成 100.00, PAID。
恢复后再执行同一组参数,结果仍然是:
1001, 100.00, PAID
它不会因为第二次执行就多出一行,也不会把金额再加 100。
DELETE 同理:删一次和删两次,最终都是不存在。
因此,标准 JDBC Sink 能提供的核心不是“每条记录绝不重复执行”,而是:
在主键、数据和副作用都满足条件时,重复执行仍能收敛到正确的最终表状态。
十四、sink.max-retries 也可能带来重复执行
Checkpoint 恢复不是唯一的重放来源。
JdbcOutputFormat.flush() 在 executeBatch() 失败时,会按照 sink.max-retries 重试:
for (int i = 0; i <= executionOptions.getMaxRetries(); i++) {
try {
attemptFlush();
batchCount = 0;
break;
} catch (SQLException e) {
...
}
}
最麻烦的情况不是“一条都没成功”,而是:
MySQL 已经执行了 Batch 前半部分
连接在返回结果前中断
Flink 不知道哪些参数已经生效
重试时,Connector 只能把 Buffer 中的记录再交一次。
主键 UPSERT 和按主键 DELETE 正是为这种“不确定成功”兜底。没有主键的普通 INSERT,在这里很容易重复插入。
需要特别注意:一次 Flush 中的 UPSERT Batch 和 DELETE Batch 是两个 executeBatch()。源码没有把整次 Flush 包装成一笔与 Checkpoint 绑定的数据库事务。不要把“批量执行”误解成“这一批所有操作与 Checkpoint 一起原子提交”。
十五、幂等恢复成立,需要同时满足五个条件
1. MySQL 真的有对应唯一约束
Flink DDL:
PRIMARY KEY (id) NOT ENFORCED
MySQL 物理表也必须有:
PRIMARY KEY (id)
或者语义完全一致的 UNIQUE KEY。
2. Paimon Key、Flink Sink Key 和 MySQL Key 语义一致
如果 Paimon 用:
(tenant_id, order_id)
唯一标识订单,MySQL 却只用:
order_id
不同租户的订单可能互相覆盖。
反过来,MySQL Key 比 Paimon Key 多字段,DELETE 又拿不到完整目标 Key,也会删不准。
3. UPSERT 是赋值,不是累加副作用
默认 MySQL Dialect 生成的是:
amount = VALUES(amount)
status = VALUES(status)
重复执行仍是同一个值。
如果自定义逻辑变成:
amount = amount + VALUES(amount)
同一条重放两次就会累加两次,不再幂等。
4. 目标表没有非幂等副作用
即使主表最终相同,Trigger 每执行一次就插一条审计记录,重放仍会生成重复审计。
自动更新时间、版本号自增、外部通知也需要单独检查。
5. 没有其他 Writer 竞争修改同一个 Key
假设业务服务在任务故障期间把 1001 改成 REFUNDED,Flink 恢复后重放旧的 PAID,UPSERT 会把新业务状态覆盖回旧值。
对“同一批数据重复执行”,UPSERT 是幂等的;对“多个系统并发争抢同一行”,它并不自动解决版本冲突。
十六、从 Checkpoint 恢复,和没有状态重新全量,是两回事
有 Checkpoint / Savepoint
Paimon Source 从已保存的 Split 和 Snapshot 位置继续,最多重放上一个成功 Checkpoint 之后的变化。
JDBC Sink 依靠 UPSERT / DELETE 收敛重复执行。
这属于正常的故障恢复路径。
没有任何 Flink 状态,再次使用 latest-full
Source 会把当前最新 Snapshot 的完整表状态重新输出成一批 +I。
目标 MySQL 已有相同主键时,UPSERT 可以把这些行覆盖到当前值,因此不会因为全量重跑就插出重复主键。
但这里有一个很容易被忽略的缺口:
最新全量只包含 Paimon 当前仍然存在的行,不会额外列出“目标 MySQL 中有、Paimon 当前已经没有”的脏行。
假设 MySQL 里残留:
id=9999
而 Paimon 最新 Snapshot 已经没有 9999。重新跑 latest-full 时,Source 根本不会发出这条记录,也不会凭空产生一个 -D[9999]。
于是:
全量 UPSERT 能补齐和覆盖现存行
全量 UPSERT 不能自动清理目标端多余行
所以,“可以幂等地重新全量写”不等于“目标端一定重新变成严格镜像”。
第一次建链路或者丢失状态后重建,常见做法是:
- 写入一张空的目标表;
- 写入带版本后缀的影子表,校验后切换;
- 全量完成后做源端与目标端主键对账,再清理多余 Key;
- 保留 consumer-id 和可恢复的 Checkpoint / Savepoint,尽量不要退化成无状态重建。
十七、不同 Changelog Producer 写 MySQL,有什么实际差别
上一篇介绍的四种 Producer,到了主键 MySQL Sink 后可以这样选:
| Paimon Producer | 常见增量输出 | JDBC Sink 中的主要结果 | 需要关注 |
|---|---|---|---|
none | 新值 Upsert Changelog | 新值直接 UPSERT | 最适合只关心 MySQL 最终状态 |
input | MySQL CDC Action 常见 -D/+I | 同 Buffer 可合并成一次 UPSERT | 跨 Flush 可能先删后插,产生短暂空窗 |
lookup | 常见 -U/+U | Planner 保留 MySQL 需要的新值 UPSERT | Paimon 侧生成旧值有额外成本,但 JDBC 最终用不到旧值 |
full-compaction | 延迟产生净变化 -U/+U | 新值 UPSERT、删除 DELETE | MySQL 可见延迟受 Full Compaction 影响 |
如果唯一目标就是维护一张按主键覆盖的 MySQL 服务表,通常不必为了 JDBC Sink 专门生成 -U 旧值。
旧值对无主键聚合、审计和撤回计算很重要;对 MySQL 主键 UPSERT 来说,新值和删除 Key 往往已经足够。
十八、批大小怎么配,先看四个方向
1. 吞吐
Batch 太小,JDBC 往返次数多,MySQL 每秒要处理更多语句和提交。
Batch 变大,通常能提高吞吐,但收益不是无限的。单批太大时,执行时间、锁持有时间、失败重试成本和内存都会上升。
2. 延迟
低流量任务主要受:
sink.buffer-flush.interval
影响。配置 5 秒,就要接受一条订单最多在 Buffer 中等待接近 5 秒,除非行数或 Checkpoint 先触发 Flush。
3. Checkpoint
Checkpoint 会同步 Flush 当前 Buffer。MySQL 变慢时,snapshotState() 也会被拖慢,最终表现成 Checkpoint 对齐完成了,Sink 快照却迟迟结束不了。
缩短 Checkpoint 间隔可以缩小故障后的重放窗口,但也会更频繁地强制 Flush 小批次。
4. 并行度
提高 sink.parallelism 会增加 JDBC 连接数和并发 Batch 数量。
它可能提升吞吐,也可能把压力变成:
- MySQL 连接数过多;
- 热点 Key 或索引页竞争;
- InnoDB 锁等待;
- 多子任务同时 Checkpoint Flush 形成流量尖峰。
生产上不要只追一个“最佳 Batch Size”。更稳妥的做法是固定一组真实更新比例、删除比例和 Key 分布,逐步调节行数、间隔与并行度,同时观察 Flink Backpressure、Checkpoint 时长、MySQL QPS、锁等待、连接数和主从延迟。
十九、上线前最容易漏掉的八项检查
1. 两边主键是否完全一致
字段数量、顺序、类型、字符集和大小写规则都要核对,不能只看字段名大致相同。
2. MySQL 目标表是否真的有唯一约束
不要把 Flink 的 NOT ENFORCED 当成数据库约束。
3. 执行计划是否保留了正确的 Upsert Key
投影、Join、聚合以后,原主键不一定还能唯一标识结果。先执行 EXPLAIN,确认 Sink 接收到的 Key 语义没有变化。
4. 是否接受 -D/+I 跨批次的短暂空窗
对普通离线服务表可能没影响,对强实时在线查询就需要专项验证。
5. 目标表是否有 Trigger、级联或审计副作用
幂等检查不能只盯主表最终值。
6. Checkpoint 是否稳定成功
Checkpoint 长期失败时,Paimon consumer 进度不安全,故障后的重放窗口也会持续扩大。
7. MySQL 慢时是否让 Flink 形成 Backpressure
JDBC Sink 是同步 Flush。数据库执行慢,Sink 线程会等待,上游最终受到背压,这是保护 MySQL 的自然结果,也是容量不足的信号。
8. 有没有“无状态重新全量”的对账方案
重新 UPSERT 不能删除目标端孤儿记录。这个缺口必须在上线前决定由谁处理。
二十、值班时按这个顺序排查
| 现象 | 先查什么 | 重点判断 |
|---|---|---|
| MySQL 一行一行写,吞吐很低 | max-rows 是否为 1、实际每批 Key 数、Batch Rewrite | 是配置成单条,还是同 Key 合并后只剩一条 |
| 配了 100,MySQL 每批却不到 100 | Flush Interval、Checkpoint、子任务流量 | 是否被更早的时间或 Checkpoint 触发 |
| 更新后出现重复行 | MySQL 真实唯一约束、Flink Sink PK | UPSERT 是否真的命中了重复键 |
| DELETE 没生效 | Sink DDL 是否有 PK、目标 Key 是否一致、上游是否有 -D | 是没收到删除,还是 WHERE Key 不对 |
| 故障后主表正确,审计表重复 | Trigger 和副作用 | 行状态幂等,但副作用不幂等 |
| Checkpoint 变慢 | JDBC Flush 延迟、MySQL 锁等待、批大小 | 是否卡在 Sink snapshotState() |
| 无状态重跑后仍有旧数据 | 对比 Paimon 当前 Key 与 MySQL Key | latest-full 不会删除目标孤儿行 |
| MySQL 偶尔先看不到订单,随后又出现 | input 的 -D/+I 是否跨 Flush | 是否发生先 DELETE、后 UPSERT |
二十一、最后陪订单 1001 完整走一遍
Paimon Source 先输出启动时全量:
+I[1001, 80.00, CREATED]
JDBC Sink 的 invoke() 收到一条,Buffer 中记录:
1001 -> UPSERT[80.00, CREATED]
达到 Flush 条件后:
PreparedStatement 设置参数
addBatch()
executeBatch()
MySQL 插入订单 1001
订单金额改成 100。如果 Paimon input 输出:
-D[1001, 80.00, CREATED]
+I[1001, 100.00, CREATED]
两条在同一个 Buffer 中相遇:
1001 -> 最后只保留 UPSERT[100.00, CREATED]
MySQL 执行:
INSERT ... ON DUPLICATE KEY UPDATE ...
订单被取消并删除:
-D[1001, 100.00, CREATED]
Buffer 最终为删除动作,Flush 时执行:
DELETE FROM orders_rt WHERE id = 1001;
任务在 Checkpoint 42 完成前失败,从 Checkpoint 41 恢复后,这次 DELETE 又来一遍:
第一次删除:影响 1 行
恢复后再删:影响 0 行
最终结果:1001 不存在
到这里,出仓到 MySQL 的关键链路就可以压缩成五句话:
Paimon 按 RowKind 发变化
Flink 一条一条调用 JDBC Sink
每个 Sink 子任务按主键合并并攒批
Flush 时批量执行 UPSERT 和 DELETE
失败允许重放,目标主键操作负责结果幂等
真正需要记住的边界也只有两条:
Batch 不等于整个 Checkpoint 的原子事务
幂等重放不等于无状态全量能够自动清理目标脏行
这两条想清楚,才算真正知道 Paimon 出仓到 MySQL 时,数据是怎样写进去、怎样重来,以及什么时候重来仍然安全。
本篇关键源码位置
JdbcDynamicTableSink.java:声明 JDBC Sink 接收+I/+U/-D,并校验更新流必须有主键JdbcOutputFormatBuilder.java:根据是否有主键选择 Upsert Buffer 或 Append BufferGenericJdbcSinkFunction.java:逐条调用writeRecord(),并在snapshotState()中强制 FlushJdbcOutputFormat.java:维护batchCount、定时 Flush、行数 Flush、重试和 Close FlushTableBufferReducedStatementExecutor.java:主键表按 Key 合并同一 Buffer 内的变化,再拆成 UPSERT 与 DELETE BatchTableSimpleStatementExecutor.java:调用 PreparedStatement 的addBatch()和executeBatch()MySqlDialect.java:生成INSERT ... ON DUPLICATE KEY UPDATE,并默认追加rewriteBatchedStatements=trueAbstractDialect.java:生成按主键执行的DELETE FROM ... WHERE ...JdbcConnectorOptions.java:定义sink.buffer-flush.max-rows、sink.buffer-flush.interval和sink.max-retriesDataTableStreamScan.java、ContinuousFileSplitEnumerator.java:Paimon 侧持续规划 Snapshot 和 SplitFileStoreSourceReader.java:把 Paimon Split 中的记录逐条发给 Flink 下游
本文 Paimon 源码基于 Apache Paimon 1.4.2;JDBC Sink 示例按仓库默认 Flink 1.20.1,并核对 Flink JDBC Connector 3.3.0-1.20。不同版本的默认批大小、类名、Changelog 适配和 JDBC Driver 行为可能变化,升级时请以对应版本源码、执行计划和压测结果为准。
:Paimon 出仓到 MySQL:UPSERT、DELETE 与幂等恢复&spm=1001.2101.3001.5002&articleId=164267779&d=1&t=3&u=4348703e62874b15906850ce06aa6386)
1万+

被折叠的 条评论
为什么被折叠?



