3. 数据一致性基础:强一致性、最终一致性、如何保证双写数据一致

做数据迁移,最怕什么?

我最怕数据对不上。源库写了一行,目标库没写进去。或者两边都写了,但值不一样。这种事一旦发生,你就等着熬夜排查吧。

这一章,我们把一致性问题聊透。我会拆成三个部分:强一致性和最终一致性的区别、双写场景下到底怎么保证数据不丢不重、以及我自己踩过的坑和补救措施。

3.1 强一致性与最终一致性:该怎么选?

先搞清楚这两个概念。你想想看,你从源库读到一条数据,同时要求目标库也读到一模一样的数据——这就是强一致性。说白了,写操作完成那一刻,所有读请求都必须看到最新的值。

最终一致性就宽松多了。它允许写完数据后,短时间内读到旧值。只要最终能追上,就算合格。

我拿双写迁移来举例吧。假设你在同时写MySQL和Kafka两个系统:

  • 强一致性:应用写完MySQL后,必须等Kafka也写完,并且你能读到这条数据,才返回成功。否则就回滚。
  • 最终一致性:应用写完MySQL就返回成功。Kafka那边异步写过去,过几秒再查,数据就对上了。

哪个更靠谱?看场景。

维度 强一致性 最终一致性
延迟 高(必须同步等待) 低(异步写入)
吞吐 低(串行化约束多) 高(可并行、批量)
实现难度 高(需要分布式事务或锁) 低(消息队列+补偿)
典型场景 金融交易、订单状态 用户资料同步、日志采集
我的建议:不要一上来就追求强一致性。强制同步往往把系统搞死。我见过太多团队为了“看起来一致”,引入分布式事务,结果吞吐暴跌、故障频发。先问问自己:用户能接受1秒的延迟吗?如果能,最终一致性就够了。

举个例子。我在做电商订单同步时,源库存写成功,目标库存延迟了2秒才更新。用户下单时看到库存为0,其实有货。这种问题,最终一致性下的补偿机制完全可以兜住——等目标库追上后,用定时任务对账纠正。没必要用强一致性硬撑。

3.2 双写一致性:三种主流方案

好,问题来了:你在同时写两套系统,怎么保证两边数据最终一样?

我归纳了三种方案。每种我都亲手部署过,优缺点全写出来。

方案一:本地事务 + 消息队列

这是我最常用的做法。流程是这样的:

  1. 应用在本地数据库开启事务。
  2. 写入本地表的同时,往同一事务内写入一张消息表(比如 t_dml_queue)。
  3. 事务提交,消息表里就有了一条记录。
  4. 后台有个定时任务,轮询这张消息表,把记录逐条发到目标系统。
  5. 目标系统消费成功后,删除或标记该记录。

这么做的好处是——事务保证了写入的原子性。本地写库和写消息表是同一个事务,要么都成功,要么都失败,不会出现“库写了但消息丢了”的情况。

核心要点:消息表必须和业务数据在同一个数据库事务中。这是唯一能保证“不丢”的手段。

代码写起来很直接。我贴一个简化版:

@Transactional
public void updateUser(User user) {
    // 1. 更新主表
    userDao.update(user);
    // 2. 写消息表
    DmlQueue queue = new DmlQueue();
    queue.setTableName("user");
    queue.setRecordId(user.getId());
    queue.setOperation("UPDATE");
    queue.setPayload(JsonUtils.toJson(user));
    dmlQueueDao.insert(queue);
    // 事务提交后,消息就落库了
}

你可能会问:如果消息发到目标系统失败了怎么办?重复发?

对,必须支持幂等。目标系统要能识别重复消息。我一般会在目标表上加一个 source_version 字段,每次写入时比对版本号,如果版本号小于等于当前值,直接跳过。这样就永远不会重复写。

我曾经踩过的坑:有一回我把消息队列的消费逻辑写成了“先删除旧数据再插入新数据”。结果消息重试了3次,目标表的记录就被删了又插、插了又删。用户查不到数据,后台日志全是“记录已删除”……后来我改成“只插入、不删除,幂等靠唯一键”。这个教训让我记住了:双写场景下,任何非幂等的操作都是定时炸弹。

方案二:分布式锁 + 双写

这个方案更直接。应用获取一把全局锁(比如Redis锁或ZooKeeper锁),然后同时写源系统和目标系统。写完后释放锁。

  1. 尝试获取锁。获取不到就重试或快速失败。
  2. 获取成功后,执行双写代码。
  3. 两个写操作都成功,释放锁。
  4. 任何一个失败,回滚已成功的写,释放锁。

优点是:逻辑简单,写代码速度快。但缺点也很明显:

  • 性能瓶颈:锁的粒度太粗。所有写请求串行化,吞吐直接打三折。
  • 依赖外部组件:Redis挂了怎么办?锁超时怎么办?这些都要处理。

我只在两种情况下用这个方案:一是数据量极小(比如配置表),二是强一致性要求极高(比如余额变动)。其他场景,我都劝团队别碰。

方案三:异步对账 + 补偿

这是最终一致性方案的典型代表。思路很简单:

  1. 双写时,只管写入源系统,目标系统异步消费消息队列。
  2. 另起一条定时任务,每隔N分钟扫描一次源表和目标表,比对记录。
  3. 发现不一致时,用源数据覆盖目标数据。

说起来容易,做起来要小心。我习惯的做法是:

  • 对账任务按表分区,每次只扫描最近1小时的数据。
  • 对比两个系统的 CRC32 或 MD5 值,而不是逐行比对(否则慢死)。
  • 发现差异后,把原始SQL重新放回消息队列,而不是直接更新。这样可以复用消费逻辑。

最佳实践:对账的间隔不要设太短(比如1分钟一次),否则系统压力大;也不要设太长(比如1小时一次),否则数据不一致窗口太大。我一般根据业务容忍度设为5~15分钟。

这三种方案没有的好坏。我个人的选择顺序是:

  1. 能接受最终一致性?→ 异步对账+补偿(方案三)
  2. 必须在事务内保证?→ 本地事务+消息队列(方案一)
  3. 没有别的手段,就是要强一致?→ 分布式锁+双写(方案二,但慎用)
避坑指南:很多人一上来就选方案二,因为写代码最方便。但一旦上了生产,锁竞争、超时、死锁……全来了。我建议你先试方案一。虽然多了一张消息表,但它在事务边界内,逻辑清晰,容易排查。

3.3 一张图看懂一致性保障体系

下面这张图,展示了三种方案的核心逻辑。你可以把它当作一致性保障的“地图”。

数据一致性保障体系 · 双写方案对比 方案一:本地事务+消息队列 应用层 本地事务 主表 消息表 后台定时轮询 目标系统 方案二:分布式锁+双写 分布式锁 源系统写 目标系统写 方案三:异步对账+补偿 源系统写入 MQ异步消费 目标系统写入 定时对账任务 补偿写入 📌 选择建议: 强调排序:本地事务+MQ (推荐) → 异步对账+补偿 (可接受延迟) → 分布式锁+双写 (慎用)

3.4 双写一致性检查清单

最后,我把自己每次做双写迁移时必查的项目列出来。你可以直接当清单用:

  1. 消息表是否在事务内? 不在事务内的消息,必丢。
  2. 消费逻辑是否幂等? 重试多少次都不会产生脏数据。
  3. 对账任务是否覆盖所有表? 漏掉一个表,数据就可能不一致。
  4. 补偿操作是否发回原消息队列? 不要直接写,否则后续排查困难。
  5. 是否有监控和告警? 对账发现不一致,要立刻告警,而不是留到第二天。
  6. 双写过程是否记录了原始SQL? 万一出问题,能回放。
我曾经因为漏掉第5条,吃过一次大亏。 对账脚本每天凌晨跑一次,发现不一致只是打日志,没人看。结果数据跑偏了一周才被发现。后来我在每次对账差异超过阈值时直接发钉钉告警。从此再也没有出过类似问题。

一致性不是一个开关,而是一套体系。消息表、幂等、对账、告警,每一个点都得守住。

好了,这一章就到这里。记住:先定义容忍度,再选方案,最后补监控。三步走完,双写的一致性就有了保障。