在微服务系统里,“写数据库”和“发消息”经常同时出现。例如订单服务创建订单后,要发出“订单已创建”事件,让库存、积分或通知服务继续处理。

最直观的代码往往是:

1
2
1. 写入订单
2. 向消息队列发送订单创建事件

问题在于,这是两次独立操作。订单写成功后,服务可能在发消息前崩溃;也可能消息已经发出,数据库事务却最终回滚。此时上下游看到的事实就不一致了。这类问题通常称为双写不一致。

Transactional Outbox(事务外盒)模式提供了一种容易记住的思路:先把“要发送的事件”与业务数据一起写进同一个数据库事务,再由独立程序慢慢把事件送到消息系统。

它是什么

Outbox 是一张普通的数据库表,用于保存待发送的事件。创建订单时,不直接向消息队列发送消息,而是在同一个本地事务内完成两件事:

1
2
3
写 orders 表
写 outbox_event 表
提交事务

因为这两次写入都在一个数据库事务里,所以它们要么一起成功,要么一起回滚。之后由“转发器(relay)”读取已提交的 outbox 记录,再发送给 Kafka、RabbitMQ 等消息系统。

常见表结构可以很简单:

1
2
3
4
5
6
7
8
9
CREATE TABLE outbox_event (
id VARCHAR(64) PRIMARY KEY,
aggregate_type VARCHAR(64) NOT NULL,
aggregate_id VARCHAR(64) NOT NULL,
event_type VARCHAR(128) NOT NULL,
payload TEXT NOT NULL,
created_at DATETIME NOT NULL,
published_at DATETIME NULL
);

字段名称和类型只是示例。重点是事件要有唯一 ID、业务对象标识、事件类型、内容以及发送状态或时间。

什么时候有用

当一个业务动作既要改变本地数据,又要通知其他系统时,就值得评估这个模式,例如:

  • 创建订单后通知库存服务扣减或预占库存;
  • 支付完成后通知积分、优惠券或消息服务;
  • 用户资料变更后同步搜索索引或分析系统。

它尤其适合“允许异步处理,但不能悄悄漏掉事件”的场景。需要注意的是,Outbox 并不是分布式事务的替代品:它解决的是本服务内“数据库更新和事件记录一起落库”的一致性,后续服务仍要面对重复消息、延迟和最终一致性。

一个具体例子

订单服务可按下面的顺序处理“创建订单”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Transactional
public Long createOrder(CreateOrderRequest request) {
Order order = orderRepository.save(Order.create(request));

OutboxEvent event = OutboxEvent.of(
UUID.randomUUID().toString(),
"Order",
String.valueOf(order.getId()),
"OrderCreated",
toJson(new OrderCreated(order.getId(), order.getUserId()))
);
outboxRepository.save(event);
return order.getId();
}

提交事务后,独立任务分批读取 published_at IS NULL 的记录,发送成功再标记时间:

1
2
3
4
读取已提交 outbox 事件
-> 发送到消息队列
-> 发送成功:更新 published_at
-> 发送失败:稍后重试

另一种常见实现不轮询业务表,而是读取数据库变更日志(CDC)。例如 Debezium 提供了 Outbox Event Router,用于把符合结构的 outbox 记录转换并路由为事件。采用哪种方式取决于现有基础设施、运维能力和延迟要求。

最容易误解的三件事

1. 有了 Outbox 就不会重复消息

不对。转发器可能已经把消息发送成功,但还没来得及更新 published_at 就重启;下次它会再次发送同一条事件。因此消费方仍应按事件 ID 或业务唯一键实现幂等处理。

更准确的目标通常是:至少一次投递 + 幂等消费,而不是承诺所有链路上的“绝对只处理一次”。

2. 直接给 outbox 表加一个定时任务就够了

定时扫描确实是可用的起点,但要补齐并发控制、批量大小、失败重试、积压监控和清理策略。多个转发实例同时扫描时,还要确保同一事件不会被并发重复处理,或让重复处理仍然安全。

3. 事件内容可以随意放整张业务表

事件更适合表达“发生了什么”,而不是无边界复制所有内部字段。事件结构一旦被下游依赖,就应考虑版本演进、敏感信息和兼容性。可以从稳定的事件 ID、类型、发生时间和必要业务字段开始。

采用前的简单检查

在引入 Outbox 前,可先回答四个问题:

  1. 这条消息丢失后会造成什么业务后果?
  2. 下游是否能接受延迟到达?
  3. 消费方是否能按事件 ID 去重?
  4. 团队是否能监控 outbox 积压、转发失败和重试次数?

若事件只是可有可无的日志,模式可能过重;若消息会驱动库存、资金或状态流转,则它提供的可靠性边界通常很有价值。

进一步资料可查看 Transactional Outbox 模式说明、Debezium Outbox Event Router 文档 和 AWS Transactional Outbox 指南。