3. 数据一致性基础:强一致性、最终一致性、如何保证双写数据一致
做数据迁移,最怕什么?
我最怕数据对不上。源库写了一行,目标库没写进去。或者两边都写了,但值不一样。这种事一旦发生,你就等着熬夜排查吧。
这一章,我们把一致性问题聊透。我会拆成三个部分:强一致性和最终一致性的区别、双写场景下到底怎么保证数据不丢不重、以及我自己踩过的坑和补救措施。
3.1 强一致性与最终一致性:该怎么选?
先搞清楚这两个概念。你想想看,你从源库读到一条数据,同时要求目标库也读到一模一样的数据——这就是强一致性。说白了,写操作完成那一刻,所有读请求都必须看到最新的值。
最终一致性就宽松多了。它允许写完数据后,短时间内读到旧值。只要最终能追上,就算合格。
我拿双写迁移来举例吧。假设你在同时写MySQL和Kafka两个系统:
- 强一致性:应用写完MySQL后,必须等Kafka也写完,并且你能读到这条数据,才返回成功。否则就回滚。
- 最终一致性:应用写完MySQL就返回成功。Kafka那边异步写过去,过几秒再查,数据就对上了。
哪个更靠谱?看场景。
| 维度 | 强一致性 | 最终一致性 |
|---|---|---|
| 延迟 | 高(必须同步等待) | 低(异步写入) |
| 吞吐 | 低(串行化约束多) | 高(可并行、批量) |
| 实现难度 | 高(需要分布式事务或锁) | 低(消息队列+补偿) |
| 典型场景 | 金融交易、订单状态 | 用户资料同步、日志采集 |
举个例子。我在做电商订单同步时,源库存写成功,目标库存延迟了2秒才更新。用户下单时看到库存为0,其实有货。这种问题,最终一致性下的补偿机制完全可以兜住——等目标库追上后,用定时任务对账纠正。没必要用强一致性硬撑。
3.2 双写一致性:三种主流方案
好,问题来了:你在同时写两套系统,怎么保证两边数据最终一样?
我归纳了三种方案。每种我都亲手部署过,优缺点全写出来。
方案一:本地事务 + 消息队列
这是我最常用的做法。流程是这样的:
- 应用在本地数据库开启事务。
- 写入本地表的同时,往同一事务内写入一张消息表(比如
t_dml_queue)。 - 事务提交,消息表里就有了一条记录。
- 后台有个定时任务,轮询这张消息表,把记录逐条发到目标系统。
- 目标系统消费成功后,删除或标记该记录。
这么做的好处是——事务保证了写入的原子性。本地写库和写消息表是同一个事务,要么都成功,要么都失败,不会出现“库写了但消息丢了”的情况。
核心要点:消息表必须和业务数据在同一个数据库事务中。这是唯一能保证“不丢”的手段。
代码写起来很直接。我贴一个简化版:
@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 字段,每次写入时比对版本号,如果版本号小于等于当前值,直接跳过。这样就永远不会重复写。
方案二:分布式锁 + 双写
这个方案更直接。应用获取一把全局锁(比如Redis锁或ZooKeeper锁),然后同时写源系统和目标系统。写完后释放锁。
- 尝试获取锁。获取不到就重试或快速失败。
- 获取成功后,执行双写代码。
- 两个写操作都成功,释放锁。
- 任何一个失败,回滚已成功的写,释放锁。
优点是:逻辑简单,写代码速度快。但缺点也很明显:
- 性能瓶颈:锁的粒度太粗。所有写请求串行化,吞吐直接打三折。
- 依赖外部组件:Redis挂了怎么办?锁超时怎么办?这些都要处理。
我只在两种情况下用这个方案:一是数据量极小(比如配置表),二是强一致性要求极高(比如余额变动)。其他场景,我都劝团队别碰。
方案三:异步对账 + 补偿
这是最终一致性方案的典型代表。思路很简单:
- 双写时,只管写入源系统,目标系统异步消费消息队列。
- 另起一条定时任务,每隔N分钟扫描一次源表和目标表,比对记录。
- 发现不一致时,用源数据覆盖目标数据。
说起来容易,做起来要小心。我习惯的做法是:
- 对账任务按表分区,每次只扫描最近1小时的数据。
- 对比两个系统的
CRC32或MD5值,而不是逐行比对(否则慢死)。 - 发现差异后,把原始SQL重新放回消息队列,而不是直接更新。这样可以复用消费逻辑。
最佳实践:对账的间隔不要设太短(比如1分钟一次),否则系统压力大;也不要设太长(比如1小时一次),否则数据不一致窗口太大。我一般根据业务容忍度设为5~15分钟。
这三种方案没有的好坏。我个人的选择顺序是:
- 能接受最终一致性?→ 异步对账+补偿(方案三)
- 必须在事务内保证?→ 本地事务+消息队列(方案一)
- 没有别的手段,就是要强一致?→ 分布式锁+双写(方案二,但慎用)
3.3 一张图看懂一致性保障体系
下面这张图,展示了三种方案的核心逻辑。你可以把它当作一致性保障的“地图”。
3.4 双写一致性检查清单
最后,我把自己每次做双写迁移时必查的项目列出来。你可以直接当清单用:
- 消息表是否在事务内? 不在事务内的消息,必丢。
- 消费逻辑是否幂等? 重试多少次都不会产生脏数据。
- 对账任务是否覆盖所有表? 漏掉一个表,数据就可能不一致。
- 补偿操作是否发回原消息队列? 不要直接写,否则后续排查困难。
- 是否有监控和告警? 对账发现不一致,要立刻告警,而不是留到第二天。
- 双写过程是否记录了原始SQL? 万一出问题,能回放。
一致性不是一个开关,而是一套体系。消息表、幂等、对账、告警,每一个点都得守住。
好了,这一章就到这里。记住:先定义容忍度,再选方案,最后补监控。三步走完,双写的一致性就有了保障。