从 Flink CDC 到 Pekko(三):怎样证明恢复失败——真实 MySQL、Actor Journal 与批次重放实验

2026/10/18 Pekko 共 6738 字,约 20 分钟

系列导读与验证范围 · 一:Actor 执行与资源 · 二:T/J/D 恢复契约 · 三:真实故障实验 · 四:不变量与恢复协议 · 五:与 Flink CDC 的取舍 · Flink CDC 深入系列


第二篇给出了一条可被推翻的假设:当前批次 ID 由回调序号生成,如果恢复后回调重新分组,同一个 ID 就可能指向不同内容。源码能说明这条路径存在,却还不能回答真实 Debezium 会交出什么、目标事务是否已经提交、Actor 是否真的从 Journal 恢复。

本篇把这几个问题变成可观察的实验。我们固定“目标已提交、Actor Checkpoint 未持久化”的窗口,在两个 JVM 之间恢复。同一条事件完整重放时,目标去重成功;原来的 16 条回调恢复成 1 条时,账本冲突导致 Actor 持久化失败,流水线停止追平。

这里整理的是 2026-10-18 已执行的真实 MySQL 实验。重写时核对测试源码、验证证据包全部 54 个内部文件哈希,并从保存的完整行数据独立复算结果;没有把复算写成重新启动数据库做实验。原始报告与复跑入口、本轮复算程序与结果均保留。

1. 先区分需要证明的三个命题

一次恢复看起来“成功”,可能只意味着进程启动了。一次测试显示“通过”,也可能意味着预期失败被正确复现。本文分开检查三个命题:

持久状态命题:进程死亡时,目标确实已有本批事务,Journal 确实还没有对应确认,D 确实仍保存旧恢复状态。

重放命题:新进程从真实 Journal 和真实 D 恢复,交出的首批与旧批是什么关系;Sink 是否再次执行业务 DML,还是拒绝或去重。

收敛命题:恢复后继续产生变更,源停止写入并等待处理后,目标是否与源全量逐字段一致。

三个命题缺一不可。没有第一个,就不知道注入的是哪个故障窗口;没有第二个,不能把去重成功与碰巧写出相同终值区分开;没有第三个,可能只是恢复了进程,却没有恢复同步能力。

这次实验不验证源事务在目标的整体原子可见。原始一个 SQL 更新多行,可以被 Debezium 分成多个回调,再分别成为目标事务。它也不验证多节点接管:两个 JVM 前后运行,始终只有一个主动执行者。

2. 为什么选择真实系统,而不是只 mock 一个 Future

实验链路使用生产 EventSourcedWorkflowActor、WorkflowExecutionEngine、MySQL CDC Source 和 MySQL Apply Sink。Source 连接真实 MySQL binlog,Journal 使用真实 JDBC 持久化,目标使用真实事务与批次账本。

环境固定如下:

项目值与边界
生产源码实验 HEAD ad58cf3;相关生产源码与公开 d88ec80 一致
核心依赖Pekko 1.1.3、Debezium 3.6.1.Final
数据库MySQL 8.4.11,ROW/FULL binlog,GTID ON
JVMMicrosoft OpenJDK 17.0.14,正式窗口实验固定 UTC
表64 行,6 列;主键、Unicode、NULL/空串、DECIMAL(18,4)、DATETIME(6)、revision
连接与事务Source、Sink、Journal 在同一隔离实例,但使用各自连接和事务
Actor 恢复同 workflowId、persistenceId、executionId;新 JVM 重放 Journal
Snapshot Store阈值设为 100000,本次没有触发

真实组件解决的是 mock 无法回答的问题:一次重启是否改变了实际回调分组;JDBC 事务提交后是否真的能查到账本;Journal 序列化出来的内容是否与恢复状态一致。

同时,真实组件不意味着生产覆盖。单实例、单目标、小数据集减少了变量,方便解释因果;它没有模拟网络分区、主库切换或大规模恢复拥塞。

3. 用因果闸门固定窗口,而不是靠睡眠碰运气

想截获 T 已提交而 J 未提交,最直接的办法似乎是提交后 sleep 再杀进程。但这种方式不能保证测试线程醒来时,Actor 尚未完成持久化。

本实验在测试 Sink 子类中先调用生产 super.commitBatch。只有真实 Future 成功后,才输出 CUTPOINT,并向上返回一个永不完成的 Promise。子进程设施

真实 Sink.commitBatch
  → 账本与业务 DML COMMIT
  → 生产 Future 成功
  → 测试记录 CUTPOINT
  → 测试返回未完成 Promise
      └─ Engine 的下一个 flatMap 无法调用 checkpointCommitted

这个闸门把一次瞬间窗口变成可稳定读取的状态。它没有伪造 Sink 提交,没有给 Actor 构造恢复 Checkpoint,也没有替换 Journal。它只延迟生产 Engine 得知“目标提交成功”。

门槛还包括本批确实含有我们刚写入的 revision=1 事件。否则可能截住初始快照或此前尚未完成的空批次,把不相关数据误当成故障样本。

3.1 注入前,先消除历史在途批次

实验先等初始快照完整对账成功,再更新一条 streaming 标记数据,确认已经进入日志阶段。接着等待四项条件同时成立:最新 T 的 batchId 等于 J 的 batchId;对应 ACK 已返回;真实 D 等于该 Checkpoint 的完整 offset;源目标仍然一致。

这道 ARM 前屏障很关键。若此前的 ACK/flush 还在途,切点时读取到的旧 D 可能属于更早的未完成工作,而不是目标批次造成的恢复间隙。编排源码

只有达到屏障,父进程才发送 ARM 并执行故障用 UPDATE。闸门触发后,再检查 T.sequence = n、J.sequence = n−1,Actor 查询和 Journal 反序列化结果一致,且目标 batchId 没有 ACK_BEGIN。

3.2 为什么终止整个 JVM

如果只停止 Actor 或调用 ActorSystem 优雅关闭,关闭流程可能促成 offset flush,改变我们要观察的 D。实验使用 Runtime.halt(86) 立即结束子 JVM,父进程检查退出码,再读取 offset 表,要求 D 与切点时相同。

这模拟的是进程突然终止,不是数据库断电。数据库本身继续运行,T 的持久事务也继续可查询。实验没有检验 MySQL 存储设备掉电后的持久性参数。

恢复使用新 JVM。不同 PID、不同 Journal writer UUID,以及相同 executionId 的记录,排除了“原进程内存还在,只是线程继续执行”的解释。更直接的检查是:Source 的 createBatches 收到的恢复 Checkpoint,必须等于故障前真实 Journal 中那一份。

4. 证据必须来自不同观察面

本次没有把所有状态都从一个应用日志里读出来,而是组合了下列证据:

观察对象实际来源排除什么误判
T独立 SQL 查询账本与目标业务表日志说 committed,但数据库没有结果
JActor 查询 + Journal payload 反序列化内存状态、日志或回复被误当成持久状态
DJDBC offset 表原始 key/valueACK 方法返回被误当成 flush 成功
重放内容生产 Source 输出的完整规范化事件与 cursor只因主键相同就断言是同一事件
业务副作用目标 AFTER INSERT/UPDATE/DELETE 审计触发器相同最终行值掩盖重复 DML
最终状态独立 JDBC 全表逐字段比较行数相同掩盖缺行、多行和字段错误

这些观测不是跨 T/J/D 的原子读。之所以能解释切点,是因为闸门阻断了本批继续推进;最终对账则在源停止产生新业务变更后进行。若生产环境一直写入,不能直接照搬两次不同时间的读结果来判断复制错误。

审计触发器也有边界:它覆盖目标 MySQL 内本次配置的业务 DML,不覆盖外部 HTTP、消息通知或另一数据库的副作用。它与目标事务一起提交,适合区分这次目标重放是否再次执行了操作。

5. 完整重放:账本怎样把 T 领先 J 的缺口补上

成功组证据目录为 verified/exact-replay-256621e3d9a6。原进程 PID=88833,恢复进程 PID=89042。

原批只有一条 UPDATE。切点为 T/J=6/5。恢复进程将真实 Debezium 的 max.batch.size 设为 1,实际输出的仍是同一条完整事件,cursor 完全相同,batchId 也相同:

50e99579f48e916e6a2a19bf8da8e2ea92e8d544c5e48c6b0707e4f8b762582d

此时恢复路径可以逐步解释:

J=5 → Source 从序号 6 编号
D 仍旧 → Debezium 再次交出同一事件
T 已有第 6 批 → 账本元数据完全相同 → AlreadyCommitted
Actor 将第 6 批写入 Journal → Source ACK → 后续继续

目标审计条数形成了一条重要证据链:

基线 65 → 原批提交后 66 → 重放后 66 → 后续三个变更后 69

重放前后都是 66,说明这次 AlreadyCommitted 路径没有重新执行目标业务 DML。它比“最终值相同”更强,但仍只覆盖这个目标事务副作用。

然后执行三个新变更:更新 id=1、删除 id=64、插入 id=65。最终 T/J=9/9,源目标各 64 行,六列全部一致,实际 D 与最后确认的 sourceOffset 一致。

实验结论:在这个固定窗口、相同完整批次重放的条件下,现有代码能够消化重复提交并继续追平。它没有证明任意重分批成立,也没有验证 J 已前进而 D 滞后的另一窗口。

6. 拆批重放:失败发生在保护检查,而不是静默丢行

失败组目录为 verified/split-replay-e0fec7eecef7。原进程 PID=89353,恢复进程 PID=89418。

源端对 64 行执行一个 UPDATE。原阶段允许真实 Debezium 每批最多 16 条,闸门只在实际业务批次至少两条时触发;本次截获 16 条。切点 T/J=5/4。暂停期间再产生与成功组相同类型的三个尾部变更。

恢复时,测试 Engine factory 只把真实 max.batch.size 改成 1。没有手工拆分 SourceRecord,没有重写 offset,也没有伪造一条失败事件。恢复后的实际首批只有一条,完整规范化内容等于原 16 条中的第一条。

同一执行从 J=4 推导出下一批序号 5,因此 batchId 再次是:

837cc2dd6a2f0627a151956ca59ba2c2be635574ffed6dacd1d7110213b65c24

但账本元数据已经不相等:

字段已提交批次恢复首批
batchSequence55
sourceRows / targetRows16 / 161 / 1
cursor.offset.filemysql-bin.000003mysql-bin.000003
cursor.offset.pos13908841390884
cursor.offset.event22
cursor.offset.row161

第一条业务事件的 source.position=1391108, source.row=0,还与用于恢复的 cursor 位置不同。第二篇解释过这两个层级;这里只报告样本值,不把它们简化成一个“最大 pos”。完整 GTID 等字段保留在 JSON 中。

真实 Sink 报 conflicting MySQL CDC ledger identity。Actor 随后持久化 ExecutionFailed,终态为 failed,T/J 停在 5/4。目标审计 81→81,没有增加新的业务 DML;D 在切点、进程终止后、失败终态保持相同完整值。

这条因果链说明:旧事件确实到达了生产 Source;失败发生在提交身份校验,保护检查阻止了冲突批次继续推进。因此它应被称为重分批导致的恢复停机与追平失败,不能叫 Reader 静默漏行,也不能据此宣称数据永久不可恢复。

这是不是一个“改配置才会出现”的人为问题

实验确实主动改变了批大小。因此它证明的命题是:当前协议不能容忍本次允许的真实重新分批。它没有证明保持相同配置时,自然重新分批发生的频率,也没有测量这种故障在生产中多常见。

但“回调边界可以变化”本来就是协议必须选择的适用范围。若项目要把批大小配置变更排除在恢复契约之外,就应明确拒绝不兼容恢复;若希望支持这类调整,则需要稳定于回调划分的业务身份。不能把实验条件删掉,再把结论说得更广;也不能因为条件受控,就忽略它确实反驳了无条件恢复保证。

7. 全量逐字段对账,怎样避免“64 行等于 64 行”的错觉

失败组最终两边都是 64 行。如果只验收 COUNT(*),结果会显示一致。实际集合却不同:源包含 id=65,目标还保留 id=64。

比较程序先建立主键索引,再分三部分:

缺失键 = 源主键集合 − 目标主键集合
多余键 = 目标主键集合 − 源主键集合
字段差异 = 对共同主键,逐列比较源值与目标值

最终缺少 65、多出 64。48 个共同主键有字段差异,共 216 处:

字段差异数
amount48
label48
note25
revision48
updated_at47
合计216

注意这个总数的含义:它只统计共同主键上的字段差异。缺失和多余的整行另外记录,没有把每行六列再算入 216。

为什么不是 48×5?不同字段有可能本来就是 NULL,或者尾部更新使某个值恰好相同。验收应比较实际字段值,不能用“受影响行数×列数”代替逐项计算。

更重要的是,216 处差异不能归因为“失败重放污染了 216 个字段”。切点时,原 64 行 UPDATE 仅提交首批 16 行,其余事件尚未应用;暂停期间又增加三个变更。账本拒绝后,这些待同步差异持续存在。审计不增长支持的是“拒绝没有新增目标 DML”,不支持“停止同步时目标已经完整”。

独立比较器如何处理类型

比较器通过独立 JDBC 查询,在 REPEATABLE READ 事务中读取源表和目标表全部数据,不使用 CDC 的解码函数。使用相同表定义与 getString 读取列值,保存 DECIMAL 的文本与 scale,区分 NULL 和空串,保留 Unicode、空格和 DATETIME 微秒。

这减少了流水线与比较器共享同一个转换错误的风险。本轮仍只是六个列定义的比较,不是所有 MySQL 数据类型的兼容性证明。若换成跨数据库目标,需要额外定义时区、精度、JSON、二进制和字符排序等比较口径。

重写时新增的 verify_evidence.py只读取不可变证据包,重新按主键计算缺失、多余和每个字段差异,并检查其与原报告相符。这提供了可重复的材料核验,没有新增一次 CDC 运行证据。

8. 前置失败必须保留,否则会高估恢复能力

最早的默认 JVM 时区实验在故障注入前就失败了:64 行 DATETIME 从源 2026-10-18 01:02:03.654321 写成目标 2026-10-17 17:02:03.654321,偏移 8 小时。

这不是恢复窗口造成的错误。若基线已错,之后的失败无法单独归因给批次重放。正式窗口实验固定子 JVM 为 UTC,隔离这个变量;生产时间转换代码没有修改,默认时区问题仍未修复。

另一次 UTC 拆批实验未进入窗口:初始快照接近 60 秒等待上限时才开始输出,采集时仅有 62/64 行。随后把基线等待上限改为 180 秒,并补充 ARM 前的持久进度屏障。接受标准仍是全表一致,没有改成“多数行成功”。这些失败样本也在归档中。

最终测试日志 2 succeeded, 0 failed 的准确翻译是:成功对照符合预期,失败反例也符合预期。它不是“两个恢复场景都正确”,不是修复验收,更不是整个仓库的测试结果。

9. 从反例得到协议约束,而不是立即删掉保护

本次实验反驳的是一个具体隐含前提:在当前恢复策略下,回调序号可以无条件充当稳定批次身份。原批内容与恢复内容变化后,账本保护能识别冲突,但缺少安全继续的协议。

它没有证明可以安全取消账本比较。取消比较可能让原 16 条的账本被当作只有 e1 的确认,或者使重复历史事件再次覆盖新状态。也没有证明“多重试几次”会改变两次批次含义不一致的事实。

下一步需要把确认依据从偶然的传输分组中剥离,同时保留目标事务原子性、冲突拒绝与真实状态查询。第四篇会从不变量推导这项改造,不把一次失败复现直接升级为一份已经正确的新实现。

本篇支持仍未验证
指定 T 已提交/J 未持久化窗口能够稳定截获COMMIT 回复丢失、J 已持久化/D 滞后
单事件完整重放成功并继续全量收敛任意自然拆批、合批及其发生频率
真实 16→1 重分批触发冲突并停止追平修复后的重分批恢复
数据值与目标 DML 审计可分别核对外部副作用 exactly-once、源事务整体可见性
新 JVM 从真实 Journal 恢复Snapshot Store、Sharding、分区接管、旧 owner
当前小数据集与固定 UTC 条件DDL、日志清理、主库切换、70 万表容量

原始证据包与 SHA256保存完整日志、数据库导出和行数据。

上一篇:三个进度的恢复契约;下一篇:从重放不变量构造恢复协议。

文档信息

Search

    Table of Contents