Transactional Outbox:数据库写成功后,消息为什么还会丢?
在微服务系统里,“写数据库”和“发消息”经常同时出现。例如订单服务创建订单后,要发出“订单已创建”事件,让库存、积分或通知服务继续处理。
最直观的代码往往是:
1 | |
问题在于,这是两次独立操作。订单写成功后,服务可能在发消息前崩溃;也可能消息已经发出,数据库事务却最终回滚。此时上下游看到的事实就不一致了。这类问题通常称为双写不一致。
Transactional Outbox(事务外盒)模式提供了一种容易记住的思路:先把“要发送的事件”与业务数据一起写进同一个数据库事务,再由独立程序慢慢把事件送到消息系统。
它是什么
Outbox 是一张普通的数据库表,用于保存待发送的事件。创建订单时,不直接向消息队列发送消息,而是在同一个本地事务内完成两件事:
1 | |
因为这两次写入都在一个数据库事务里,所以它们要么一起成功,要么一起回滚。之后由“转发器(relay)”读取已提交的 outbox 记录,再发送给 Kafka、RabbitMQ 等消息系统。
常见表结构可以很简单:
1 | |
字段名称和类型只是示例。重点是事件要有唯一 ID、业务对象标识、事件类型、内容以及发送状态或时间。
什么时候有用
当一个业务动作既要改变本地数据,又要通知其他系统时,就值得评估这个模式,例如:
- 创建订单后通知库存服务扣减或预占库存;
- 支付完成后通知积分、优惠券或消息服务;
- 用户资料变更后同步搜索索引或分析系统。
它尤其适合“允许异步处理,但不能悄悄漏掉事件”的场景。需要注意的是,Outbox 并不是分布式事务的替代品:它解决的是本服务内“数据库更新和事件记录一起落库”的一致性,后续服务仍要面对重复消息、延迟和最终一致性。
一个具体例子
订单服务可按下面的顺序处理“创建订单”:
1 | |
提交事务后,独立任务分批读取 published_at IS NULL 的记录,发送成功再标记时间:
1 | |
另一种常见实现不轮询业务表,而是读取数据库变更日志(CDC)。例如 Debezium 提供了 Outbox Event Router,用于把符合结构的 outbox 记录转换并路由为事件。采用哪种方式取决于现有基础设施、运维能力和延迟要求。
最容易误解的三件事
1. 有了 Outbox 就不会重复消息
不对。转发器可能已经把消息发送成功,但还没来得及更新 published_at 就重启;下次它会再次发送同一条事件。因此消费方仍应按事件 ID 或业务唯一键实现幂等处理。
更准确的目标通常是:至少一次投递 + 幂等消费,而不是承诺所有链路上的“绝对只处理一次”。
2. 直接给 outbox 表加一个定时任务就够了
定时扫描确实是可用的起点,但要补齐并发控制、批量大小、失败重试、积压监控和清理策略。多个转发实例同时扫描时,还要确保同一事件不会被并发重复处理,或让重复处理仍然安全。
3. 事件内容可以随意放整张业务表
事件更适合表达“发生了什么”,而不是无边界复制所有内部字段。事件结构一旦被下游依赖,就应考虑版本演进、敏感信息和兼容性。可以从稳定的事件 ID、类型、发生时间和必要业务字段开始。
采用前的简单检查
在引入 Outbox 前,可先回答四个问题:
- 这条消息丢失后会造成什么业务后果?
- 下游是否能接受延迟到达?
- 消费方是否能按事件 ID 去重?
- 团队是否能监控 outbox 积压、转发失败和重试次数?
若事件只是可有可无的日志,模式可能过重;若消息会驱动库存、资金或状态流转,则它提供的可靠性边界通常很有价值。
进一步资料可查看 Transactional Outbox 模式说明、Debezium Outbox Event Router 文档 和 AWS Transactional Outbox 指南。


