ByFomo

架构实战|Outbox + 消费幂等:把“至少一次”驯化成“几乎恰好一次”(连载第 11 篇)

2026/02/04
0
0

> 连载背景:我们在做一个高并发交易平台(下单、支付、优惠、库存、发货、账务),上一集把“订单状态机 + 超时关单”梳顺了,但你很快会发现:**状态机的每一次跳转,背后都要通知一堆人**(发券、扣库存、发MQ、写账本、触发风控……)。

>

> 而现实的消息系统通常是“至少一次”(at-least-once)投递:**你不丢消息,但你可能重复**。于是问题来了:

>

> - 订单已支付 → 你发了一条“PAY_SUCCESS”事件。

> - MQ 稳稳当当给你投递了 2 次。

> - 下游发货系统很认真,发了 2 次货。

>

> 恭喜你:你不是在做交易平台,你是在做慈善。

# 1. 为什么“至少一次”在交易链路里是个坑

交易链路天然是“副作用”密集区:扣库存、发券、记账、通知、积分、埋点……

如果你把“消息投递成功”当作“业务成功”的一部分,就会遇到三类经典事故:

1) **DB 成功、消息失败**:订单状态变成已支付,但没通知到库存/发货。

2) **消息成功、DB 失败**:消息发出去了,DB 回滚了,下游看见“已支付”,上游却还是“待支付”。

3) **消息重复**:你重试、MQ 重投、消费者重平衡……重复消息是常态,不是异常。

你看,这就像你在公司群里说“我已经把线上问题修好了”,然后你撤回了,但群里已经有人截图了。

# 2. 目标:把“至少一次”驯化成“几乎恰好一次”

严格意义的 exactly-once 需要端到端协议支持(事务消息、幂等写、状态对齐),成本很高。

我们工程上常见的、足够实用的目标是:

- **生产端:不丢、不乱、可追溯**(Outbox/CDC/可靠投递)

- **消费端:可重复消费**(幂等、去重、可回放)

结果是:在业务层面做到“exactly-once-like”(看起来像恰好一次)。

# 3. 设计一:Transactional Outbox(事务外盒)

## 3.1 思想:把“发消息”变成一次普通的 DB 写入

不要在业务事务里直接发 MQ。

在订单服务里,支付回调落库时,同一事务里再写一条 outbox 记录:

- 订单表(orders)更新状态:PAID

- 事件表(outbox_event)插入一条“OrderPaid”事件

然后由后台异步投递器(Relay)把 outbox_event 可靠地发到 MQ。

这样你就把问题 1、2 统一成了一个:**只要 DB 事务提交成功,事件一定存在**。

## 3.2 表结构(建议从一开始就想清楚字段)

```sql

CREATE TABLE outbox_event (

id BIGINT PRIMARY KEY,

aggregate_type VARCHAR(64) NOT NULL, -- Order/Payment/Inventory

aggregate_id VARCHAR(64) NOT NULL, -- 订单号

event_type VARCHAR(64) NOT NULL, -- OrderPaid

event_key VARCHAR(128) NOT NULL, -- 幂等键(建议业务可读)

payload JSON NOT NULL,

headers JSON NULL,

status TINYINT NOT NULL, -- 0=NEW,1=SENDING,2=SENT,3=DEAD

retry_count INT NOT NULL DEFAULT 0,

next_retry_at DATETIME 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 我喜欢用:`{eventType}:{bizId}:{version}`

例如:`OrderPaid:20260204A00001:v1`

**要点:**

- unique 约束能挡住同一业务事件重复插入(生产端幂等第一道门)。

- status + next_retry_at 支持重试调度。

## 3.3 生产端代码(Spring + 事务)

```java

@Transactional

public void onPayCallback(PayCallback cmd) {

Order order = orderRepo.lockByOrderNo(cmd.orderNo());

// 1) 订单状态机校验(上一集的老朋友)

order.pay(cmd.payTime(), cmd.tradeNo());

orderRepo.save(order);

// 2) 写 Outbox

OutboxEvent evt = OutboxEvent.builder()

.id(idGen.nextId())

.aggregateType("Order")

.aggregateId(order.getOrderNo())

.eventType("OrderPaid")

.eventKey("OrderPaid:" + order.getOrderNo() + ":v1")

.payload(Map.of(

"orderNo", order.getOrderNo(),

"userId", order.getUserId(),

"amount", order.getPayAmount(),

"payTime", cmd.payTime(),

"tradeNo", cmd.tradeNo()

))

.status(0)

.createdAt(LocalDateTime.now())

.updatedAt(LocalDateTime.now())

.build();

outboxRepo.insert(evt);

}

```

注意:这里的 lockByOrderNo 建议 `SELECT ... FOR UPDATE`,否则支付回调并发你会被教育。

# 4. Relay 投递器:别用“while(true) + sleep(1)”吓唬后端

投递器有三种常见实现:

1) **定时扫描表**:最简单,适合早期。

2) **CDC(Debezium)监听 binlog**:延迟更低,吞吐更高。

3) **DB 触发器/NOTIFY**:不建议,维护成本高。

我们从定时扫描做起(可演进,别一上来就 Debezium 伤到团队)。

## 4.1 扫描 + 批量发送(要“抢锁”避免多实例重复发送)

```sql

-- 伪代码:挑一批 NEW 的事件,并标记为 SENDING

UPDATE outbox_event

SET status = 1, updated_at = NOW()

WHERE id IN (

SELECT id FROM (

SELECT id

FROM outbox_event

WHERE status = 0

AND (next_retry_at IS NULL OR next_retry_at <= NOW())

ORDER BY created_at

LIMIT 200

FOR UPDATE SKIP LOCKED

) t

);

```

`SKIP LOCKED` 是好东西:多实例并行投递,不会互相打架。

## 4.2 发送成功后标记 SENT

```java

public void relayOnce() {

List<OutboxEvent> batch = outboxRepo.pickForSending(200);

for (OutboxEvent e : batch) {

try {

mq.send("order.events", e.getEventType(), e.getEventKey(), e.getPayload());

outboxRepo.markSent(e.getId());

} catch (Exception ex) {

outboxRepo.backoff(e.getId()); // retry_count++ & next_retry_at

}

}

}

```

**关键:**

- MQ message key 用 event_key(消费端就有天然幂等锚点)。

- backoff 一定要指数退避,不然事故现场就是“你重试,我也重试,大家一起重试”。

# 5. 消费端幂等:别跟重复消息讲道理,给它上规矩

消费端幂等的目标:**同一 event_key,只生效一次**。

我通常会用“幂等表 + 唯一键”作为最后防线:

```sql

CREATE TABLE consumer_dedup (

id BIGINT PRIMARY KEY,

consumer VARCHAR(64) NOT NULL,

event_key VARCHAR(128) NOT NULL,

processed_at DATETIME NOT NULL,

result_code VARCHAR(32) NOT NULL,

UNIQUE KEY uk_consumer_event (consumer, event_key)

);

```

## 5.1 Java 消费逻辑(正确姿势:先占坑,再干活)

```java

public void onMessage(OrderPaidEvent evt) {

String consumer = "shipping-service";

String key = evt.eventKey();

// 1) 先用唯一键占坑:插入成功才继续

boolean acquired = dedupRepo.tryInsert(consumer, key);

if (!acquired) {

// 重复消息:直接 ack

return;

}

// 2) 再执行业务副作用(发货、扣减、通知)

shippingApp.createShipment(evt.orderNo(), evt.userId());

// 3) 可选:记录处理结果,便于排障/回放

dedupRepo.markSuccess(consumer, key);

}

```

`tryInsert` 典型实现:

```sql

INSERT INTO consumer_dedup(id, consumer, event_key, processed_at, result_code)

VALUES(?, ?, ?, NOW(), 'INIT');

```

捕获唯一键冲突即可。

**为什么是“先占坑再干活”?**

因为你永远不知道:

- 业务执行到一半,进程被 K8s 赶下线

- 下游超时,你重试

- MQ 认为你没 ack,又投了一次

先占坑可以把“重复执行”变成“重复发现”。

## 5.2 幂等粒度怎么选:按事件?按业务动作?

经验:

- **事件幂等**:最简单,一个 event_key 对应一次处理。

- **动作幂等**:更灵活,例如发货、发券分别幂等。

交易平台里,我倾向于 **动作幂等**(因为同一事件可能驱动多个动作,且动作可能拆服务)。

但连载里我们先从事件幂等讲清楚,再逐步演进。

# 6. 这套方案的坑(以及我们如何不踩)

## 6.1 “我有 Outbox 了,为什么还是会乱?”

常见原因:

- 事件投递顺序乱(多分区、不同 topic)

- 消费端并发处理导致顺序乱

解决思路:

- 同一 aggregate(订单号)尽量走同一分区(key=orderNo)。

- 消费端按 key 串行(或者保证同 key 并发为 1)。

## 6.2 “幂等表会不会爆?”

会。

你得把它当作日志来治理:

- TTL/按月分表

- 只存必要字段

- 处理结果存一段时间即可(比如 7~30 天)

另外,幂等表不是审计表。审计要进“账本/流水”,别混。

## 6.3 Poison Message(毒消息)怎么处理?

毒消息通常来自:

- payload 结构变更

- 下游 bug

- 数据异常(比如 orderNo 不存在)

策略:

- 重试 N 次进入 DEAD

- 投递到 DLQ(死信队列)

- 自动告警 + 人工介入 + 支持“修复后回放”

别把“死信”当作“死了就算了”,交易系统里它们是“尸检报告”。

# 7. 与上一集的衔接:状态机 + Outbox 是一对 CP

上一集我们说:订单状态机要“可验证、可重放、可兜底”。

这一集补齐:

- 状态机把 DB 里的状态变成“确定”

- Outbox 把状态变化变成“可传播的事实”

- 消费幂等把传播的不确定性(重复/重试)消化掉

合起来,你的系统才不会在支付回调里变成“薛定谔的分布式事务”。

# 8. 小结(以及下一集预告)

这一集你应该记住三句话:

1) **别在业务事务里直接发 MQ**(除非你真的掌控事务消息)。

2) **Outbox 让“DB 成功”和“事件存在”强绑定**。

3) **消费端幂等不是优化项,是生存项**。

下一集我们会继续推进交易平台的“性能事故史”:

**库存热点 & 秒杀:预扣、排队、最终一致修复**。

最后送你一个真实段子收尾:

> 我们曾经上线一个“用户支付成功送优惠券”的功能。

> 因为没有消费幂等,某个用户在网络抖动里被送了 18 张券。

> 运营问我:这是 Bug 还是活动?

> 我说:看 KPI。