ByFomo

架构实战|Outbox + 消费幂等:把“至少一次”变成“像一次”(连载第 2 篇)

2026/02/04
3
0

# 架构实战|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 保证异常可治理

下一篇我们继续硬核:秒杀库存为什么总翻车,以及如何优雅地翻车。