# 架构实战|Outbox + 消费幂等:把“至少一次”变成“像一次”(连载第 2 篇)
> 上一篇我们把交易系统拆成了“订单/库存/支付/履约/账务”五个成年人,并立了三条宪法:幂等优先、可补偿、可解释。
>
> 这一篇我们解决交易系统里最阴魂不散的问题:**消息不会只来一次**。
>
> 你会在凌晨的告警里看到它的身影:
> - 订单状态已支付,但履约没触发
> - 用户收到两张券(还挺开心,但你不开心)
> - 库存被扣了两次(老板的脸色更精彩)
>
> 所以今天的主题是:
>
> **Outbox Pattern + Consumer Idempotency:在“至少一次投递”的世界里,活出“像一次”的体面。**
---
## 0. 先讲一个真实到不想承认的故事
某次我们线上出现了一个“偶现”的 bug:
- 支付成功回调 → 订单状态更新为 `PAID`
- 随后应当发一条 `OrderPaid` 事件给履约系统
- 但是有极少数订单,状态是 `PAID`,却没有履约
排查一圈后发现:
1) 更新订单状态的 DB 事务提交成功
2) MQ 发送那一刻网络抖了一下/Producer 超时/队列短暂不可用
3) 发送失败没有被正确重试
于是我们得到了最尴尬的状态:
> **数据库里“历史已写下”,消息世界却“假装没发生”。**
如果你曾经在复盘会上说过“我们将加强重试与监控”,那我建议你把这篇文章打印出来贴在工位上——不是为了自责,而是为了让未来的你少加班。
---
## 1. 交易系统里的“消息”到底是什么?
先明确一个概念:
- **命令(Command)**:请你做这件事(有意图,有可能失败)
- **事件(Event)**:这件事已经发生了(事实,应该可重放)
在交易系统里,我们更推荐在跨域协作时使用“事件”:
- `OrderPaid`:订单已支付(事实)
- `InventoryReserved`:库存已锁定(事实)
- `FulfillmentDone`:履约完成(事实)
事件的价值是:
> 事实可以重放,意图容易吵架。
---
## 2. “至少一次”不可避免:你要么面对它,要么被它支配
为什么 MQ 基本都是“至少一次投递”(at-least-once)?
因为“恰好一次”(exactly-once)在分布式里意味着:
- 强一致协调
- 更复杂的协议
- 更高的成本
- 更低的可用性
而业务更现实的需求是:
> **宁愿多来一次,也不能少来一次。**
少来一次是漏单、漏履约、漏记账;
多来一次顶多是重复处理(前提是你做了幂等)。
所以我们要接受现实:
- Producer 可能重试导致重复
- Broker 可能重投导致重复
- Consumer 崩溃恢复也会重复
**消息重复不是异常,是常态。**
---
## 3. Outbox Pattern:把“发消息”变成“写数据库”
核心思路一句话:
> **在同一个本地事务里,把“要发的事件”写到 outbox 表里。**
这样你就不会出现“DB 成功但 MQ 失败”的撕裂。
### 3.1 典型流程(以支付成功为例)
在订单服务里,处理支付成功:
1) 订单状态 `PAYING -> PAID`(CAS/乐观锁)
2) 插入 outbox:`OrderPaid` 事件(payload 包含 orderId、payOrderId、amount、occurredAt…)
3) 本地事务提交
提交之后,由 outbox 投递器异步扫描并发送到 MQ。
### 3.2 Outbox 表设计(MySQL)
```sql
create table t_outbox (
id bigint primary key,
aggregate_type varchar(32) not null, -- Order
aggregate_id bigint not null, -- orderId
event_type varchar(64) not null, -- OrderPaid
event_key varchar(128) not null, -- 幂等键(建议:eventType + aggregateId + version)
payload json not null,
status varchar(16) not null, -- NEW / SENDING / SENT / DEAD
retry_count int not null default 0,
next_retry_at datetime not null,
created_at datetime not null,
updated_at datetime not null,
unique key uk_event_key (event_key),
key idx_status_retry (status, next_retry_at)
);
```
几个关键点:
- `event_key` 是 outbox 自己的幂等键,防止同一事务重复插入
- `status + next_retry_at` 让投递器可以高效扫表
- `payload` 保留足够信息让下游不必回查太多(但也别塞 2MB)
### 3.3 Java 事务伪代码
```java
@Transactional
public void onPaySuccess(PayCallback cb) {
Order order = orderDao.findForUpdate(cb.getOrderId());
// 1) 幂等:如果已经 PAID 直接返回
if (order.isPaid()) return;
// 2) 状态机 CAS(简化)
boolean ok = orderDao.casUpdateStatus(order.getId(), "PAYING", "PAID", order.getVersion());
if (!ok) {
// 并发竞争失败,重新读一次状态再决定
Order latest = orderDao.find(cb.getOrderId());
if (latest.isPaid()) return;
throw new IllegalStateException("order status conflict");
}
// 3) 写 outbox
OutboxEvent ev = OutboxEvent.orderPaid(order, cb);
outboxDao.insert(ev);
}
```
注意:这里用了 `findForUpdate` 只是表达“确保你理解一致性”,实际项目中常用 CAS + 重试即可,不一定要行锁。
---
## 4. Outbox 投递器:不帅,但决定你晚上能不能睡
Outbox 投递器是一个后台任务(可以是独立进程,也可以是服务内线程池)。
它做的事情很朴素:
1) 扫描 `status=NEW && next_retry_at<=now()` 的事件
2) 标记为 `SENDING`(抢占)
3) 发送到 MQ
4) 成功 → `SENT`
5) 失败 → `retry_count++`、更新 `next_retry_at`(指数退避),必要时 `DEAD`
### 4.1 抢占(避免多实例重复发送)
多实例部署下,多个投递器会同时扫描。解决方式:
- 用 `update ... where status=NEW limit N` 抢占
- 或者 select 后 `update status=NEW -> SENDING` 带条件
示例:
```sql
update t_outbox
set status='SENDING', updated_at=now()
where id=? and status='NEW';
```
如果返回 1,表示抢到;返回 0,说明别人抢走了。
### 4.2 发送语义:至少一次 + 乱序
投递器发送到 MQ 本身也会遇到:
- 超时(不确定是否成功)
- broker 重试
因此 outbox 投递器也默认“至少一次”。
这意味着:
> Outbox 解决的是“DB 与 MQ 的一致性”,并不解决“消息只来一次”。
“只来一次”的问题交给消费者(下一节)。
---
## 5. 消费幂等:你要学会对重复说“我认识你”
消费者幂等,本质上是:
> **同一条业务事件,无论被消费多少次,业务结果都只生效一次。**
### 5.1 幂等键怎么选?
幂等键必须满足:
- 业务语义上唯一
- 可追踪
- 可重放
推荐做法:
- 事件携带 `eventId`(全局唯一)
- 或使用 `event_key`(`eventType + aggregateId + version`)
其中 “version” 很关键:
- 一个订单可能产生多个事件:`OrderCreated`、`OrderPaid`、`OrderCancelled`
- 只用 `orderId` 会冲突
### 5.2 消费去重表(Inbox)
消费者侧建一张 `t_inbox`:
```sql
create table t_inbox (
id bigint primary key,
event_key varchar(128) not null,
event_type varchar(64) not null,
consumer varchar(64) not null,
processed_at datetime not null,
unique key uk_consumer_event (consumer, event_key)
);
```
处理流程:
1) 收到消息
2) 先插入 `t_inbox`(用唯一键保证一次性)
3) 插入成功 → 执行业务
4) 插入失败(唯一键冲突)→ 说明已经处理过,直接 ack
这就是最稳的“幂等落库”。
### 5.3 为什么“先插入 inbox 再执行业务”很重要?
因为你最怕:
- 业务执行成功
- 还没记录幂等
- 进程挂了
- 重启后又来一遍
于是你发了两张券。
所以顺序应该是:
> **先把“我处理过了”写进数据库,再去做业务。**
那有人会问:
> “那如果写 inbox 成功了,但业务执行失败了怎么办?”
这就引出了下一节:**幂等不是银弹,它需要可补偿与状态机配合。**
---
## 6. 幂等 + 状态机:把“重复消费”变成“推进进度条”
以履约系统为例:消费 `OrderPaid` 后要做发券。
我们建议履约表本身就是状态机:
`t_fulfillment(order_id, type, status, version, ...)`
- `INIT`:未开始
- `DOING`:执行中
- `DONE`:完成
- `FAILED`:失败(可重试/不可重试)
消费逻辑:
1) 先 inbox 去重
2) 查履约记录
3) 如果 `DONE` → 直接 ack
4) 如果 `INIT/FAILED` → 执行发券,并把状态更新为 `DONE`
这样重复消息不会重复发券,因为状态机挡住了。
你会发现:
> inbox 解决“事件重复”,状态机解决“业务动作重复”。
两者缺一不可。
---
## 7. 事务边界:消费者也要像 outbox 一样“落库再 ack”
消费者使用 MQ 时,通常有两种提交点:
- **先 ack 再落库**:性能好,但容易丢
- **先落库再 ack**:更稳,但吞吐略低
交易系统里我建议:
> **关键链路:先落库再 ack。非关键链路:可以先 ack 再异步。**
尤其是:履约、入账、库存等关键动作。
### 7.1 “落库再 ack”的实现要点
- 消费逻辑必须可重入
- 业务操作必须幂等
- inbox 插入与业务状态更新尽量同事务
伪代码:
```java
@Transactional
public void onOrderPaid(Event e) {
// 1) inbox 去重
boolean first = inboxDao.tryInsert("fulfillment-service", e.getEventKey());
if (!first) return; // already processed
// 2) 履约状态机推进
Fulfillment f = fulfillmentDao.findOrCreate(e.getOrderId(), "COUPON");
if (f.isDone()) return;
fulfillmentDao.casUpdateStatus(f.getId(), f.getStatus(), "DOING", f.getVersion());
// 3) 调用发券
couponClient.grant(e.getUserId(), e.getOrderId(), /*idempotentKey*/ e.getEventKey());
// 4) 标记 DONE
fulfillmentDao.casUpdateStatus(f.getId(), "DOING", "DONE", f.getVersion() + 1);
}
```
这里 couponClient 也最好支持幂等键,否则你只能在你这边补偿。
---
## 8. 业务场景:一笔订单走完要经过多少次“至少一次”?
把链路串起来你会发现:
1) 支付回调(可能至少一次)
2) 订单服务写 outbox(一次,但投递可能至少一次)
3) MQ 投递到履约服务(至少一次)
4) 履约服务调用第三方发券(第三方也可能至少一次)
5) 履约完成事件写 outbox,再投递给账务(至少一次)
也就是说:
> **“至少一次”不是一个点,它是一片草原。**
你如果只在某一处做幂等,会发现系统还是会在别处复发。
所以工程策略是:
- 每个边界都要有幂等
- 每个动作都要有状态
- 每条链路都要可解释
---
## 9. 常见坑:你以为做了 Outbox,其实只是“又写了一张表”
### 9.1 Outbox 事件丢了:投递器挂了没人管
解决:
- outbox 投递器本身要监控:积压量、最老事件延迟、失败率
- `DEAD` 状态要报警
### 9.2 Outbox 表无限增长
解决:
- `SENT` 事件可以归档或按时间分区
- 保留 7~30 天游标足够追溯
### 9.3 消费者用 Redis set 做去重
Redis 去重不是不行,但注意:
- Redis 宕机会导致去重失效
- 过期时间设置不当会导致“过期后重复处理”
交易系统关键链路更推荐 DB inbox。
### 9.4 幂等键选错了
例如只用 `orderId` 作为幂等键,结果:
- `OrderPaid` 和 `OrderCancelled` 冲突
正确做法:
- `eventType + aggregateId + version`
---
## 10. 本文小结:让系统“像一次”,靠的是纪律
这一篇的结论可以压缩成四句话:
1) 分布式世界里“至少一次”是常态,不是例外。
2) Outbox 解决“写库成功但消息没发”的撕裂。
3) Inbox(消费去重)+ 状态机解决“重复消费导致重复执行”。
4) 监控与治理让你知道系统什么时候开始“偷偷撒野”。
下一篇(第 3 篇)我们会继续沿着交易主线写:
> **库存热点与秒杀:从预扣到一致性回补(以及为什么你不该迷信分布式锁)。**
最后送一句你未来会感谢我的话:
> **系统的可靠性不是靠“相信”,是靠“默认会失败,然后把失败关进笼子”。**
---
## 11. 进阶:你真的需要“恰好一次”吗?(以及为什么它经常是营销词)
有些中间件会宣传“Exactly Once”,但你要非常谨慎地理解它的边界条件。通常它指的是:
- 在 **某个 producer** 与 **某个 broker** 的协议范围内,借助事务/幂等 producer 达成“不会重复写入同一 partition”的语义;
- 或者在 **stream processing** 场景里,借助 checkpoint + 事务写出实现“端到端处理一次”。
听起来很美,但交易系统落地时你会遇到三个现实:
1) 你有外部系统:支付回调、第三方发券、物流——它们不参加你的“exactly once 协议”。
2) 你有多种存储:MySQL、Redis、ES、对象存储——它们不会一起提交/回滚。
3) 你有业务副作用:发券、扣库存、发短信——这些动作做了就是做了,回滚只能靠补偿。
所以在大多数交易系统里,我们追求的是:
> **在业务结果层面“像一次”(Exactly-once-like),而不是在技术协议层面“恰好一次”。**
这也解释了为什么 inbox/outbox 这种“土味工程”会长青:它不是酷,它是可控。
---
## 12. 进阶:顺序性(Ordering)——比你想象的更难
当你开始做事件驱动,你很快会遇到“顺序”问题:
- `OrderPaid` 先到,`OrderCreated` 后到(极端场景)
- 同一订单的多个事件并发投递,消费者并行消费
- 你为了吞吐开了 32 个 consumer 线程,顺序就从此与您无缘
解决顺序性有三种常见路线:
### 12.1 用 partition key 保序(推荐用于同一聚合根)
把 `orderId` 作为消息 key,让同一订单的事件落到同一 partition,然后消费者按 partition 串行处理。
优点:简单,符合“聚合根串行”的直觉。
缺点:热点订单会形成热点 partition(一般可接受,热点订单本来就麻烦)。
### 12.2 业务层用版本号兜底(强烈推荐)
事件里带 `version`(或状态机版本),消费者只接受“下一跳版本”。
例如订单版本从 7 到 8,你只处理 version=8 的事件;如果来了 version=10,你先记录但不推进,等缺的版本补齐再处理(或者触发回查/补偿)。
这套方案的核心收益是:
> **把“顺序问题”从消息系统迁回到业务状态机,变成你可解释、可治理的问题。**
### 12.3 彻底不依赖顺序:只做幂等 + 最终一致
也就是“顺序来不来无所谓,我都能收敛到正确状态”。
这最理想,但需要:
- 事件表达足够“最终态”(例如携带完整支付结果,而不是“加一”式增量)
- 状态机足够强
- 补偿机制完善
在简历站里你可以写成一句话:
> 我们把顺序当成优化项,而不是正确性前提。
面试官一般会点头,因为他也被顺序折磨过。
---
## 13. 进阶:死信与回放(DLQ & Replay)——让“偶发”可复现
真实世界里一定会有“怎么都处理不了”的消息:
- payload 缺字段
- 外部服务长期失败
- 数据脏了(最可怕的三字)
此时你需要两样东西:
1) **死信队列(DLQ)**:把失败消息隔离出来,不要阻塞主消费。
2) **可回放(Replay)**:把 DLQ 的消息在修复后重新投回去。
注意:回放会再次触发重复,所以幂等必须能扛住回放。
我喜欢把 DLQ 叫做“系统的垃圾桶”,但这不是贬义:
> 没有垃圾桶的系统,最后只能把垃圾丢在卧室里。
---
到这里,你已经具备了一个“能上线、能扛事、能写进简历”的事件驱动交易链路骨架:
- outbox 保证消息不丢
- inbox 保证重复不炸
- 状态机保证业务可收敛
- DLQ + replay 保证异常可治理
下一篇我们继续硬核:秒杀库存为什么总翻车,以及如何优雅地翻车。