3. 实时同步方案设计:全量+增量同步策略、Exactly-Once语义保证、数据一致性校验
这一章,我们聊聊实时同步方案的核心设计。说白了,就是怎么把数据从源端完整、不丢、不重地搬到 Lakehouse 里。
很多刚接触 CDC 的同学,上来就问:“我用 Flink CDC 直接读 Binlog 不就行了?” 嗯,理论上可以。但实际生产环境里,一张表几千万甚至上亿行,你让 Flink 从头读 Binlog?那得等到猴年马月。所以,全量+增量 才是标准打法。
3.1 全量+增量同步策略
这个策略其实不复杂。我画个图你就明白了。
流程看起来简单,但坑都在细节里。我一个个说。
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 的利器。
具体做法:
- 每条 CDC 记录都带上主键和版本号(或时间戳)。
- 写入时,使用
MERGE INTO或INSERT OVERWRITE语义。 - 如果主键相同,用版本号判断是否覆盖。
-- 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 解决的是正确性问题,一致性校验解决的是信任问题。三者缺一不可。