MySQL CDC 的一致性边界:快照、位点与下游幂等
沿 MySQL、Debezium、Kafka 到下游状态表逐段检查重复、乱序、删除与重放,避免把连接器保证误当作业务保证。
Read in English →工程学习与设计笔记。示例使用合成场景;适用版本与参考资料见正文。
适用范围:MySQL 8.0、Debezium 2.7 与 Kafka 3.9 的概念和文档语义。本文讨论通用设计,不是生产配置清单。
把数据库变更送进消息队列,通常很快就能跑通。更难的问题出现在故障恢复之后:同一条更新来了两遍怎么办?快照期间发生的修改会不会漏掉?一条迟到的旧更新,会不会把已经删除的对象重新创建出来?
先分清三种“位置”
源端 binlog 位点、连接器保存的 offset、下游消费的 Kafka offset,各自回答不同问题。它们不能互相替代,也不能单凭某一个位点增长就证明业务结果正确。
Debezium 的初始快照负责建立基线,随后衔接变更日志。异常恢复可能产生重复事件;如果恢复所需的 binlog 已被清理,连接器无法凭空补回那段历史。快照与故障行为应按具体模式核实,参见 Debezium 2.7 MySQL Connector 。
从运维角度,需要同时记录源日志保留窗口、最大可容忍停机时间、连接器延迟、下游积压,以及重建基线需要多久。只把 binlog 保存七天而没有恢复演练,“七天”仍只是配置,不是恢复能力。
幂等键应该对应业务对象还是变更事件
假设同步的是订单状态表,一条标准化事件可以包含:
{
"entity_key": "order:1001",
"event_id": "source-a:tx-17:row-2",
"version": 12,
"operation": "upsert",
"value": {"status": "PAID"}
}
这是示意格式。event_id 必须由真实、稳定的事件标识规则生成;version 是上游业务显式维护的单调版本,并非 Debezium 默认提供的万能顺序号。数据库主从切换、快照重建和多源合并时,不能直接拿一个文件名或时间戳冒充跨来源全序。
对“保存最新状态”的下游,处理规则可以定义为:新版本覆盖旧版本,相同版本与相同内容视为重放,相同版本但内容不同进入冲突队列,低版本忽略并计数。比较与更新必须在同一个原子操作中完成,否则两个并发消费者仍可能先后覆盖错误。
对“每次变更加一”的聚合,下游按主键覆盖就不够了。需要记录已处理的事件 ID,并把去重记录与累加结果放在同一事务内,或者使用能一起提交状态与消费进度的计算系统。去重记录的保留时间必须覆盖可重放窗口;过期后再次重放,重复累加会重新出现。
删除和乱序最容易被漏掉
物理删除目标行会同时丢掉版本。如果旧的更新随后抵达,就可能重新插入这行。因此可考虑保留包含版本的逻辑删除标记,待超过重放边界再清理。这里的业务删除标记,与 Kafka compacted topic 中 value 为 null 的 tombstone 不是一个概念,不能混用。
还要覆盖主键变更、快照读取事件、schema 变更和解析失败。字段解析不了时直接跳过,延迟看起来可能很健康,但下游已经缺行。隔离失败事件时应保留脱敏后的上下文、错误类型和源位置,以便修复后重放。
为什么开启 Kafka 事务还不够
Kafka 的事务可以协调 Kafka 内部的输出与消费位点;写到外部数据库、搜索引擎或 HTTP 服务,还需要目标系统参与一致性设计。保证的边界见 Kafka 3.9 消息语义 。
例如下游数据库已经提交,消费者在提交 Kafka offset 前崩溃,恢复后就会再次读到相同事件。把下游写入改成带版本条件的幂等操作,可以让这次重复没有额外业务效果。反过来,先提交 offset 再写数据库,会留下“位点已前进,数据未落地”的缺口。
用故障矩阵验收链路
我更愿意用以下实验验收,而不是只比一次总行数:
| 注入点 | 要验证的结果 |
|---|---|
| 快照期间持续更新同一主键 | 最终状态与源端一致,衔接无缺口 |
| 目标提交成功后立即终止消费者 | 重放不重复计数、不重复副作用 |
| 删除之后重放旧更新 | 删除状态不被复活 |
| 同主键事件乱序 | 版本较低的事件不能覆盖较高版本 |
| schema 新增或字段类型变化 | 有兼容策略或可恢复的隔离流程 |
最后按主键区间核对数量、关键字段与删除状态,并保留少量逐条 diff 样本。总数相等无法发现“一条漏掉、另一条重复”这种抵消错误。CDC 的可用性最终取决于:故障之后,能否解释、恢复并核对每一次业务状态变化。