数据双写
2019 年,某金融公司启动核心系统拆分。订单服务从单体中剥离出来,新服务准备接收流量,但数据库还在主库里。怎么让新服务的写操作同时更新旧库?最简单的办法是:写两边。
这就是数据双写(Dual Write)。
双写是迁移期间数据同步的核心手段。它的本质是:在同一个业务操作中,同时向新旧两个数据源写入数据,确保在切换流量期间,两边的数据保持一致。
但双写也是最容易出问题的环节。写入延迟增加、数据冲突、失败重试、网络抖动——任何一个环节出问题,都会导致数据不一致,而数据不一致的后果,轻则是业务异常,重则是资金损失。
这一节从双写的三种模式讲起,分析每种模式的风险和适用场景,并给出生产级的解决方案。
双写的三种模式
双写有三种实现方式,各有权衡:
同步双写
同步双写是最直观的实现方式:在同一个业务方法中,先写新库,再写旧库(或者反过来)。
问题在于:任何一个写入失败,都会导致数据不一致。
如果新库写入成功、旧库写入失败,数据只在新库中,旧库缺失这条记录;如果反过来,旧库有记录但新库没有。两种情况都是数据不一致,但第一种更危险——新服务以为写入成功了,结果旧库没有数据。
你可能会想到加事务。但新库和旧库是两个独立的数据库连接,无法用同一个事务包裹。除非你使用分布式事务框架(如 Seata),但引入分布式事务又会增加复杂度。
更关键的问题是:延迟。 两次写入意味着两倍的响应时间。如果新库在另一个机房,网络延迟可能增加 10~50ms。
异步双写(消息队列)
异步双写把「写入旧库」这件事从主流程中抽离出来:通过消息队列异步完成。
异步双写解决了同步双写的两个问题:不阻塞主流程,不增加接口响应时间。但它引入了新的问题:消息可能丢失。
Kafka 默认是「至少一次」(at-least-once)语义,消息不会丢失。但如果不正确配置(如 acks=0),或者消费者重启期间有消息到达,消息可能丢失。
另一个问题是消息重复。 如果消费者处理成功但返回 ACK 失败,Kafka 会重发这条消息,导致旧库写入重复数据。你需要在消费者端做幂等处理。
本地消息表
本地消息表是解决消息可靠性问题的经典方案。它的核心思想是:把消息记录当作业务数据,在同一个事务里写入本地数据库。
这里的关键是:消息记录和业务数据在同一个数据库实例中,在同一个事务里写入。如果事务提交失败,消息记录也不会被写入;如果事务提交成功,消息记录一定在数据库里。
但消息在本地消息表里,怎么同步到旧库?需要一个定时任务轮询消息表:
定时任务负责把消息从本地消息表发送到 Kafka,消费者再把消息写入旧库。为什么多此一举? 因为本地消息表解决了 Kafka 消息可能丢失的问题——即使 Kafka 宕机,只要消息表在数据库里,就不会丢失。定时任务会不断重试,直到发送成功。
数据冲突处理
双写期间,最麻烦的问题不是「写不进去」,而是同一个数据在新旧系统中被修改了不同的值。
场景是这样的:
- 旧系统中,用户 A 的手机号是
13800138000 - 新系统上线,开始双写
- 用户 A 通过新系统把手机号改成了
13900139000 - 用户 A 通过旧系统(或其他渠道)把手机号改成了
13700137000 - 新库收到的是
13900139000,旧库收到的是13700137000 - 数据冲突了
解决方案有三种:
方案一:以新库为准。 迁移期间,所有业务操作都走新系统,旧系统只做数据同步。如果用户必须通过旧系统操作,需要加限制(如只读)。
方案二:最后写入优先(LWW)。 每次写入都记录时间戳,冲突时以最新时间的数据为准。简单,但可能丢失数据。
方案三:人工介入。 冲突时告警,人工确认数据保留哪个值。数据准确性高,但运营成本也高。
对于大多数迁移场景,方案一(以新库为准)是最推荐的。迁移的目标就是用新系统替换旧系统,业务操作应该尽量收敛到新系统。
双写的监控与告警
双写最怕的不是写失败,而是静默失败——写入看起来成功了,但另一边没有收到数据,日志里也没有明显错误。这种情况持续几小时,就会积累大量数据不一致。
必须建立完善的监控:
监控的核心指标有三个:
- 不一致率:新旧库数据差异的比例,应该
<1% - 延迟:从新库写入到旧库同步完成的时间,应该
<5 秒 - 消息堆积:本地消息表中 PENDING 状态消息的数量,应该
<1000 条
真实案例
真实案例:某电商公司订单系统迁移中的双写问题
- 现象:迁移期间,用户反馈订单状态不对,有些订单在新系统中显示「已支付」,但在旧系统中显示「待支付」
- 原因:异步双写的消息队列出现了短暂的抖动,部分消息延迟了 10 分钟才被消费,导致旧库数据更新不及时
- 解决方案:引入本地消息表作为兜底,确保即使 Kafka 出现问题,消息也不会丢失;增加监控告警,消息延迟超过 30 秒就告警
- 来源:内部技术复盘文档
总结
数据双写是迁移期间数据同步的核心手段,三种模式各有适用场景:
- 同步双写:简单但风险高,适合过渡期短、数据量小的场景
- 异步双写(消息队列):性能好,但需要保证消息不丢失
- 本地消息表:最可靠,但实现复杂,适合对数据一致性要求高的场景
选择双写模式时,最重要的判断依据是:对数据不一致的容忍度。 如果业务能接受几分钟的数据延迟,本地消息表是首选;如果业务要求实时一致,需要引入分布式事务(如 Seata)或其他补偿机制。
常见陷阱与反模式:
- 忽略幂等处理:异步双写时,如果消费者重启或 Kafka 重试,消息可能被重复消费。必须在消费者端做幂等处理(如基于主键的 INSERT 或带条件 UPDATE)。
- 没有监控:双写失败可能是静默的。必须建立数据一致性监控,及时发现数据差异。
- 事务边界错误:同步双写时,不要试图用本地事务包裹两个数据源的写入。两个独立数据库无法用同一个事务保证原子性。
思考题
问题 1:如果新库写入成功,但消息发送失败(Kafka 不可用),本地消息表方案如何保证数据不丢失?
参考答案
本地消息表方案的核心在于:消息记录和业务数据在同一个事务中写入。如果 Kafka 发送失败,消息记录已经持久化在数据库中,定时任务会不断重试发送。直到 Kafka 恢复,消息最终会被发送成功。关键配置:定时任务的执行频率要足够高(如每秒一次),重试策略要合理(如最多重试 3 次,超过后告警人工介入)。
问题 2:双写期间,如果旧库被其他系统(如定时任务)修改了数据,如何发现并处理这种「脏写」?
参考答案
需要在数据中增加「数据来源」和「最后修改时间」字段。新系统写入时标记来源为「新系统」,旧系统写入时标记来源为「旧系统」。如果发现某条记录被旧系统修改,而新系统也有修改历史,则可能出现数据冲突。解决方案:迁移期间限制旧系统的写入能力,或者在应用层增加写锁(同一时刻只允许一个系统写入)。
问题 3:对于已经存在大量历史数据的新旧库,如何快速验证双写期间的数据一致性?
参考答案
分三步走:第一步,全量校验:在迁移开始前,对比新旧库所有数据,记录不一致的数据;第二步,增量校验:迁移期间,每小时或每天运行一次增量校验,只对比最近修改过的数据;第三步,实时告警:对于关键的写操作,在写入完成后立即触发异步校验,发现不一致立即告警。增量校验的 SQL 示例:SELECT * FROM new_db.users WHERE update_time > last_check_time MINUS 1 hour 对比 SELECT * FROM old_db.users WHERE update_time > last_check_time MINUS 1 hour。