3. 实时同步方案设计:全量+增量同步策略、Exactly-Once语义保证、数据一致性校验

这一章,我们聊聊实时同步方案的核心设计。说白了,就是怎么把数据从源端完整、不丢、不重地搬到 Lakehouse 里。

很多刚接触 CDC 的同学,上来就问:“我用 Flink CDC 直接读 Binlog 不就行了?” 嗯,理论上可以。但实际生产环境里,一张表几千万甚至上亿行,你让 Flink 从头读 Binlog?那得等到猴年马月。所以,全量+增量 才是标准打法。

3.1 全量+增量同步策略

这个策略其实不复杂。我画个图你就明白了。

全量+增量同步流程 源数据库 全量快照读取 增量 Binlog 消费 Lakehouse 目标表 全量 → 增量 切换点 关键说明: 1. 全量阶段:使用 SELECT * 或分片查询,将历史数据一次性拉取到 Lakehouse。 2. 增量阶段:从 Binlog 或 WAL 中消费实时变更事件,持续写入。 3. 切换点:全量完成后,记录当前 Binlog 位点,无缝切换到增量模式。

流程看起来简单,但坑都在细节里。我一个个说。

3.1.1 全量阶段:分片读取与断点续传

全量读取,最忌讳的就是一次性 SELECT *。你想想看,一张 10 亿行的表,单线程读,内存直接爆掉。我建议用分片(Chunk)策略。

分片策略核心思路:

  • 按主键或唯一索引分片,比如 ID 1-10000 一个 Chunk。
  • 每个 Chunk 独立读取,写入临时文件或分区。
  • 记录每个 Chunk 的读取进度,支持断点续传。

我曾经在一个项目里,源表没有主键。嗯,这就很尴尬了。没有主键,分片就无从谈起。后来我们用了 ROW_NUMBER() 窗口函数,人为构造了一个虚拟主键。虽然性能差了点,但至少能跑了。

避坑指南: 全量阶段一定要加 SELECT ... WITH (NOLOCK) 或等价隔离级别,避免长时间锁表。我曾经见过一个案例,全量跑了 2 小时,源库的写入全部被阻塞,业务直接挂了。

3.1.2 增量阶段:Binlog 位点管理与回溯

增量阶段的核心,是位点(Offset)管理。Flink CDC 或者 Debezium 都会自动记录 Binlog 位点,但你要确保位点信息是持久化的。

我个人习惯把位点写入一个独立的 Kafka Topic 或者数据库表。这样即使任务重启,也能从上次中断的地方继续消费。

-- 位点表示例(存储在 MySQL 中)
CREATE TABLE cdc_offset (
    table_name VARCHAR(255) PRIMARY KEY,
    binlog_file VARCHAR(255),
    binlog_pos BIGINT,
    update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

这里有个细节:全量转增量时,位点怎么对齐? 我的做法是:在全量开始前,先记录一个快照位点。全量结束后,从那个位点开始消费增量。这样就不会漏掉全量期间产生的变更。

3.2 Exactly-Once 语义保证

Exactly-Once,说白了就是不丢不重。在分布式系统里,这其实很难。但 Lakehouse 给了我们一些便利。

为什么难?因为 CDC 链路里,每个环节都可能出问题:

  • 源端 Binlog 可能重复发送
  • Kafka 可能重复消费
  • Flink 任务可能故障重启
  • 写入 Lakehouse 时可能部分成功

要实现 Exactly-Once,我总结了三个关键点:

环节 策略 说明
读取 幂等消费 + 位点提交 Flink 的 Checkpoint 机制,保证每条记录只处理一次
处理 事务性写入 使用两阶段提交(2PC)或幂等写入
写入 Lakehouse 事务支持 利用 Iceberg/Delta Lake 的 ACID 特性

注意: 不要迷信 Flink 的 Exactly-Once 语义。它只能保证 Flink 内部的状态一致性。如果下游写入失败,或者 Lakehouse 不支持事务,数据还是可能重复。我建议在写入层再加一层去重逻辑。

3.2.1 基于 Lakehouse 的幂等写入

Lakehouse 格式(如 Iceberg、Delta Lake、Hudi)都支持主键 Upsert。这是实现 Exactly-Once 的利器。

具体做法:

  1. 每条 CDC 记录都带上主键和版本号(或时间戳)。
  2. 写入时,使用 MERGE INTO 或 INSERT OVERWRITE 语义。
  3. 如果主键相同,用版本号判断是否覆盖。
-- Delta Lake 示例:MERGE INTO 实现幂等写入
MERGE INTO lakehouse_table AS target
USING cdc_stream AS source
ON target.id = source.id
WHEN MATCHED AND source.version > target.version THEN
    UPDATE SET target.col1 = source.col1, target.version = source.version
WHEN NOT MATCHED THEN
    INSERT (id, col1, version) VALUES (source.id, source.col1, source.version);

这样即使重复写入,也不会产生重复数据。嗯,这个方案我用了好几年,稳得很。

3.3 数据一致性校验

同步做完了,怎么证明数据是对的?这就是一致性校验要解决的问题。

我把它分成两个层面:

  • 实时校验: 每条记录写入后,对比源端和目标端的 CRC 或 Hash。
  • 离线校验: 定期对全表做 COUNT、SUM 或抽样对比。

3.3.1 实时校验:行级 Hash 对比

在 CDC 链路中,我们可以给每条记录计算一个 Hash 值。写入 Lakehouse 后,再算一次 Hash。如果一致,说明这条记录没被篡改。

实现方式:

  • 源端:在 Debezium 或 Flink CDC 中,用 UDF 计算 MD5(所有字段)。
  • 目标端:写入后,用同样的算法计算 Hash,存入校验表。
  • 对比:定时任务扫描校验表,找出 Hash 不一致的记录。

我曾经遇到过一个坑:源端和目标端的字段顺序不一致,导致 Hash 对不上。后来我统一用字段名排序后再计算 Hash,问题就解决了。

3.3.2 离线校验:全表对账

实时校验只能覆盖增量数据。对于历史数据,我们需要定期做全表对账。

校验方法 适用场景 优缺点
COUNT 对比 快速发现数据丢失 简单,但无法发现数据错误
SUM/AVG 对比 数值型字段校验 快速,但精度有限
抽样对比 大表校验 效率高,但可能漏检
全量 Hash 对比 小表或核心表 最准确,但耗时

我个人建议:核心业务表用全量 Hash 对比,普通表用 COUNT + 抽样。 这样既保证了准确性,又控制了成本。

避坑指南: 离线校验时,一定要考虑源端和目标端的时间窗口。比如源端还在写入,目标端还没同步完,这时候对比肯定对不上。我一般会设置一个 5 分钟的延迟窗口,只对比 5 分钟之前的数据。

3.4 小结

这一章的内容,说白了就是三个词:分片、幂等、校验。全量+增量解决的是效率问题,Exactly-Once 解决的是正确性问题,一致性校验解决的是信任问题。三者缺一不可。