Outbox + 消费侧幂等:把「落库成功、消息丢失」堵在源头

有个 bug 反复出现又很难复现:订单落库成功了,钱包没扣款;或者钱包扣了,库存没释放。查日志发现业务事务提交了,但 MQ 的发送失败了。

在事务里发 MQ 是行不通的

最直觉的写法是:开事务 → 写订单 → 发 MQ → 提交。问题是发消息是一次网络调用,它不在事务里:

  • 消息发出去了、事务回滚了 → 下游收到一条不存在的订单
  • 事务提交了、消息发失败了 → 下游永远不知道(就是那个 bug)

把发送放在提交之后也不安全,因为进程可能在提交成功、发送之前挂掉。这是个经典的两难,本质是「两个系统没法参加同一个事务」。

本地消息表

解决办法是把「发消息」这件事也落库:

  1. 业务写入和 outbox 记录在同一个本地事务里提交。二者要么都成功,要么都失败——这是数据库能保证的。
  2. 后台任务扫描 outbox 表里未投递的记录,投递给 RocketMQ,成功后标记 SENT。
  3. 投递失败就重试,有上限,超了进死信等人工。

这样「消息会不会丢」就退化成「后台任务会不会跑」——后者是可以用监控解决的工程问题,而不是一致性问题。

至少一次,就必须幂等

转发机制必然导致重复投递(投递成功但标记失败,下次会再发)。所以消费侧必须能承受重复:

  • claim 租约:消费前先抢这条消息的处理权,带超时租约。多实例并发消费同一条消息时,只有一个能抢到;抢到的一方挂了,租约过期后别人接手。
  • 消费侧状态机:处理逻辑同样用 UPDATE ... WHERE status = 'PENDING' 推进,重复消费时影响行数为 0,直接跳过。
  • 死信:重试超过阈值进死信队列,避免一条坏消息把消费线程堵死。

这三件事一起做,才算「可恢复的调度」——订单、钱包、知识库事件都走这套壳子,业务代码只关心自己那一小段状态迁移。

代价

代价是真实存在的:多一张表、多一个扫描任务、消息延迟从毫秒变成「扫描间隔」级别。所以并不是所有事件都值得这么做——能接受偶尔丢的事件,直接发就行;涉及钱和库存的,才值得上 Outbox。

这个判断标准比技术选型本身更重要。