从 Flink CDC 到 Pekko(四):重新分批之后,恢复为什么仍应成立——从不变量构造 T/J/D 协议

2026/10/18 Pekko 共 7620 字,约 22 分钟

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


第三篇已经把问题缩小到一个明确边界:目标提交了一组事件,恢复后同一组事件换了回调划分,批次 ID 与内容的对应关系失效。修复不能只换一个哈希公式,也不能让 Sink 遇到账本冲突时直接忽略。

我们真正需要的是一个性质:同一段源历史,无论怎样分成传输批次、在允许的提交窗口崩溃多少次,都应得到规定的目标效果;重复读取不应重复制造业务效果。 本篇从这个性质推导身份、目标事务、Actor 确认和源进度的契约。

下文是设计推导,不是已实现功能。项目生产源码仍为 d88ec80。新增的有限模型检查验证了抽象算法在限定状态空间内的性质;真实 MySQL、Actor Journal、offset store 上的新协议仍为 evidence_incomplete。源码与证据范围

1. 先写出事件历史,才能谈“同一批”

设一个捕获分区的业务历史为有序序列:

E = e1, e2, …, en

其中每个事件包含足以确定目标操作的内容。传输层将它切成连续批次,比如 [e1,e2] [e3];恢复后可以变成 [e1] [e2,e3]。这些括号只描述一次交付,不应改变事件的业务身份。

若 Apply(e, S) 表示事件 e 对目标状态 S 的作用,理想结果是按源顺序折叠整段历史:

S* = Apply(en, … Apply(e2, Apply(e1, S0)) …)

这里不能把 E 换成无序集合。对同一主键,先 UPDATE 后 DELETE,与先 DELETE 后 UPDATE,结果可能不同。去重解决重复执行,顺序解决新旧覆盖;两个约束都需要存在。

第一阶段先限定在:单个可排序捕获分区、一个主动 writer、固定 Schema、已完成可信初始化的目标、可继续重放的 streaming 日志、单个事务型 MySQL 目标。目标表与去重证据位于同一事务边界,且没有绕开复制协议的任意目标写入。

这些假设不是为了回避工程问题,而是为了知道证明在哪一步使用了什么。若目标被其他程序修改,重复事件被正确跳过也不能自动修复外部修改;若初始全量不可信,仅证明后续每条事件一次,也不能证明整张表一致。

2. 三种身份各自承担什么职责

当前 BatchId 对 executionId、sourceNodeId、partitionId 和 callbackSequence 做散列,适合标识本次执行中的传输批次。它缺少的不是散列强度,而是与稳定源事件的对应。

新协议需要区分:

身份负责什么恢复后的要求
deliveryToken关联本次回调、内存资源和 ACK 句柄可以变化,不能作为跨进程去重键
eventId标识一条可重放的源事件或操作分量同一事件不变,不同事件不混淆
commitToken标识目标已经持久提交的覆盖证据可被重复查询、重复确认,不要求等于回调序号

当前 Bridge 已有 deliveryToken,可以保留它的局部用途;目标 commitToken 和稳定 eventId 则需要新增契约,不能声称已有。

2.1 主键、payload 哈希和末尾 cursor 为什么都不够

表主键标识一行,不标识一次变更。相同主键可以连续更新多次;把主键当 eventId,会把后续合法更新误删为重复。

payload 哈希标识内容,也不必标识一次发生。两次合法业务事件可以写入相同值;若承诺包含审计等副作用,将它们合并同样错误。

整批末尾 cursor 只描述这一批结束时的恢复上下文。将它分配给批内所有行,会把多条事件合并;将它作为整个批的身份,则在拆批后改变。第二篇已说明 Debezium 的 source position 与 restart offset 也不应混为一谈。

一个候选身份需要稳定的源谱系、日志事件位置、事件内行位置和操作分量等信息。但是,本文不把这个候选列表写成已证明正确的公式。主键修改可能产生多个逻辑操作,事务内多行事件需要区分位置,主库切换可能改变文件命名;当前规范化 Envelope 还没有完整保留所有可能需要的事务与谱系信息。Envelope 结构

第一道实现门槛是:从真实 SourceRecord 中证明候选字段的稳定性与区分能力。测试必须覆盖同一事务多次更新同一行、多行 binlog event、主键变化、重启重读和显式拆批。通过这些测试之前,eventId 仍是协议假设。

2.2 owner epoch 与复制 generation 不能混用

owner epoch 表示当前哪一任执行者有写权限。节点接管时它应变化,但同一源事件的去重身份不应因为换 owner 而变化,否则接管本身会绕过去重。

复制 generation 则表示一次有明确初始化与配置语义的数据复制历史。重新全量装载、目标映射语义变化或重新定义基线,可能需要新 generation;普通进程恢复不应随意换 generation。一次失败后生成新 executionId,也不应自动让已提交事件失去旧去重证据。

因此可以把账本命名空间描述为“目标复制任务 + generation + 身份规范版本”,再在其中识别稳定 eventId。Schema 与转换规则参与内容解释和冲突校验;如果改变规则,必须定义迁移方式,不能仅靠当前配置重新解释旧记录。

3. 目标事务:先把一个事件的效果与证据绑在一起

设计的最小目标事务如下。它是协议伪代码,没有写入生产 Sink:

BEGIN
  验证当前 owner / generation / Schema 契约
  按源顺序遍历本次交付的事件:
    若 eventId 已有证据:
      校验其规范化内容与语义版本相同
      不重复应用业务效果
    否则:
      应用业务效果
      保存 eventId、内容证据及归属信息
  保存本次结果的覆盖凭据 commitToken
COMMIT

原有 MySQLCdcApplySinkNode 已把目标 DML 和批次账本放在同一事务里。这个边界可以保留。需要变化的是去重粒度与提交凭据的含义:恢复首批只包含 e1 时,应该查询 e1 的既有证据,而不是要求它重新组成旧的 16 条批次。

检查与写入必须形成一个原子决定。若只是事务外查询“eventId 不存在”,两个 writer 都可能通过检查。第一阶段的单 writer 限制减少并发问题,目标唯一约束仍应是最终防线;引入多 owner 后,还必须结合 fencing。

遇到相同 eventId、不同规范化内容时,整批事务回滚并进入可诊断阻塞。不能选择“以新内容为准”,因为此时无法知道是身份冲突、Schema 解释变化还是源历史发生变化。生产实现若用摘要替代完整内容比较,还需固定规范化规则与摘要版本;有限模型直接比较内容,不验证真实摘要方案。

3.1 从原子性推出“每个事件至多应用一次”

假设 eventId 稳定唯一,所有目标写入经过上述事务,去重证据不被提前删除。

第一次处理 e 时,如果事务未提交,效果与证据都不可见,恢复可以重新处理;如果事务提交,二者同时存在。以后再遇到 e,账本命中且内容相同,跳过效果。因此,在这个假设集合内,e 不会产生第二次目标业务效果。

这条证明没有使用批次大小。所以 e 与哪些邻居一起被交付,不影响其判定。

3.2 “至多一次”为什么还不是最终正确

永远拒绝所有事件也满足至多一次,却完全不同步。还需要至少一次最终覆盖:源日志保留,恢复不会跳过缺口,依赖最终可用,未提交事件能再次获得执行机会。再加上正确的初始目标状态与同分区顺序,才有条件得到最终状态 S*。

如果两个不同的新事件乱序进入同一主键,即使它们各执行一次,目标仍可能错误。事件级去重也不保证整笔源事务在目标同时可见。64 行 UPDATE 被分成多个目标事务时,中间状态仍然可见;事务级一致性需要另建源事务缓冲与提交机制。

4. T、J、D 应该保持哪些关系

定义 Covered(T) 为已经有目标持久证据的事件集合;Acked(J) 为 Actor 宣告已确认的事件覆盖;Skip(D) 为按照 D 正常恢复时不再交付的必需事件。还要给允许忽略的元数据记录定义明确的“已处理”规则。

可以表达三条安全约束:

Acked(J) ⊆ Covered(T)
Skip(D) ⊆ Covered(T)
每个被跳过的记录,必须有已提交或按固定规则无需应用的证据

这里不要求 T、J、D 每时每刻相等。T 先提交、J 稍后确认、D 更晚刷盘,是正常路径。正确协议需要能够容忍这种滞后,而不是靠降低 flush 间隔假装滞后消失。

J 仍然是工作流意图和生命周期的权威来源,例如是否启用、处于什么 generation、是否被人工停止。T 则是目标业务提交的事实来源。不能用 T 中存在一些新提交,推翻 J 中明确的停止意图并擅自继续运行。

4.1 为什么完成前缀不能取最大值

假设共享日志顺序为 a1, b1, a2,分别属于两张表。B 的 writer 已完成 b1,A 的 a1 尚未完成。取最大完成位置会得到 b1,并越过 a1。

正确前缀是:从原安全位置起,直到第一个未完成记录之前,所有必需记录都有处理结果。可记录乱序完成集合,但只有填上连续缺口,才能推进全局确认。

这里“位置”是已定义的源顺序,不是将不同 binlog file、GTID 集合或 JSON pos 随意做数值 max。过滤记录、事务边界和分区都要纳入源恢复语义。

4.2 ACK 完成仍不替代读取 D

第二篇已经核实 markBatchFinished 存在 flush 未成功却返回的源码分支。因此运行状态至少应分别记录:目标提交、J 确认、ACK 调用返回、观测到的 D 持久化。D 滞后不是立即判定数据损坏,而是意味着下一次恢复可能重复更多历史。

协议必须能消化重复,监控也应区分“数据已到目标,但源恢复点未推进”与“目标尚未收到数据”。两种状态的处理方式不同。

5. 恢复状态机:先协调事实,再恢复交付

当前 EventSourcedWorkflowActor 能从 Journal 恢复执行并重新启动 Engine。可以保留这个入口,但恢复后不宜立即把旧 J 当成唯一事实启动下一批编号。

建议的状态转换是:

恢复工作流意图与 generation
  → 获取并确认本任所有权
  → 校验源谱系、目标身份与 Schema
  → 查询目标提交证据,协调 J 的缺口
  → 从 D 重放
  → 目标去重/提交 → J 确认 → 连续覆盖允许的 Source ACK

这些是协议状态,不是现有类名。

若 T 已提交、J 尚未确认,协调过程可以把经过验证的目标凭据补入 J,也可以在重放到达后按事件命中证据再确认。两条路径都不能仅拿 T 的最大序号越过未证明的区间。

若 J 已确认、D 仍旧,恢复读取会再次交出旧事件。稳定 eventId 仍命中 T,因而不受新的回调序号影响。这正是当前批次 ID 方案最难处理、而新契约必须覆盖的窗口。

若目标 COMMIT 回复丢失,调用方先进入结果不确定状态,查询相同提交身份或逐事件证据。查不到不一定意味着永远没提交,查询需要遵循目标事务隔离与最终结果可观察的契约;不能在旧请求仍可能提交时无限发起不受约束的新写入。

如果源日志已清理、源谱系不匹配、证据缺失或同身份内容冲突,应进入可解释的阻塞状态,保留事实供修复。恢复的“活性”不能建立在跳过未知数据的基础上。

直接把 T 的最大 cursor 写回 Debezium offset store 并不是上述流程的一部分。T 保存的是提交覆盖,D 还包含读取器恢复上下文;未经证明的 seek 加速可能跳过事件或破坏事务内跳过状态。

6. 有限模型检查了什么

为了检查重分批不变性,本轮写了一个独立 Python 模型:protocol_model.py。它使用六条有序事件,包含同一键多次更新、删除、重新插入和另一键更新。模型直接提供稳定唯一 eventId,目标事务以复制状态后整体提交模拟。

六条事件有 32 种连续分批方式。程序枚举原始分批、每个原批提交边界、允许滞后的源恢复前缀,以及恢复后剩余事件的全部连续分批方式。共检查 7,696 个组合。

所有组合都检查两件事:最终业务状态等于不发生故障的顺序执行;每个事件在目标审计列表中恰好出现一次。最终状态为 a=3, b=8。模型还检查同 eventId 不同内容必须拒绝,并保留三个负例:旧 after-image 覆盖新值、最大完成位置越过缺口、提前清理账本后旧事件再次执行。实际输出

模型结果不能升级成实现结果。 它没有构造真实 SourceRecord eventId,没有执行 J 持久化、offset flush、并发 writer、DDL 或 MySQL 事务。原子性、稳定身份和单 writer 是输入假设,不是被程序发现成立的性质。

它的价值在于让第四篇的论证有一个可执行的有限检查,能够暴露算法说明中“换个批次就改变含义”的错误;完整正确性仍依赖上文推理及实际实现对假设的兑现。

7. 两个会破坏证明的工程边界

7.1 旧 owner 不会因为 Actor 被重新分配就失去数据库权限

Cluster Sharding 解决实体归属,进程内 KillSwitch 解决本进程执行取消。它们不能自动让网络分区另一侧的旧进程失去 MySQL 写权限。当前 registerReliableExecution 的取消协调只发生在本进程。

多节点阶段需要目标可验证的 owner epoch。一个可推理的单目标实现是:更新所有权与业务提交都锁定同一条所有权记录;业务事务检查 epoch 后,在锁保护范围内写 DML 和证据;新 owner 增加 epoch 时也经过这条串行边界。

这样旧事务要么在接管前完成,要么在接管后检查失败。若只是事务外读一次 epoch,旧进程可以读完后暂停,新 owner 接管,再由旧进程继续提交,fencing 就失效了。

这只是候选实现。热点锁、接管等待、源 offset 写入权限和多目标提交仍需另外设计;没有分布式实验前,不能用一个 epoch 字段宣称防住了双写。

7.2 去重证据清理可能让已经证明的性质失效

假设 e1、e2 已提交,目标当前为 e2 的新值。删除 e1 的去重证据后,从老恢复点读到 e1,目标会把它当新事件应用,造成回退。模型已经给出这个最小反例。

因此清理水位不能只跟随最近 D。还要考虑允许恢复的最老 Checkpoint/备份、旧 owner 是否失效、源历史是否仍可能重读、重新初始化策略和审计保留需求。

连续区间压缩可以减少每事件账本成本,但前提是区间没有洞,事件顺序和分区定义稳定。把离散已完成集合压成一个最大值,只是把缺口藏起来,不是压缩证明。

8. 共享读取、快照和 Schema 为什么要扩展模型

第一篇指出当前一表一 Engine 的成本。共享捕获器有机会降低连接和线程数量,却让确认进度覆盖多张表。

慢表阻塞时,可以停止共享 Source 的确认,让同捕获域一起背压;也可以先把未完成事件写入持久 spool,再允许 D 前进。后一种情况下,安全关系变为“目标已完成,或已有可重放 spool 证据”,系统多了一份持久边界及其消费进度。它不是免费获得隔离,必须再验证 spool 丢失、清理和恢复。

并行快照同样不能套用 streaming eventId。当前 MySQLSnapshotSourceNode 使用主键上界和 keyset 分页。上界限制扫描范围,不代表每一页来自同一个可恢复数据库视图,更不等于 Flink CDC 的 LOW/HIGH 回填。

快照若重跑后读取到了新值,不能仅用“表主键 + 同一个 snapshot generation”把新内容解释成旧快照行。需要定义快照代次、分片范围、日志衔接和目标覆盖规则;否则稳定 ID 可能稳定地认错内容。

Schema 变化也会改变事件解释。旧 Schema 数据仍在 writer 缓冲时,先执行 DDL 可能使旧数据按新结构写入。一个演进协议至少需要说明:哪个源位置是 Schema 切换边界,旧数据如何排空或保留旧版本解释,DDL 成功而 J 未记录时如何根据目标状态重复确认。

当前 Schema identity 检查与不支持类型拒绝应该保留。它们是边界保护,不是 DDL 演进实现。把检查删除,并不会自动生成排空屏障或幂等迁移。

9. 怎样把证明义务落实到现有类

现有组件保留的机制真正需要变化的契约
Source / Envelope真实 Debezium、规范化、完整恢复 offset稳定事件身份所需原始信息、显式阶段与身份版本
BridgedeliveryToken、回调句柄、局部容量限制ACK 以完整覆盖为条件;阻塞等待与取消资源需要另行处理
Apply SinkDML 与证据同事务、类型检查、冲突拒绝事件级去重、可查询覆盖凭据、目标所有权校验
ExecutionEngine顺序处理、错误归属、取消入口不再用回调编号推断业务覆盖,处理未知提交结果
EventSourced Actor持久生命周期、executionId 校验、恢复入口协调态、目标凭据确认、generation 与人工阻塞语义
测试设施真 MySQL/Journal、闸门、强制退出、全表比较扩展故障窗口与反例,分别验收活性和安全性

第一篇已复现的共享阻塞池饥饿,也必须计入活性条件。数学上允许事件重试,不代表执行池里真的有线程执行提交和关闭。协议证明与资源验证不能各自假定另一方永远工作。

10. 故障矩阵应当检验哪些证据

下面是新协议的验收要求,未执行的格子不标通过。各用例都要保存真实 T/J/D、交付内容、目标操作审计和静止源的全量逐字段结果。

固定窗口或变体必须核实的事实期望的新协议行为当前证据
T 已提交,J 未持久化;1→1、16→1、1→16T 中原事件覆盖与恢复事件身份旧事件不重写,新事件继续,最终全表一致旧协议真实 1→1 成功、16→1 失败;新协议未实现
J 已持久化,D 未更新Journal 实际有凭据,D 仍旧旧日志重读不重复产生效果模型仅覆盖重复事件;真实窗口未测
COMMIT 成功、客户端回复丢失独立查询目标事务事实查询后协调,不凭异常猜回滚未测
ACK 返回但 flush 失败ACK 日志与实际 D 分开读取容忍更旧重放,告警可辨识上游源码分支;未注入
相同 eventId、不同内容原始记录、规范化版本和账本整批回滚并阻塞,不吞掉冲突抽象模型拒绝;真实编码未测
owner 交接时旧请求晚到epoch 与目标提交线性化位置拒绝接管后的旧 owner 写入未测
慢表完成有洞,共享 Source 继续每路提交与持久 spool/前缀不跳过未处理事件,资源有明确上限模型有越洞反例;共享实现未做
账本清理后恢复旧点允许的最老恢复点与被删证据拒绝非法旧恢复或仍正确去重模型有提前清理反例;真实未测
旧 Schema 缓冲未清空时 DDL缓冲版本、DDL 事实、J 的位置不错解释旧数据,DDL 可安全协调本项目未测

这份矩阵的通过标准不是“没有异常”。冲突输入应该安全拒绝;正常重复应该恢复推进;每个结果都要与预先规定的行为一致。

本文给出的协议仍然有严格边界,但它已经能够回答为什么重新分批不应改变业务结果、为什么仅有最大进度不够、以及每一处代码变化需要什么证据。下一篇将这些责任放回 70 万表场景,比较自建 Pekko CDC 与使用 Flink CDC 分别承担的成本。

上一篇:真实故障实验;下一篇:Pekko 与 Flink CDC 的取舍。

文档信息

Search

    Table of Contents