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';

这里有个细节:什么时候存断点?我个人习惯是在消费完一批数据并成功写入目标端之后再存。如果先存断点再写入,写入失败时断点已经前移了,就会丢数据。反过来,先写入再存断点,即使写入成功但存断点失败,下次启动时最多重复消费一批数据,不会丢。

经验之谈:断点存储建议用独立的事务,或者写入一个单独的持久化存储(比如ZooKeeper、etcd)。别把断点和业务数据混在同一个事务里,否则回滚时会连带断点一起回滚,导致重复消费大量数据。

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 快照与增量的衔接策略

好了,断点和去重都聊完了。现在回到最初的问题:快照和增量到底怎么衔接?

我总结了一套比较成熟的策略,分三步走:

  1. 快照开始前,记录一个基准点。比如记录当前的binlog位置或版本号。这个点就是快照和增量的分界线。
  2. 快照执行期间,增量日志正常采集但不应用。把快照期间产生的变更暂存起来,或者标记为"待处理"。
  3. 快照完成后,从基准点开始应用增量。先应用快照期间积压的变更,再继续监听新的变更。

这样做的好处是:快照和增量之间没有重叠窗口,不会产生重复数据。而且快照期间产生的变更不会丢失,因为增量日志一直在采集。

核心原则:快照和增量的分界线,必须在快照开始之前就确定下来。不要在快照完成之后再去"找"分界线,那样一定会出问题。

下面这张图展示了完整的衔接流程:

快照与增量衔接流程图 T0 T1 T2 T3 记录基准点 执行快照 暂存增量 应用增量 关键说明: • T0:快照开始前,记录当前binlog位置或版本号作为基准点 • T1-T2:快照执行期间,增量日志正常采集但暂不应用 • T2-T3:快照完成后,从基准点开始应用积压的增量变更 数据流示意 源库 快照数据 增量日志 暂存区 合并写入 目标库 去重检查(幂等/唯一键)

4.5 实战中的几个坑

理论说完了,分享几个我在实战中踩过的坑,希望能帮你少走弯路。

坑一:快照时间太长,增量日志被清理了。我曾经有个项目,全量快照跑了6个小时,结果MySQL的binlog只保留4小时。快照完成后,增量同步发现binlog已经被清理了,中间4小时的变更全部丢失。后来我加了监控,快照超过3小时就报警,同时把binlog保留时间延长到48小时。

坑二:断点存了,但恢复时发现数据源已经重置。比如Kafka的topic被重建了,或者MySQL的主库切换了。这时候旧的断点位置在新数据源里根本不存在。我的做法是:每次恢复时先校验断点有效性,如果无效就触发一次全量快照。

坑三:去重表膨胀太快。有个同事把去重记录永久保留,结果半年后去重表比业务表还大。其实去重只需要覆盖"可能重复"的时间窗口就够了。快照和增量衔接的场景,窗口通常不超过24小时。我一般设置7天的TTL,既安全又省空间。

一个小技巧:去重表可以用Redis的SET或者HyperLogLog来实现,性能比关系型数据库高一个数量级。但要注意,Redis的HyperLogLog是有误差的(约0.81%),对精度要求高的场景还是用SET或者关系型数据库。