痛点:双写问题
想象一个典型的电商场景:用户下单后,你需要同时做两件事------
- 在数据库中更新订单状态为"已支付"
- 向消息队列(Kafka / RabbitMQ 等)发布一个
OrderPaid事件,让下游服务(库存、积分、通知等)感知
问题是,这两个操作分别针对数据库 和消息中间件,它们是两个独立的系统。如果数据库写入成功,但消息发布失败(网络抖动、MQ 宕机),事件就丢了,下游服务永远不知道这笔订单发生了什么事,数据最终不一致。
Outbox 模式的核心思想
不直接发送消息,而是将消息与业务数据写入同一个本地数据库事务中。 具体流程如下:
- 业务操作 + 写入发件箱 :在同一个数据库事务里,既更新业务表(如订单表),也向一张专门的
outbox表插入一条记录,记录需要发送的事件(事件类型、负载、目标主题等)。 - 事务提交:数据库事务提交。此时,业务数据和待发送事件已原子性地持久化。
- 投递器(Outbox Forwarder / Relay)取走事件 :一个独立的后台进程------即你所说的 "Outbox 投递器" ------轮询
outbox表,读取状态为"待发送"的事件,将其投递到消息队列。 - 标记已发送 :投递成功后,将
outbox表中对应记录的状态更新为"已发送"。
由于业务数据和事件记录在同一个本地事务中,要么一起成功,要么一起回滚,从根本上保证了原子性。
Outbox 投递器(Forwarder / Relay)的实现方式
投递器是独立于业务服务的后台组件,常见的实现方案有两种:
方案一:轮询(Polling)
- 一个后台 Worker 定期扫描
outbox表,查询status = 'pending'的记录。 - 逐条或批量投递到消息队列,投递成功后更新状态。
- 优点:实现简单,不依赖额外组件。
- 缺点:有轮询延迟,高并发下可能对数据库造成压力。
方案二:CDC(Change Data Capture)
- 利用数据库的 binlog(如 Debezium + Kafka Connect)监听
outbox表的数据变更。 - 一旦有新的
outbox记录写入,CDC 组件立即捕获并推送到消息队列。 - 优点:实时性高,不轮询数据库,对业务无侵入。
- 缺点:需要引入额外的基础设施(如 Debezium、Kafka Connect)。
发件箱表的典型设计
一张工程可用的 outbox 表,至少需要覆盖以下字段:
| 字段 | 说明 |
|---|---|
id |
全局唯一标识(UUID) |
event_key |
业务幂等键(如 order:12345),用于下游去重 |
event_type |
事件类型(如 OrderCreated) |
payload |
事件体(JSON 格式) |
aggregate_id |
聚合根 ID,用于保证同一业务链路的事件顺序 |
status |
状态(pending / published / failed) |
next_retry_at |
下次重试时间 |
retry_count |
重试次数 |
created_at |
创建时间 |
关键索引:(status, next_retry_at) 联合索引,支撑投递器高效轮询。
Outbox 模式的优缺点
优点:
- 解决了本地事务与消息发送的原子性难题
- 不依赖分布式事务(如两阶段提交),性能好
- 消息投递具备"至少一次"(at-least-once)语义,配合下游幂等消费,可实现最终一致性
缺点:
- 消息是异步投递的,存在短暂的延迟
- 下游消费者需要做好幂等处理(因为投递器可能重复投递)
- 需要额外维护
outbox表及其清理机制
适用场景
- 微服务间的事件驱动通信
- 需要保证数据库变更与消息发布的原子性
- 秒杀、订单、支付等核心链路
- 任何需要最终一致性的分布式系统
Outbox 模式是微服务架构中实现可靠消息投递的事实标准方案,目前已被广泛集成到各种框架和工具中,如 .NET 的 CAP、Go 的 go-outbox、Java 的 Spring Modulith 等。