4. 快照与增量衔接:如何保证数据一致性?断点续传与去重机制
快照同步和增量同步,单独看都不难。但把它们串起来,才是真正的考验。我见过太多项目,快照跑得好好的,增量也跑得好好的,偏偏在切换的那一刻出了乱子——数据对不上,要么丢了几条,要么重复了一大片。
说白了,衔接的核心就两个问题:断点续传和去重。前者保证不丢,后者保证不重。今天我们就来拆解这两个机制。
4.1 衔接的痛点:为什么快照和增量会打架?
先看一个典型场景。你凌晨2点跑了一次全量快照,把源库的100万条记录全部拉到目标端。快照完成后,增量同步开始工作,监听并同步后续的变更。一切看起来都很完美——直到某天,增量同步因为网络抖动中断了2小时。
恢复后,问题来了:
- 中断期间源库发生了哪些变更?
- 从哪个位置开始续传?
- 如果快照和增量之间有重叠,会不会重复同步?
我早期做过一个电商数据同步项目,就栽在这个坑里。快照跑了3小时,增量启动时没处理好边界,结果订单表里出现了大量重复记录。排查了一整天才发现,是快照执行期间产生的变更,被增量又同步了一遍。
4.2 断点续传:从哪断的,就从哪接
断点续传不是什么新鲜概念,下载文件时大家都用过。但在数据同步场景里,它要解决的问题更复杂——不仅要记住位置,还要保证这个位置是精确的、可回溯的。
4.2.1 基于偏移量的断点
最直接的方式,就是记录一个偏移量。比如MySQL的binlog,每条日志都有一个唯一的position。你只需要在同步进程里定期把这个position存下来,下次启动时从它开始读就行。
-- 记录断点
INSERT INTO sync_checkpoint (task_name, binlog_file, binlog_pos, update_time)
VALUES ('order_sync', 'mysql-bin.000123', 456789, NOW())
ON DUPLICATE KEY UPDATE
binlog_file = VALUES(binlog_file),
binlog_pos = VALUES(binlog_pos),
update_time = NOW();
-- 恢复断点
SELECT binlog_file, binlog_pos
FROM sync_checkpoint
WHERE task_name = 'order_sync';
这里有个细节:什么时候存断点?我个人习惯是在消费完一批数据并成功写入目标端之后再存。如果先存断点再写入,写入失败时断点已经前移了,就会丢数据。反过来,先写入再存断点,即使写入成功但存断点失败,下次启动时最多重复消费一批数据,不会丢。
4.2.2 基于时间戳的断点
有些数据源不支持精确的偏移量,比如一些老旧的API或者日志文件。这时候可以用时间戳做断点。
做法很简单:每次同步时,记录下当前同步的最大时间戳。下次启动时,只拉取这个时间戳之后的数据。
// 伪代码
long lastSyncTime = getLastSyncTime("user_sync");
List<Record> records = api.query("updated_at > " + lastSyncTime);
for (Record record : records) {
targetDB.save(record);
if (record.getUpdatedAt() > lastSyncTime) {
lastSyncTime = record.getUpdatedAt();
}
}
saveSyncTime("user_sync", lastSyncTime);
但时间戳方案有个明显的缺陷:精度问题。如果两条记录的时间戳相同,或者系统时钟有偏差,就可能漏掉或重复。我曾经在跨机房同步时遇到过,源库和目标库的时钟差了3秒,导致每次同步都有几条数据对不上。
4.2.3 基于版本号的断点
这是我最推荐的方式。给每条数据加一个单调递增的版本号(比如全局自增ID、LSN等),同步时记录已处理的最大版本号。下次启动时,只拉取版本号大于这个值的数据。
版本号天然有序、唯一、单调递增,不会出现时间戳的精度问题,也不像偏移量那样依赖特定数据源的实现。很多现代数据库(如PostgreSQL的LSN、MongoDB的oplog ts)都原生支持这种机制。
| 断点类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 偏移量 | 精确、无歧义 | 依赖数据源实现 | MySQL binlog、Kafka |
| 时间戳 | 通用、实现简单 | 精度问题、时钟偏差 | API接口、日志文件 |
| 版本号 | 精确、通用、单调递增 | 需要数据源支持 | PostgreSQL LSN、MongoDB oplog |
4.3 去重机制:保证每条数据只处理一次
断点续传解决了"不丢"的问题,但"不重"还得靠去重。尤其是在快照和增量衔接的边界上,重复几乎是必然的。
4.3.1 幂等写入
去重最优雅的方式,就是让写入操作本身具备幂等性。不管同一条数据来多少次,最终结果都一样。
在关系型数据库里,这通常用INSERT ... ON DUPLICATE KEY UPDATE或者MERGE语句实现:
-- MySQL 幂等写入
INSERT INTO orders (order_id, user_id, amount, status, update_time)
VALUES ('ORD2024001', 1001, 299.00, 'PAID', NOW())
ON DUPLICATE KEY UPDATE
amount = VALUES(amount),
status = VALUES(status),
update_time = VALUES(update_time);
在HBase、Cassandra这类NoSQL里,天然就是幂等的——同样的row key写入多次,结果不变。Kafka虽然不保证严格去重,但配合enable.idempotence=true也能做到生产者层面的幂等。
4.3.2 基于唯一键的去重
如果目标端不支持幂等写入,或者你想更精细地控制去重逻辑,可以维护一个去重表。
-- 去重表结构
CREATE TABLE sync_dedup (
sync_task VARCHAR(64) NOT NULL,
record_id VARCHAR(128) NOT NULL,
processed_at DATETIME NOT NULL,
PRIMARY KEY (sync_task, record_id)
);
-- 写入前检查
INSERT INTO sync_dedup (sync_task, record_id, processed_at)
VALUES ('order_sync', 'ORD2024001', NOW())
ON DUPLICATE KEY UPDATE processed_at = processed_at;
-- 如果影响行数为0,说明已处理过,跳过
if (rowsAffected == 0) {
skip();
}
这个方案的好处是灵活,可以针对不同任务、不同记录做精细控制。坏处是多了张表,需要定期清理过期数据。我一般会加个TTL,保留最近7天的去重记录就够了。
4.3.3 窗口去重
还有一种场景:数据是流式到达的,你没法在写入前做完整去重。这时候可以用窗口去重——在内存里维护一个滑动窗口,窗口内的数据做去重,窗口外的数据认为已经处理过。
Flink的Deduplicate算子就是典型的窗口去重实现。它基于事件时间,在指定的时间窗口内保留第一条或最后一条记录。
// Flink 窗口去重示例
DataStream<Order> stream = env
.addSource(kafkaConsumer)
.keyBy(order -> order.getOrderId())
.window(TumblingEventTimeWindows.of(Time.hours(1)))
.process(new DeduplicateFunction<>());
窗口去重适合对延迟敏感的场景,但需要权衡窗口大小和内存消耗。窗口太大,内存扛不住;窗口太小,可能漏掉迟到的数据。
4.4 快照与增量的衔接策略
好了,断点和去重都聊完了。现在回到最初的问题:快照和增量到底怎么衔接?
我总结了一套比较成熟的策略,分三步走:
- 快照开始前,记录一个基准点。比如记录当前的binlog位置或版本号。这个点就是快照和增量的分界线。
- 快照执行期间,增量日志正常采集但不应用。把快照期间产生的变更暂存起来,或者标记为"待处理"。
- 快照完成后,从基准点开始应用增量。先应用快照期间积压的变更,再继续监听新的变更。
这样做的好处是:快照和增量之间没有重叠窗口,不会产生重复数据。而且快照期间产生的变更不会丢失,因为增量日志一直在采集。
下面这张图展示了完整的衔接流程:
4.5 实战中的几个坑
理论说完了,分享几个我在实战中踩过的坑,希望能帮你少走弯路。
坑一:快照时间太长,增量日志被清理了。我曾经有个项目,全量快照跑了6个小时,结果MySQL的binlog只保留4小时。快照完成后,增量同步发现binlog已经被清理了,中间4小时的变更全部丢失。后来我加了监控,快照超过3小时就报警,同时把binlog保留时间延长到48小时。
坑二:断点存了,但恢复时发现数据源已经重置。比如Kafka的topic被重建了,或者MySQL的主库切换了。这时候旧的断点位置在新数据源里根本不存在。我的做法是:每次恢复时先校验断点有效性,如果无效就触发一次全量快照。
坑三:去重表膨胀太快。有个同事把去重记录永久保留,结果半年后去重表比业务表还大。其实去重只需要覆盖"可能重复"的时间窗口就够了。快照和增量衔接的场景,窗口通常不超过24小时。我一般设置7天的TTL,既安全又省空间。