2、双写问题的本质:数据库与消息队列双写导致的数据不一致与丢失风险

说实话,双写问题是我在分布式系统里踩过最深的坑之一。

那时候我刚接手一个订单系统,业务逻辑很简单:用户下单后,把订单数据写入 MySQL,再发一条消息到 RabbitMQ 通知下游做库存扣减。代码看起来没什么毛病,但线上总会出现「订单已支付,库存没扣」的诡异现象。我查了三天日志,最后发现——问题就出在「双写」这两个字上。

2.1 什么是双写?

双写,说白了就是同一个业务操作,要同时写入两个不同的存储系统。

在 Outbox Pattern 出现之前,最常见的做法是:

  1. 先写数据库(订单表、账户表等)
  2. 再写消息队列(Kafka、RocketMQ 等)

你想想看,这两个步骤不是原子操作。它们之间没有事务保障,没有回滚机制。一旦第二步失败,数据就裂开了。

核心矛盾:数据库和消息队列是两个独立的系统,它们之间没有分布式事务协议(或者说,实现分布式事务的成本太高)。

2.2 双写带来的两大风险

我把它总结成两个词:不一致 和 丢失。

2.2.1 数据不一致

什么叫不一致?就是数据库里有这条记录,但消息队列里没有对应的消息。

举个例子:

// 伪代码:双写场景
beginTransaction();
orderDAO.insert(order);          // 步骤1:写数据库
messageQueue.send(orderEvent);   // 步骤2:发消息
commitTransaction();             // 步骤3:提交事务

这段代码看起来没问题,对吧?但实际运行中,步骤2可能因为网络抖动、Broker 宕机、连接池耗尽等原因失败。这时候数据库已经写入了,消息却没发出去。下游系统永远不知道这笔订单存在。

反过来也一样:

// 另一种顺序
messageQueue.send(orderEvent);   // 步骤1:先发消息
beginTransaction();
orderDAO.insert(order);          // 步骤2:写数据库
commitTransaction();             // 步骤3:提交事务

如果步骤2或3失败,消息已经发出去了,下游系统收到了一个「不存在」的订单。这就是典型的「幽灵消息」。

我曾经踩过的坑:有一次消息先发出去了,数据库写入因为主键冲突回滚了。结果下游的库存服务已经把库存扣了,用户却看到订单创建失败。最后只能手动对账修复,折腾了一整夜。

2.2.2 数据丢失

数据丢失比不一致更可怕。不一致至少还能对账发现,丢失了可能永远找不回来。

丢失的场景通常发生在:

  • 应用崩溃:写完数据库后,应用进程突然挂了,消息还没发出去
  • 网络分区:数据库写入成功,但消息队列 Broker 不可达,消息发送超时
  • 事务回滚:消息发送成功,但数据库事务提交失败,消息已经无法撤回

我见过一个极端案例:某团队把消息发送放在事务提交之后,以为这样最安全。结果数据库主从切换时,事务提交成功了,但消息发送因为连接池爆满失败了。那批订单数据永远没有进入后续的处理流程。

2.3 为什么不能靠「重试」解决?

很多人第一反应是:失败了就重试呗。

嗯,这里要注意。重试确实能解决一部分问题,但它引入的新问题可能更麻烦。

重试策略 问题
同步重试 阻塞业务线程,可能引发雪崩
异步重试 重试队列本身也需要持久化,又回到双写问题
无限重试 消息重复,下游需要幂等处理
有限重试 超过重试次数后,数据还是丢了

说白了,重试只是把问题推迟了,并没有从根源上解决「两个系统写操作不同步」的问题。

2.4 双写问题的本质

我个人习惯把问题抽象到更底层去看。双写问题的本质是什么?

是「本地事务」和「远程调用」无法组成一个原子操作。

数据库的事务只保证本地 ACID。消息队列的发送是一个远程 RPC。这两者之间没有统一的协调者。你没办法让数据库说「如果消息没发成功,我的事务也别提交」。

有人会说:用 XA 分布式事务不行吗?

理论上可以。但实际项目中,XA 事务的性能开销太大,而且很多消息队列不支持 XA。就算支持,两阶段提交的锁持有时间也会把系统拖垮。我在一个高并发场景下试过,TPS 直接掉了 70%。

我的建议:不要试图用分布式事务来解决双写问题。在大多数业务场景下,最终一致性是可以接受的。关键是要保证「数据不丢」和「最终能对齐」。

2.5 一张图看懂双写问题

下面这张图展示了双写场景下,数据不一致和丢失的典型路径:

双写问题示意图 双写问题:不一致与丢失风险 应用服务 MySQL 数据库 消息队列 ① 写入订单 ② 发送消息 ✗ 场景一:数据不一致 数据库写入成功,消息发送失败 下游系统永远不知道这笔订单 场景二:数据丢失 应用写完数据库后崩溃 消息未发送,数据永久丢失 本质:本地事务与远程调用无法组成原子操作 重试只能缓解,不能根治

2.6 小结

双写问题的根因,说白了就是两个系统之间没有「共识」。数据库说「我写好了」,消息队列说「我没收到」,这时候你信谁?

我在项目中见过太多团队试图用「加重试」「加日志」「加人工补偿」来填这个坑。结果呢?重试导致消息重复,日志淹没在磁盘里,人工补偿永远赶不上业务量增长。

所以,我们需要一个更优雅的方案——把「写数据库」和「发消息」这两个动作,变成一个原子操作。这就是 Outbox Pattern 要解决的核心问题。

记住一句话:不要试图在两个系统之间保持强一致性。把其中一个系统作为「真相源」,另一个系统通过可靠机制去同步。这才是分布式系统的正确打开方式。