Flink CDC 深入解析(二):增量快照的水位、回填与多 Chunk 衔接

2026/10/18 Flink 共 19669 字,约 57 分钟

系列导读与验证材料 · 一:一致性边界 · 二:增量快照 · 三·上:Source 状态机 · 三·下:故障恢复 · 四:Schema 演进 · 五:Paimon 提交


第一篇区分了状态一致、事件历史与端到端提交。这一篇进入增量快照的核心:数据库持续写入时,一次 SELECT 的结果如何与 binlog 对齐;多个 Chunk 在不同时间完成后,如何拼成可以继续追赶的表状态。

关键结论是:每个 Chunk 先用窗口日志把自己的扫描结果归一到自己的 HIGH;持续日志再从最小 HIGH 起读,按每个 Chunk 的 HIGH 消除已经包含在快照中的变化。 快照阶段结束时,各 Chunk 可以对应不同的时间边界。把它们对齐到同一个日志位置,还需要后续日志。

源码固定为 Apache Flink CDC release-3.7.0,提交 045e8da45df4b1017849b04e2d8bfe8f0ed7936d。主线是 MySQL Connector 的独立实现,默认开启回填,表有稳定主键、稳定 Chunk key,Schema 在读取期间不变。下文给出这些条件下的人工证明草图,不是 DBLog 对 Flink CDC 的现成证明,也不是机器验证。“实测”部分来自 2026 年 10 月 18 日执行的真实 MySQL Reader 集成测试;形式化补写之后,先用真实 MySQL Reader 验证撤去稳定 Chunk key 假设的迁移场景,再另行运行完整 Flink MiniCluster → Paimon 作业。两层实验分别设置稳定键对照、保存独立证据;所测迁移在 Reader 重建和真实目标当前表中都出现缺行,不能推广为所有配置的结论。

先解决扫描结果属于哪个时间的问题

设一行的值依次经历 10 → 20 → 30。扫描可能读到 10,也可能读到 20。若先发出扫描结果,再把一段重叠日志全部发出,读到 20 的那一行可能再次收到把值设为 20 的更新;若把日志先发出去,再输出旧扫描行,又可能用 10 覆盖 30。日志本身有序,并不足以决定扫描行应该插入历史的哪个位置。

最直接的解法是得到一个全表一致性读视图,并把它准确对应到日志位置。但增量快照希望把大表拆开扫描,让进度和资源占用以 Chunk 为单位。此时要解决的核心问题变为:即使不知道 SELECT 对应的精确日志位置,能否找到包含它的窗口,并消除窗口内的不确定性。

DBLog 如何用窗口安放扫描行

DBLog 的 Algorithm 1 先短暂停止日志处理,更新水位表写入 LOW,读取一个主键范围,再写 HIGH 并恢复日志处理。两次水位写入产生可识别的日志事件。消费 LOW 到 HIGH 之间的变化时,照常输出日志,同时从扫描缓冲区删除同键行;遇到 HIGH 后补发剩余扫描行。它要求提交历史可排序、范围读取高效,且 SELECT 不能读到 LOW 之前的陈旧视图。论文的“避免长时间停顿”允许上述短暂停顿。DBLog 第 3.2、3.3 节及 Algorithm 1

Flink CDC 的 SignalEventDispatcher 明确注明受到 DBLog 启发,但代码采取了不同的输出策略。MySQL 路径在本地队列中构造水位信号,把窗口日志合并进扫描结果,最后输出归一后的快照行。理解这一步,才能判断窗口中的中间更新是否还会作为独立事件交给下游。SignalEventDispatcher

需要比较的行为DBLog Algorithm 1本文 MySQL 增量快照路径
水位产生位置源库水位表写入形成日志标记查询日志位置,向本地队列放入信号
扫描与日志同键冲突移除该扫描行,保留日志输出按日志覆盖或删除缓冲中的行
窗口处理后的结果日志与未冲突扫描行交织合并后的快照行,再进入持续日志阶段

从不变量看,DBLog 保持的是“尚待补发的扫描键,与窗口内已经出现变化的键不相交”。若 C(p) 表示 LOW 之后、已消费至位置 p 的本表变更键集合,则剩余扫描缓冲的键为 keys(Q) − C(p)。日志继续按原顺序输出;到 HIGH 时,剩余扫描行在整个窗口内未改变,因此补发它们不会覆盖窗口中的较新版本。这个推导依赖论文的读取与日志前提,不包含下游投递的 exactly-once 证明。DBLog 第 3.1—3.3 节

Flink CDC 下文要证明的是另一件事:用日志改写扫描 Map,使最终 Map 等于 HIGH 处的状态。两个算法共享窗口思想,但“排除冲突扫描行”与“吸收窗口变化”对应不同的不变量,不能交换它们对事件历史的结论。

沿真实调用链找到 LOW 和 HIGH

一次快照 Split 的主调用链如下。这里的 MySqlSnapshotSplit 是一个表的一个键范围;MySqlBinlogSplit 则描述日志读取的起止位置及已完成快照的信息。

SnapshotSplitReader.submitSplit
  ├─ MySqlSnapshotSplitReadTask.doExecute
  │    ├─ currentBinlogOffset → LOW
  │    ├─ SELECT 当前 Split 的键范围
  │    └─ currentBinlogOffset → HIGH
  ├─ createBackfillBinlogSplit(LOW, HIGH)
  └─ MySqlBinlogSplitReadTask 执行有界回填

SnapshotSplitReader.pollWithBuffer
  └─ 消费上述队列 → 合并 Map → 输出 LOW、快照行、HIGH

submitSplit先配置上下文,再在工作线程中执行扫描和回填;doExecute依次读取 LOW、完整扫描当前范围、读取 HIGH。读取 HIGH 时,回填尚未完成。HIGH 表示扫描需要追赶到哪里,不表示这个 Chunk 已经向下游发送完毕。

LOW/HIGH 的载体是 BinlogOffset。取得位置时,DebeziumUtils.currentBinlogOffset读取日志文件、位置和可用的 GTID 集合。该版本的 MySQL 连接会探测 SHOW BINARY LOG STATUS,不支持时回退到 SHOW MASTER STATUS。随后 SignalEventDispatcher创建 SourceRecord并执行 queue.enqueue,这个步骤没有向源库写水位表。位置采集、SQL 探测

这些信号也不具有 Flink 事件时间 Watermark 的含义。MySqlRecordEmitter识别内部水位,将 HIGH 写入快照 Split 状态,不把它当作业务行反序列化输出。事件时间推进、日志边界和 Checkpoint 完成,是三套不同的机制。MySqlRecordEmitter.processElement

先定义要证明的状态与模型假设

只考虑一张表的一次正常初始读取。令 U 为记录主键集合,S(p): U → Row ∪ {⊥} 为日志位置 p 对应的表状态,⊥ 表示该键不存在。CREATE/UPDATE 将键的值设为完整 after image,DELETE 将它设为 ⊥;这里不把更新解释成“再加一次增量”。状态等式只比较业务列值与行的存在性,不比较 READ 包装、sourceOffset 或时间戳等记录元数据;后文 Map 中缺失的键也统一记作 ⊥。

固定 Chunk key 函数 g(k) 后,范围 K_i=[a_i,b_i) 对应主键集合 U_i={k | g(k)∈K_i}。S_i(p) 是 S(p) 在 U_i 上的限制,Q_i 是 SELECT 返回的完整键值映射,未返回的键记为 ⊥。E_i(a,b] 表示逻辑窗口内属于 U_i 的有序行变更,Apply 表示按上述赋值/删除语义折叠这些事件。

L、H、p、q 是同一条历史中的逻辑切点,涉及真实数据库状态时取完整事务提交后的边界;事务内部可按行事件顺序推演,但不能把中间状态称为已提交表状态。实现中的 BinlogOffset 如何对应这些切点,是后文要核对的义务,不能预先简化成文件位置整数。

下列条件是证明的充分前提,不是“代码调用顺序正确”就自动获得的事实:

编号模型假设在实现中需要核对什么
A1日志来自同一条可比较的提交历史;状态与日志顺序相容位点来自同一源历史,不能把分叉 GTID 当成线性时间轴
A2存在 L_i≤R_i≤H_i,使 Q_i=S_i(R_i),包括不存在的键;范围扫描无漏行读视图不早于 LOW,也不含 HIGH 之后的状态;查询完整结束,不能只看事务隔离级别名称
A3主键与 g(k) 在证明区间内稳定;U_i 两两不交且覆盖所需键域SQL、Java 范围判断与排序比较一致;新插入也有唯一范围,删除再插入不改变其归属
A4Schema 固定,事件提供正确键和足以重建行的 after image,保留 NULL 语义UPDATE 是完整状态覆盖,DELETE 能定位原键;不能把缺列当 NULL
A5回填提供 (L_i,H_i] 的完整有序事件,持续日志也不漏事件;本模型每个逻辑事件按原顺序处理一次,边界映射一致不能缺失末次更新、乱序覆盖或把 HIGH 后的记录并入快照
A6正常回填开启,消费完窗口才输出;衔接使用与快照一致的范围/HIGH 元数据,先建立快照基线再应用增量BINLOG_END、FinishedSnapshotSplitInfo 与切换协议各有责任;当前模型不包含失败重放或 Sink 提交

A2 是便于阅读的较强条件。下面的逐键证明实际只需要:窗口内从未改变的键,其扫描值和存在性正确;窗口内改变过的键,由完整日志的最后一次状态覆盖修正。不过,不能因此把读取不陈旧、完整范围扫描等要求省略。DBLog 第 3.2 节也明确要求 SELECT 位于水位窗口内;本文没有证明所有 JDBC 配置都满足这个前提。

源码在这条扫描路径上没有显式把隔离级别设置为 READ COMMITTED。本轮用同一配置和连接工厂新建的 JDBC 连接,实测 autocommit=true、隔离级别为 JDBC 的 4,即 REPEATABLE_READ。这个观察不能被改写成“Flink CDC 强制使用 READ COMMITTED”。判断读取是否足够新,需要结合事务生命周期和实际连接配置。

事务开始时间也不决定归属。一个事务可以在 LOW 之前开始修改,在 SELECT 时仍未提交,直到 HIGH 之前才提交。此时 SELECT 可以读到旧版本,提交后的 binlog 会在回填阶段把它修正。本轮针对这一时序做了独立连接实验,结果见后文。

回填如何把扫描结果归一到 HIGH

正常回填时,队列里的输入顺序是:LOW、扫描行、HIGH、窗口 binlog、BINLOG_END。pollWithBuffer把扫描行放入 Map;遇到 HIGH 后切换到回填阶段;遇到 BINLOG_END 才结束合并。最终返回 LOW、Map 中保留下来的行、HIGH。因此,队列里先出现 HIGH,并不意味着 HIGH 后面的回填日志已经向下游输出。pollWithBuffer

flowchart TD
    A[LOW 后读取扫描行] --> B[按主键放入 Map]
    B --> C[遇到 HIGH 切换到窗口日志]
    C --> D{日志所属键是否在当前 Chunk}
    D -->|否| C
    D -->|是| E[CREATE 或 UPDATE 覆盖 DELETE 移除]
    E --> C
    C -->|遇到 BINLOG_END| F[输出 Map 中的 READ 行]
    F --> G[输出 HIGH 信号]

对于有主键的表,核心规则可以缩写为以下伪代码。它表达的是已核对源码的行为,省略了 Schema、信号、无主键表和异常处理。

M = 将 SELECT 行按完整记录主键放入 Map
for e in 当前 Chunk 回填范围内的 binlog:
    if chunkKey(e) 不属于当前 Chunk:
        continue
    if e.op 为 CREATE 或 UPDATE:
        M[e.key] = READ(e.after)
    if e.op 为 DELETE:
        M.remove(e.key)
输出 M 的全部值

RecordUtils.upsertBinlog先判断范围归属,再合并。CREATE、UPDATE 使用 after 值生成新的 READ;DELETE 移除 Map 中对应记录。Snapshot Reader 使用 HashMap,不能据此承诺快照行按主键或提交时间排序。回填产生的 READ 与原始扫描 READ 会一起输出。

不变量一:每个键始终只有一个当前值

在 A3、A4 下,Map 用完整记录主键标识行;同一键重复扫描或回填只覆盖对应项,删除移除该项。因而 M 是键到值的部分函数,而非事件列表。这说明为何快照阶段能合并重复状态,但没有说明事件历史只出现一次,也没有扩展到无主键的 List 分支。

不变量二:已触及的键取日志值,未触及的键保留扫描值

把属于当前 Chunk 的窗口事件按顺序写成 e_1,…,e_m。令 M_j 为从 Q 开始处理前 j 条事件后的 Map,T_j 为这 j 条事件触及的键集合,last_j(k) 为其中 k 的最后一次状态(DELETE 对应 ⊥)。不变量为:

k ∈ T_j  ⇒ M_j(k) = last_j(k)
k ∉ T_j  ⇒ M_j(k) = Q(k)

归纳步骤:j=0 时 T 为空,M 就是 Q。处理下一条事件只影响它的键 k:CREATE/UPDATE 用完整 after 覆盖,DELETE 移除;这恰好建立新的 last(k)。其他键不变,因此不变量继续成立。它依赖的是“覆盖为完整状态”,不是对任意增量操作都成立的交换律。

终点推论:消费完全部 m 条事件后,触及过的键由不晚于 H 的最后一次变更决定,故值为 S_i(H)(k);未触及的键在 (L,H] 内未变,由 A2 可知 Q(k)=S_i(H)(k)。两类键覆盖 U_i,于是:

Normalize(Q_i, E_i(L_i,H_i]) = S_i(H_i)

不存在的键也包含在证明中:先插入后删除,终值为 ⊥;扫描后才插入的键,由 CREATE 加入 Map。证明不是只检查 SELECT 已经返回的行。

中间状态不能套用终点等式。例如 Q 已读到 30,窗口日志依次为 10→20、20→30;只处理第一条时 M 会暂时变成 20。不同键还可能分别保留扫描时刻的值或较早日志的值,所以 M_j 不必是源库某一共同时间点的状态。pollWithBuffer 等待 BINLOG_END 后才输出,正是把这种内部归一过程与最终快照结果隔开。该源码行为已核对;证明只在 A1—A6 成立时说明输出等式。

这个等式是状态结论。窗口中的 20→30 等历史已经折叠为 READ(30),需要完整审计事件的消费者不能把快照 READ 当成每一次业务操作。

边界比较不能简化成两个整数

代码里有两个看似不同、实际上承担不同职责的判断。回填任务在处理一次底层 binlog event 后,检查当前位置 isAtOrAfter(endingOffset),达到边界就发送 BINLOG_END。持续日志的逐 Chunk 过滤则使用 position.isAfter(chunkHigh),只保留尚未包含在该 Chunk 快照中的记录。有界读取停止条件、持续日志过滤

不能据此机械写成“行事件文件位置必须大于 HIGH.pos”。BinlogOffset还包含 GTID 集合、事务内 event 和 row 进度。在 Debezium 的恢复位置里,GTID 集合记录的是此前已完成的事务,事务中的行还需要 event/row 描述进度;事务提交后才推进已完成 GTID 集合。BinlogOffset.compareTo、Debezium 1.9.8 的 MySqlOffsetContext

本轮首个成功运行日志中,单 Chunk 的 HIGH 带有 GTID …:1-38, event=0, row=0;HIGH 之后那条 UPDATE 的恢复位置仍带 GTID …:1-38,但有 event=2, row=1,最终被正确保留。只比较 GTID 字符串是否相等,会把这个事件误判为已经包含。

正文中的 E(L,H]只用于表示提交历史上的逻辑窗口。实际边界由连接器的恢复位置语义实现;它不是对 binlog 字节地址的区间算术。以下最小值、最大值讨论也限定在同一条可比较的日志历史上,不扩展到相互分叉的 GTID 集合。

Chunk 的范围和主键承担不同职责

Chunk key 决定行交给哪个 Split;记录主键决定 Map 中哪些事件属于同一行。当前实现的 Chunk key 是单列,默认选主键的第一列,也允许配置其他列。有复合主键,并不意味着分片边界按完整复合主键划分。ChunkUtils.getChunkKeyColumn

源码使用左闭右开的范围 [start,end):start=null表示没有下界,end=null表示没有上界。一个中间 Chunk 的实际 SQL 条件写作 id >= start AND NOT(id = end) AND id <= end,对本文非空整数键等价于 start <= id < end。二分查找和回填范围判断也使用这一约定。StatementUtils、SplitKeyUtils

因此,对于 A=(-∞,105)、B=[105,+∞),键 105 只属于 B。本轮 Reader 实测同时检查了扫描和回填:A 输出 4 行且没有 105,B 输出 6 行并包含回填后的 105=55。原有的 8 个 SplitKeyUtils 单测另行验证了数值边界、字节数组、排序和二分查找行为。

Chunk size 是切分目标,不是扫描结果行数的硬上限。数值分布满足条件时,MySqlChunkSplitter按估算行数和数值跨度计算步长;否则查询下一段边界。实际扫描查询按键范围读取,不加 Chunk size 的 LIMIT。重复 Chunk key、倾斜和扫描期间新增数据,都可能改变范围内的行数。切分决策、数值分布估算

多 Chunk 为什么必须从最小 HIGH 追赶

设 A 先完成,边界为 H_A;B 后完成,边界为 H_B,且 H_A < H_B。A 的快照只包含截至 H_A 的状态。在两个 HIGH 之间,A 的行仍然可以更新。这些变化既不在 A 的快照里,也不会被属于 B 的回填接管。

如果直接从 H_B 开始读日志,这部分 A 的变化就没有补齐路径。MySqlHybridSplitAssigner.createBinlogSplit因此取所有已完成 Split 的最小 HIGH 作为起点,同时保存每个 Split 的表、范围和 HIGH。createBinlogSplit

从最小 HIGH 起读又带来重叠:B 的快照已经包含了其 HIGH 之前的变化,所以这部分日志必须过滤。进入“纯 binlog 阶段”之前,BinlogSplitReader.shouldEmit根据表和 Chunk key 找到所属 Split,再比较该 Split 的 HIGH。不能只用一个全局 HIGH 做统一判断。

start = min(H_1, H_2, ..., H_n)

对于来自已完成快照范围的数据记录 e:
    i = 根据表和 Chunk key 找到所属 Chunk
    emit(e) = offset(e).isAfter(H_i)

某个表的当前位置超过这个表的最大 HIGH 后,该表所有 Chunk 的重叠窗口都已过去。代码将它加入 pureBinlogPhaseTables,后续该表的数据记录可以跳过逐 Chunk 范围比较。这个优化按表生效,并非等待整个作业所有表共享一个最大值。hasEnterPureBinlogPhase 与 configureFilter

不变量三:每个 Chunk 有自己的推进边界

快照基线是 M₀=Union_i S_i(H_i)。这不是全表共同时间点的快照。令持续日志从 h_min=min_i H_i 读至逻辑切点 p,定义每个 Chunk 的有效边界 F_i(p)=max(H_i,p)。若仅把通过该 Chunk 过滤的事件应用到它的基线上,则保持:

p ≤ H_i:M_i(p) = S_i(H_i)                 // 仍保留该块自己的快照
p > H_i:M_i(p) = Apply(S_i(H_i), E_i(H_i,p]) = S_i(p)
因此:   M(p)   = Union_i S_i(F_i(p))

这是一个“各块边界不同”的状态不变量。初始 p=h_min 时各块保留自己的快照;日志继续推进时,p≤H_i 的事件被过滤,p>H_i 的事件按顺序更新所属块。A3 保证事件有唯一归属,A5、A6 保证没有缺口、错配或把旧快照覆盖到新增量之后。不同块的更新作用于不相交的键集合,因此可以逐块归纳;这不赋予跨块业务事务原子可见性。

对任意完整提交边界 q≥max_i H_i,所有 F_i(q) 都等于 q,才得到:

Union_i Apply(S_i(H_i), E_i(H_i,q]) = S_table(q)

这里说的是从快照和日志重建出的逻辑状态,不是 Reader 内部另有一张全表,也不是外部 Sink 在任意时刻的查询保证。q 之前可以是一组混合边界状态;即使一个事务同时更新两个 Chunk,消费者也不能由这个终点等式推导出它们始终同时可见。

从最小 HIGH 开始,是不漏掉任何块所缺后缀的通用起点;从更早处开始并正确过滤也可成立,只会多读。若从更晚处开始,则可能丢掉最早完成块尚未包含的变化。因此“必须取最小 HIGH”指这条实现使用的最晚通用安全起点,不是声称任何更早起点都不正确。

需要区分手工构造的有界日志读取与 snapshot启动模式。本文的有界 Reader 实验确实验证了从最小 HIGH 补齐至最大 HIGH,但在这个固定提交中,MySqlSource.createEnumerator为 snapshot-only 选择 MySqlSnapshotSplitAssigner,不会走初始模式使用的 Hybrid Assigner。Hybrid 内部虽有把终点设为最大 HIGH 的条件分支,也不能据此断言 snapshot-only 作业会执行跨 Chunk 的日志补齐。初始模式的最小 HIGH 追赶结论与上述 Reader 实验不受影响。MySqlSource 的入口选择

哪些反例会破坏结论

下面区分三种证据:模型反例展示去掉假设后等式为何不成立;源码条件推演把构造时序代入已核对的分支;既有实测才表示实际执行过。模型反例本身不能写成运行结果;跨 Chunk 迁移这一项已有下文的新增实测,其他未运行的反例仍保持推演身份。

陈旧视图:回填窗口根本不包含需要修正的更新

模型反例,破坏 A2:在 L 之前,k 已从 10 提交为 20;Q 却来自更早的读视图,返回 10,且 (L,H] 没有 k 的变化。T 始终为空,最终 M(k)=10,而 S(H)(k)=20。完整回填也无济于事,因为应修正的事件在窗口之外。复用旧事务或从延迟副本读取,都需要额外核对这一前提;本文没有实际运行这两种故障配置。

最后一次覆盖不可靠:日志顺序与完整行缺一不可

模型反例,破坏 A5:真实顺序为 10→20→30,却回放为 20→30 后再 10→20,最终留下 20;漏掉最后一条也可留下 20。反过来,若把 H 之后的 30→40 错并入快照,则输出超前到 40。这分别说明有序、完整和上界准确的作用,不意味着正常源码会这么做。

模型反例,破坏 A4:Q 的一行为 (amount=10,note='old');窗口先改 amount=20,再改 note=’new’。若事件只带变化字段,直接把最后一条 {note:'new'} 当完整 after 覆盖,就不能重建 (20,'new')。这针对“完整覆盖”模型;不声称本文已实测某种 MySQL row-image 配置的失败方式。DDL 改变字段布局同样要求重新建立解码模型,第四篇的运行期 Schema 研究不能自动补足此处的固定 Schema 假设。

Chunk key 迁移:两个快照都没读到,持续日志又被过滤

源码推演,破坏 A3;后文 Reader 实验与另行执行的真实 Paimon 作业均已复现该路径:固定主键 k,另用可变列 c 分片,A 的范围为 c<105,B 为 c≥105。构造如下顺序:

A 已在 H_A 完成:k 的 c=110,不在 A 快照中
H_A < e < L_B:   UPDATE k SET c=100,主键不变
B 随后扫描:      k 已离开 B,也不在 B 快照中
H_B 完成:        两块快照都没有 k
从 H_A 追赶:     e 的 before.c=110,被归到 B;e≤H_B,过滤

这里 B 的 LOW 也晚于 e,所以 B 的回填不会补它。把该 UPDATE 代入 getStructContainsChunkKey 的 before 取值与 shouldEmit 的逐块 HIGH 比较,得到上述遗漏路径。下文实验固定了分片,实测 UPDATE 的 before/after 与水位关系,并确认 Reader 输出缺少该键。这使该特定路径从源码推演变成有运行证据的结论,但没有验证自动切分、并发调度或故障恢复。UPDATE 的 Chunk key 取值、逐块过滤分支

起点与 HIGH 错配:共同起点不能替代逐块边界

模型反例,破坏 A5/A6:H_A=10、H_B=20,A 中一行在位置 15 更新。若改从 20 起读,该变化既不在 A 快照里,也不会再被读到。若仍从 10 起读、却统一用 20 过滤,结果相同。相反,统一用 10 过滤会放出 B 已吸收的窗口变化,破坏“没有快照/日志重叠”的划分;最终主键值是否仍正确,需要另看消费方式。

已有真实案例是不同 HIGH 的混合快照:后文测试中 A 的 103=20 与 B 的 106=50 从未同时存在。它反驳“所有快照 READ 一输出就是一个全局时间点快照”,并不反驳按各块 HIGH 追赶后的终点等式。skip-backfill 的重复 UPDATE 也已有 Reader 实测,但它主动撤去了 A6 的正常回填条件。

安全性成立,也不等于一定能完成

上述等式证明的是安全性:满足前提且完成指定窗口后,输出应是什么。活性还需要有限的 Chunk 扫描和窗口、可用日志、查询与队列持续推进,以及足够资源。若 (L,H] 在回填前已被清理,完整日志前提失效;如果扫描或消费永久阻塞,就不能保证产生最终输出。这里不推断实现一定静默丢数据,也可能报错或持续等待,实际处理方式需另测。

即使能完成每个固定的有限窗口,也没有证明能在给定时限内追上不断前移的日志头。本文没有做吞吐、内存或延迟基准,更没有由安全性推导性能保证。

用真实 MySQL 验证窗口和衔接

实验使用独立 MySQL 8.4.11 容器,InnoDB、ROW binlog、FULL row image、GTID 开启;Java 为 Temurin 11.0.29,Flink 依赖为源码默认的 1.20.3。容器只映射本机回环地址。测试直接实例化未修改的 SnapshotSplitReader 和 BinlogSplitReader,以独立 JDBC 写连接和已有 SnapshotPhaseHooks 安排提交时序。

原实验新增的 ArticleIncrementalSnapshotITCase有 5 个真实数据库用例,均通过;上游 SplitKeyUtilsTest的 8 个单测也全部通过,共 13 个,失败、错误、跳过均为 0。下面的 r/u 是 Debezium READ/UPDATE 操作;列出的内容是 Reader 输出记录,不是 Sink 表结果。

单 Chunk 的窗口合并

初始有 101 至 110 共 10 行,amount 都为 10。LOW 之后、SELECT 之前插入 111=11;SELECT 结束、HIGH 之前,把 101 连续改为 20、30,删除 102,插入 112=12 和 113=13,再删除 113;HIGH 之后把 101 改为 40。

快照输出:11 条 READ
  包含 r:101=30、r:111=11、r:112=12
  不包含 102、113

从快照 HIGH 继续读取:
  u:101=40

单 Chunk 实际输出同时检查了三件事:SELECT 已看见的 111 没有因回填变成两行;SELECT 未看见的更新、插入和删除被合并;HIGH 之后的 40 没有越过边界进入快照。该用例使用一个不限上下界的 Chunk,不能单凭它推断多范围过滤正确。

两个 Chunk 的不同 HIGH

这次明确构造 A 的范围为 (-∞,105),B 为 [105,+∞),顺序执行扫描以固定时序。A 的 SELECT 后、H_A 前把 103 改成 20;H_A 后把 103 改成 30、106 改成 40。随后扫描 B,在 B 的 SELECT 后、H_B 前,把 106 改成 50、边界键 105 改成 55。

观察点实际 Reader 输出说明
A 的归一快照4 条 READ,含 103=20A 只归一到 H_A
B 的归一快照6 条 READ,含 106=50、105=55B 已吸收自身窗口变化
从 H_A 读至 H_B只有 u:103=30补 A 的缺口,过滤 B 已包含的变化
再写 103=60、106=70、110=99 后,从 H_A 重读至新终点依次为 u:103=30、u:103=60、u:106=70、u:110=99同时覆盖重叠窗口及其后的持续日志

双 Chunk 实际输出中的最后一组使用一个新的 Reader 从同一起点重读,目的是独立断言完整输出,不是一次故障恢复实验。两个 Chunk 的扫描也没有并发执行。这里验证的是实际范围、回填和多 HIGH 过滤;并行调度、网络重排和 Checkpoint 衔接仍属于下一篇的验证范围。

A 快照里的 103=20 与 B 快照里的 106=50,在本次源库时序中没有同时存在过。源库在 106=50 出现之前就已把 103 改成了 30。这为“Chunk 快照的并集不一定是全局时间点快照”提供了一个实际案例;有界日志输出的 103=30 正是所需补齐。

跨 Chunk 修改键:迁移组漏行,稳定键对照完整

新增 ArticleChunkKeyMovementITCase 直接运行同一固定版本的 SnapshotSplitReader 和 BinlogSplitReader。源库是新建的独立 MySQL 8.4.11 容器,仍为 InnoDB、ROW/FULL、GTID ON;主键 id 不变,显式将普通列 c 设为 Chunk key,手工固定 A=(-∞,105)、B=[105,+∞)。每个用例重建自己的三行测试表:

id    c     amount
101   100   10
102   110   20
103   120   30

时序由同步调用固定,不靠 sleep 猜测:完整取完 A 的快照与 HIGH 后,提交三条独立 UPDATE;随后才启动 B 的扫描,并完整取回 LOW/HIGH。迁移组的三条更新是:

UPDATE items SET c=100 WHERE id=102;
UPDATE items SET amount=11 WHERE id=101;
UPDATE items SET amount=31 WHERE id=103;

对照组只把第一条换成 UPDATE items SET amount=21 WHERE id=102,保留 c=110。id=101 的更新用于确认 A 缺少的增量确实能放出;id=103 的更新由 B 快照吸收,同时使迁移事件严格早于 B 的 LOW。

每组从 H_A 到 H_B 分别启动两个真实 BinlogSplitReader:一份不传已完成快照 metadata,用于读取该段原始变更;另一份传入 A/B 的真实范围和 HIGH,验证生产过滤路径。未替换 shouldEmit,也未手工伪造 UPDATE。原始事件实际为主键 id=102 的 UPDATE,迁移组 before.c=110、after.c=100。用完整 BinlogOffset 比较并断言:

H_A < E_102 < L_B == H_B

实际结果如下;单元格中的三元组依次为 (id,c,amount):

观察点修改 Chunk key 的迁移组Chunk key 不变的对照组
A 快照READ(101,100,10)READ(101,100,10)
B 快照READ(103,120,31)READ(102,110,21)、READ(103,120,31)
不传快照 metadata 的日志UPDATE(102,100,20)、UPDATE(101,100,11)、UPDATE(103,120,31)UPDATE(102,110,21)、UPDATE(101,100,11)、UPDATE(103,120,31)
带 A/B HIGH 的追赶日志仅 UPDATE(101,100,11)仅 UPDATE(101,100,11)
MySQL 当前完整结果3 行,含 (102,100,20)3 行,含 (102,110,21)
快照加追赶日志重建结果2 行,缺少 id=102;其余字段一致3 行,所有字段与源库一致

迁移组并非没有捕获 UPDATE:无快照过滤的 Reader 已实际输出它;带 metadata 的 Reader 则没有输出。结合 UPDATE 按 before.c=110 归到 B、位置不晚于 H_B 的源码分支,解释了这次过滤。A 的快照早于迁入,B 的快照晚于迁出,两边也都没有 id=102。因此,在所测边界 H_B,Reader 输出无法重建源表完整状态。

测试结果的含义:两个用例的断言均通过,0 失败、0 错误、0 跳过;其中迁移用例刻意断言 MISSING_KEYS=[102] 与 DATA_EQUAL=false,属于行为刻画测试。“通过”表示遗漏现象被复现,绝不表示该组同步正确或连接器已修复。整理探针格式后再次执行得到同样结果,仍只计 2 个独立用例;原有 5 个 Reader 用例和 8 个工具单测未在这次补充中重跑。

这组 Reader 实验的边界:最后的键值表由测试程序按完整主键应用 Reader 输出重建,并与源库 SELECT id,c,amount 全量比较;没有运行真实 Sink。这次只覆盖固定主键、可变非主键 Chunk key、B→A 单向迁移、顺序扫描、所列三条独立提交和指定 GTID 配置。没有运行 Assigner 自动切分、并发 Chunk、修改主键、其他迁移方向、Checkpoint 恢复或 Sink 提交,也没有验证后续更新会怎样影响缺失行。本次未修改连接器生产代码。

证据:迁移组完整输出、稳定键对照输出、探针源码、运行摘要与复现条件。

从 Reader 到完整作业:Paimon 提交后仍缺少该行

新增端到端实验使用相同 CDC 提交、Flink 1.20.3、MySQL 8.4.11,以及第五篇的 Paimon 2.0.0。这里运行完整的单 JVM MiniCluster 作业:生产 MySQL Source、Splitter/Assigner、Reader、SchemaOperator、分区、Paimon writer 和 precommit,最终落到真实 filesystem Catalog 与数据文件。没有把上一组 Map 重建当作 Sink,也没有改写连接器过滤或提交算法。

为让生产切分器自行生成同一边界,初始数据改为 20 行,c 连续取 95..114,chunk.size=10,先执行 ANALYZE TABLE;关键行仍为 (101,100,10)、(102,110,20),另一进度标记行为 (103,114,30)。日志确认估算行数 20、分布因子 1、步长 10;探针必须断言实际仅有 A=(-∞,105) 与 B=[105,+∞),否则实验失败。因此,这里覆盖了真实自动切分与分配,但只覆盖本组确定输入及 p=1 顺序扫描。

时序通过现有 MySqlSource.setSnapshotHooks 固定:A 的 post-HIGH 观察位置;B 的 pre-LOW 在开始扫描前同步提交与 Reader 组相同的三条更新。迁移组修改 id=102 的 c 为 100,对照组只修改其 amount 为 21。生产任务记录的实际 LOW/HIGH 与钩子查询逐项一致;事后以独立复制 server-id 读取原始 binlog,取得真实 UPDATE 的完整位点,断言仍为 H_A < E_102 < L_B == H_B。这份审计日志不会送入作业或 Sink。生产水位核对、实际探针。

快照输出后完成 C1;确认 A 的追赶更新 id=101 已输出,再提交 id=103 amount=32 并等待 Source 实际收到,证明持续日志已推进到 B HIGH 之后。此后停止源端写入,完成 C2、独立读取 Paimon;再完成 C3、取消作业、退出实验 JVM,分别重复全量读取。最后另起 JVM,只读持久化文件,与已冻结并完整保存的 MySQL 查询结果比较。

全量对账时点修改 c 的迁移组c 不变的对照组
C2 完成,独立读取 snapshot-2MySQL 20 行,Paimon 19 行,缺少 id=102两端 20 行 × 3 列一致
再完成 C3,读取 snapshot-3仍缺少 id=102全量一致
取消作业后重新打开 Catalog仍缺少 id=102全量一致
新 JVM 回读 snapshot-3仍缺少 id=102;其余 19 行逐字段一致20 行 × 3 列全部一致

对账逐项比较字段名与顺序、全部主键及 id,c,amount 的值,并检查重复主键、多余行、缺失行和公共主键的字段差异。两组均无多余行和重复主键;迁移组唯一差异是缺少完整的 (102,100,20)。数据来自 SQL 查询和真实 Paimon 全表扫描,不是只比 count。完整数据与四阶段结果见迁移组对账、稳定键对照、证据索引。

实际结果与解释分开:Source 事件记录中,迁移组既没有 id=102 的快照 INSERT,也没有对应 UPDATE;对照组有 B 快照的 INSERT(102,110,21)。两组完整作业各完成三个 Checkpoint,迁移组在真实目标中仍缺行。这把已有 Reader 现象推进到了所测完整链路;结合前面的 before-key/HIGH 过滤分支,可解释该行为何没有送达 Sink。额外正常提交没有补回缺行,不能据此声称故障恢复或后续再更新 id=102 也一定不能补回。

验证边界:两个 main 探针场景的断言均成功,其中迁移组刻意断言 data_equal=false;这不是两个“同步正确”的作业。p=1、bucket=1、主键 id 不变、非主键 INT 列 c 迁移、固定 Schema、默认回填、无故障、filesystem Paimon 是本次条件。未测并发 Chunk、反向迁移、主键变更、Checkpoint 失败恢复(后续独立实验见下一节)、DDL、物理进程故障、其他存储后端或缺失行的后续更新;本组也未独立扫描 input changelog。源码未修复,Reader 组与这两组完整作业分别统计。

从迁移前、迁移后的 Checkpoint 恢复,是否补回缺行

新增恢复实验沿用上述版本、20 行、真实两 Chunk 和 Paimon 链路,另行执行四个独立场景:迁移/稳定键对照 × 迁移前/后恢复点。没有把 Reader 重建或前一组无故障提交当作恢复证据。这里“迁移前”有精确含义:A 已输出 10 行,B 已分配但暂停在 pre-LOW,源端尚未提交迁移。此时完成 C1;实际 Reader 状态只有 B=[105,+∞), highWatermark=null。C1 并不是 A 尚未完成的恢复点。

释放 B 后,仍按 H_A < E_102 < L_B == H_B 固定迁移;等 Source 输出 A 的追赶更新和 B HIGH 之后的 id103 amount=32,再选择是否完成 C2。迁移前组保留 C1 为最近成功点;迁移后组完成 C2,其 Reader 状态为已经前进的 binlog Split。随后两组在同一语义窗口注入故障:下一 Checkpoint 的 Paimon snapshot 已可独立读取,commitIdentifier 等于该次编号,而全局 Future 仍未完成。释放下游闸门后抛错,由 Flink 自动恢复 task attempt;不是手工拼装 Reader。

恢复点与故障点实际恢复路径迁移组最终 Paimon稳定键对照
C1 在迁移前完成,C2 发布后失败恢复 C1;只重扫 B,A 不重扫源 20 行、目标 19 行,仍缺 (102,100,20)20 行 × 3 列一致
C2 在迁移后完成,C3 发布后失败恢复 C2 的 binlog Split,不再执行快照扫描同样缺少 id102,其余 19 行逐字段一致20 行 × 3 列一致

恢复编号来自 Flink 初始化上下文;Reader 交还的首个 Split 与所选 Checkpoint 记录的范围/位点文本一致。恢复后用 id103 amount=33 确认日志继续推进;迁移前组先等重扫 B 的 HIGH 固定才提交这个标记。之后停止写入,在恢复后成功 Checkpoint、额外一次成功 Checkpoint、取消作业后及新 JVM 中分别全量读取。四阶段均得到上表结果;没有多余行、重复主键或公共主键的字段差异。四组结果与完整证据。

源码解释:C1 虽早于迁移,但 A 的完成事实已被覆盖,恢复不意味着整表重扫。重扫 B 时 id102 已迁出;重新追赶日志仍按 before.c=110 和 B 的 HIGH 过滤。C2 则已经推进到遗漏形成之后,恢复它也不会主动回查源库缺行。实验确认的是这两个具体恢复点都未修复遗漏,不是“任意更早 Checkpoint 都无效”。A 尚未完成的 Checkpoint、重新全量扫描、后续再更新 id102、不同迁移方向均未验证。

故障是单 JVM MiniCluster 中的 task 异常,p=1、bucket=1、filesystem、固定 Schema、默认回填;未测物理 JM/TM kill、HA、并发 Chunk 或本组 input changelog。四组行为断言成功,其中两组迁移结果明确为 data_equal=false。恢复协议按所选状态继续工作,与全量数据完整性是两个判断。状态分析见第三篇下,外部发布边界见第五篇附录 H。

长事务与没有变化的窗口

长事务用例在 LOW 之前,通过独立连接关闭自动提交并把 101 改成 77;在 SELECT 结束之后、HIGH 之前才提交。快照最终输出 r:101=77,HIGH 大于 LOW。它验证了这一事务提交时序能被回填覆盖,没有测量锁等待、死锁或长事务资源消耗。

另一用例在扫描窗口内不产生写入,实测 LOW 等于 HIGH,Reader 正常结束并输出原有 10 条 READ。对应源码会跳过不必要的 binlog 回填,直接发送 BINLOG_END。LOW=HIGH 在这个用例中确实意味着没有需要回填的变化。

关闭回填后为什么同一个更新会再来一次

scan.incremental.snapshot.backfill.skip=true会同时改变两处行为:快照任务强制令 HIGH=LOW,Snapshot Reader 走 pollWithoutBuffer直接输出扫描批次;持续日志阶段不再按正常回填完成的规则过滤重叠变化。HIGH 的设置、无缓冲输出

实测时,101 初始为 10;LOW 后、SELECT 前改为 20;SELECT 后改为 30。结果如下。

快照:r:101=20
记录的边界:HIGH == LOW
后续日志:u:101=20、u:101=30

快照已经包含的 20 又作为 UPDATE 出现了一次。这个结果支持源码注释所说的 at-least-once 退化,但不能只凭这一条时序证明所有配置下的最终 Sink 语义。若窗口里有更早的中间版本,重叠回放还可能产生暂时回退;本轮没有专门复现该回退时序。

还应区分两种 LOW=HIGH:正常回填路径中,它可以表示窗口没有日志进展;skip 模式中,它是人为保存的重读起点,不说明源库在扫描期间没有变化。

正确性依赖的条件也是成本和风险的来源

回填把不确定性集中在每个 Chunk 的内存缓冲中。正常路径要等到 BINLOG_END 才返回归一结果,扫描本身完成不代表首批快照数据已经可输出。多个活跃 Chunk 会各自占用缓冲;fetch size 控制 JDBC 拉取批量,不能替代整块 Map 的内存预算。

回填任务只限定当前表,行属于哪个 Chunk 则在 RecordUtils 中判断。同一表的多个 Chunk 可能各自读取重叠的日志区间。因此,Chunk 越小不必然意味着总工作越少:扫描更短,但查询、连接和回填次数可能增加。Chunk 较大又会扩大缓冲和等待时间。这些是由调用路径推导出的成本关系,本轮没有做吞吐、内存或延迟基准。

稳定 Chunk key 是前面逐键证明的重要前提。源码对 UPDATE 使用 before 中的 Chunk key 判断归属;对于无主键表,还存在 after 已经移出范围的告警和 at-least-once 注释。不能把稳定主键例子的证明直接推广到跨 Chunk 迁移。无主键、Chunk key 更新、字符串排序规则与数据库比较是否一致,都需要各自验证。RecordUtils 的范围与更新分支

并行快照结束后,分配器还要处理输出顺序。MySqlSnapshotSplitAssigner在并行度大于 1 时,收到全部完成信息后等待一次满足条件的 completed checkpoint;并行度为 1 时走立即完成分支。Hybrid Assigner 只有在初始快照分配阶段完成后才创建持续 Binlog Split。并行度大于 1 的 Checkpoint 门槛仍是本篇的源码结论;新增完整作业只覆盖 p=1 的真实切换,不能替代并行协议验证。完成条件、Binlog Split 分配门槛

验证结论及下一篇的边界

形式化推导本身不增加实验计数。原有覆盖为 5 个真实 Reader 用例与 8 个工具单测;随后执行迁移/对照 2 个 Reader 用例,另新增 2 个完整 MySQL → Paimon 作业场景。两层迁移组均确认缺行;本次进一步新增迁移/对照 × 两个恢复点的 4 个真实 Paimon 故障场景,没有重跑前述历史用例。重跑和新 JVM 回读不增加独立场景数,断言成功也不等于数据一致。

待核实问题已确认的结论证据层级
归一等式需要哪些前提A1—A6 下,由“已触及/未触及键”不变量推出 M_i=S_i(H_i)人工条件化证明;不是新增实验或机器验证
多 Chunk 何时对应共同切点M(p)=Union_i S_i(max(H_i,p));完整边界 q 不早于全部 HIGH 时得到全表状态条件化推导;顺序双 Chunk Reader 用例只覆盖其具体时序
去掉前提后会怎样陈旧视图、缺失/乱序日志、部分 after 和 HIGH 错配可破坏证明模型反例;这些故障配置仍未运行
跨 Chunk 修改键会否漏行所测 B→A 迁移在两份快照中均缺失,UPDATE 被逐块 HIGH 过滤;H_B 处 Reader 重建缺少 id=102,完整作业完成 C2/C3 后真实 Paimon 仍缺该行源码、独立 Reader 实验,以及另行执行的 p=1 MySQL → Paimon 作业;两层稳定键对照均完整
迁移后故障恢复能否补回从 A 已完成、B 未 LOW 的 C1 恢复只重扫 B;从迁移后 C2 恢复 binlog;两组真实目标仍缺 id102另行执行 4 个完整故障作业(含两组稳定键对照);限定恢复点,不证明任意旧 Checkpoint 的结果
LOW/HIGH 是否写入源库水位表这条 MySQL 路径查询位置并产生本地队列信号源码
HIGH 是否表示回填完成HIGH 先采集,BINLOG_END 后才完成归一输出源码及单 Chunk 实测
多次更新和删除如何处理同键覆盖为最终 READ,删除后不输出该行源码及真实 Reader 实测
没有变化的窗口是否能结束LOW=HIGH 时直接结束回填源码及真实 Reader 实测
多 Chunk 从哪里追赶从最小 HIGH 起读,再按各自 HIGH 过滤分配器源码、手工 metadata 的 Reader 实测;新增完整作业覆盖 p=1 自动切分与分配
恰在分片边界的键属于哪里左闭右开,105 属于以 105 开始的 ChunkSQL 与过滤源码、单测及 Reader 实测
等于 HIGH 的恢复位置是否输出正常过滤使用严格 isAfter,完全相等不输出源码;未另造等值记录运行该私有谓词
skip backfill 是否仍消除窗口重叠不再保证,实测出现已扫描更新的重放源码及真实 Reader 实测
并行完成后何时切换分配器按并行度和 completed checkpoint 控制p>1 门槛仍为源码结论;新增 p=1 作业不覆盖该并行条件

实验证据保留了测试源码、通过数汇总、Maven 完整日志、Surefire XML、MySQL 配置与镜像标识、DBLog 原论文及源码 SHA-256 清单。原实验通过数为 5 个真实数据库用例和 8 个工具单测;新增迁移实验的独立结果摘要另行保存,不能把其行为刻画断言通过解释成数据一致。

跨 Chunk 修改键已分别覆盖特定 B→A Reader 时序和单并行度真实 Paimon 提交后的缺行。该迁移场景又覆盖了上述两个恢复点的 task 故障。其他迁移方向、主键更新、A 未完成的恢复点、并发 Chunk 调度、日志清理或主从切换、快照期间 DDL 与 Sink 故障组合仍未覆盖。文章的状态证明限定在明确的输入条件内;Reader 集成通过不等于端到端 exactly-once 通过。下一篇继续沿 Split 分配、完成报告、Checkpoint 状态和恢复位置,检查这些边界怎样在失败后保持成立。

精读源码与论文的顺序

先读 DBLog 第 3 节,明确窗口、冲突和输出顺序;再依次读 MySqlSnapshotSplitReadTask、SnapshotSplitReader、RecordUtils,核对一次 Chunk 的完整生命周期。

随后把 StatementUtils和 SplitKeyUtils并排读,检查 SQL 范围与日志归属是否一致;最后连接 MySqlHybridSplitAssigner与 BinlogSplitReader,追踪同一份 FinishedSnapshotSplitInfo 如何从分配器进入逐记录过滤。本文所有 Flink CDC 链接均固定到同一提交。

公开测试源码、结果和复现条件见系列验证材料。


系列导读与验证材料 · 一:一致性边界 · 二:增量快照 · 三·上:Source 状态机 · 三·下:故障恢复 · 四:Schema 演进 · 五:Paimon 提交

文档信息

Search

    Table of Contents