系列导读与验证范围 · 一: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 |
| JVM | Microsoft 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,但数据库没有结果 |
| J | Actor 查询 + Journal payload 反序列化 | 内存状态、日志或回复被误当成持久状态 |
| D | JDBC offset 表原始 key/value | ACK 方法返回被误当成 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
但账本元数据已经不相等:
| 字段 | 已提交批次 | 恢复首批 |
|---|---|---|
| batchSequence | 5 | 5 |
| sourceRows / targetRows | 16 / 16 | 1 / 1 |
| cursor.offset.file | mysql-bin.000003 | mysql-bin.000003 |
| cursor.offset.pos | 1390884 | 1390884 |
| cursor.offset.event | 2 | 2 |
| cursor.offset.row | 16 | 1 |
第一条业务事件的 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 处:
| 字段 | 差异数 |
|---|---|
| amount | 48 |
| label | 48 |
| note | 25 |
| revision | 48 |
| updated_at | 47 |
| 合计 | 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保存完整日志、数据库导出和行数据。
上一篇:三个进度的恢复契约;下一篇:从重放不变量构造恢复协议。
文档信息
- 本文作者:Xuxiaotuan
- 本文链接:https://xuyinyin.cn/2026/10/18/pekko-cdc-3-mysql-journal-experiment/
- 版权声明:自由转载-非商用-非衍生-保持署名(创意共享3.0许可证)