ByFomo

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

2026/02/04
0
0

> 上一篇(第 1 篇)我们把“高并发交易平台”的骨架搭起来:**下单是一个业务事实**,支付是另一个业务事实;系统会在峰值流量下抖动、超时、重试、偶发失败——但业务不能跟着抖。

>

> 这一篇我们把“骨架”长出肌肉:**如何在分布式里把消息投递的“至少一次”变成业务侧“几乎恰好一次”**。

---

## 1. 事故复盘:一条消息,三次扣款(和四次背锅)

先从一个真实得让人想请假回家种地的场景说起。

我们的交易平台叫「星港交易」:用户下单后,订单服务要发一个“订单已创建”事件给下游:

- 库存服务:预占/扣减

- 风控服务:异步评分

- 营销服务:冻结优惠券

- 通知服务:发短信

一开始我们图省事:订单服务事务提交后,直接 `MQ.send(orderCreated)`。

然后灾难发生在某个周五晚上:

- 订单写库成功

- `MQ.send` 超时(网络抖了一下)

- 应用以为失败,于是重试

- 结果 MQ 其实已经收到了第一条(只是 ACK 丢了)

于是下游消费到两次“订单已创建”,库存扣两次、营销冻结两次……

同事说:“那就让下游去重啊。”

我说:“好。”

然后下游写了 `if (processed.contains(msgId)) return;`,processed 放在 Redis。

再然后另一个周五:

- Redis 热点 key 过期雪崩

- processed 集合丢了

- 消费者又处理了一遍

最终用户看到:

- 订单状态正常

- 优惠券被冻住了两次

- 库存被扣两次(库存负数)

- 客服工单像雨一样下

结论:**消息系统能做到的“恰好一次”很难,且贵;业务系统必须默认“至少一次”,并把幂等与一致性做在自己手里。**

---

## 2. 目标定义:我们到底要什么“一致性”?

在交易场景里,我们不是为了学术优雅,而是为了三个业务目标:

1) **订单创建成功后,事件一定会发出去**(最终一定发,不能丢)

2) **事件重复投递不会让业务重复生效**(幂等)

3) **可观测、可追踪、可修复**(出了问题能定位、能补偿)

所以这一篇的解法是两个零件:

- **Transactional Outbox**:保证“写库”与“发事件”原子地绑定(至少不丢)

- **消费者幂等 + 去重表/状态机**:保证“重复事件”不重复生效(不多扣)

把它们拼起来,就是一条在工程上很实用的链路:

> DB 事务提交 → Outbox 表落一条待投递事件 → 异步投递器扫描/推送 MQ → 消费者按业务幂等处理 → 处理结果写入幂等表/业务表 → ACK

---

## 3. Transactional Outbox:把“发消息”变成“写表”

### 3.1 为什么 Outbox 能解决“丢消息”?

因为数据库事务天生擅长一件事:**要么都成功,要么都失败**。

我们把“订单表插入”与“outbox 表插入”放在同一个本地事务里:

- 订单成功而 outbox 失败 → 事务回滚 → 订单也不成功(业务可重试)

- 订单成功且 outbox 成功 → 事务提交 → 事件至少在 outbox 里(不会丢)

这比“先写库后发 MQ”更稳,因为后者的失败窗口太大。

### 3.2 表结构建议:不是越复杂越好

```sql

CREATE TABLE tx_outbox (

id BIGINT PRIMARY KEY,

biz_type VARCHAR(64) NOT NULL, -- OrderCreated / PaymentSucceeded ...

biz_key VARCHAR(128) NOT NULL, -- 业务幂等键:orderId / paymentId

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 NOT NULL,

created_at DATETIME NOT NULL,

updated_at DATETIME NOT NULL,

UNIQUE KEY uk_biz (biz_type, biz_key)

);

```

几点经验(都是熬夜换来的):

- `biz_type + biz_key` 做唯一约束:**从源头防重复写 Outbox**(比如应用层重试导致重复插入)

- `status` 要有 `SENDING`:防止多实例扫描器并发捞同一条

- `next_retry_at`:退避重试,避免故障期打爆 MQ

- `payload` 建议存“业务最小必要信息”,不要把整个订单对象塞进去(大 JSON 会让 MySQL 哭)

### 3.3 写入 Outbox:跟订单写入同事务

```java

@Transactional

public CreateOrderResult createOrder(CreateOrderCommand cmd) {

Order order = orderFactory.newOrder(cmd);

orderRepository.insert(order);

OutboxEvent evt = OutboxEvent.orderCreated(order);

outboxRepository.insert(evt); // 同一事务

return new CreateOrderResult(order.getId());

}

```

这里最关键的不是代码,而是心态:**你必须接受 MQ 只是“异步传输工具”,而不是“可靠性救世主”。**

---

## 4. 投递器(Relay):扫描 + 发送 + 兜底

### 4.1 两种典型实现

1) **轮询扫描(Pull)**:定时任务扫描 `status=NEW && next_retry_at<=now()`

2) **CDC(binlog)**:Debezium/Canal 订阅 outbox 表变更,实时推送

简历项目里,轮询扫描足够;生产级别可以逐步演进到 CDC。

### 4.2 轮询扫描的并发控制:FOR UPDATE SKIP LOCKED

MySQL 8 可以用 `SKIP LOCKED` 避免多实例抢同一条:

```sql

SELECT *

FROM tx_outbox

WHERE status = 0

AND next_retry_at <= NOW()

ORDER BY id

LIMIT 100

FOR UPDATE SKIP LOCKED;

```

扫描器拿到记录后,先更新为 `SENDING`,再发 MQ:

```java

public void relayOnce() {

List<OutboxEvent> events = outboxRepository.lockBatch(100);

for (OutboxEvent e : events) {

try {

outboxRepository.markSending(e.getId());

mqProducer.send(e.topic(), e.key(), e.payload(), e.headers());

outboxRepository.markSent(e.getId());

} catch (Exception ex) {

outboxRepository.markRetry(e.getId(), nextBackoff(e.getRetryCount()));

}

}

}

```

**注意**:`markSending` 与 `lockBatch` 通常同一个事务里完成,避免竞争。

### 4.3 “发成功但标记失败”的幽灵问题

有一种很阴间的情况:

- MQ 发送成功

- 标记 `SENT` 更新失败(DB 瞬断)

结果:下一轮扫描又发一次 → 重复投递。

这就是为什么我们必须在消费者侧做幂等:**Outbox 只保证不丢,不保证不重复。**

---

## 5. 消费者幂等:别用“缓存记忆”,用“账本记忆”

### 5.1 幂等的本质:同一个业务事实,只能落一次

消息里应该携带一个稳定的业务幂等键:

- `eventId`:全局唯一(可由 outbox 的 id)

- `bizKey`:比如 `orderId`

消费者的幂等通常有三种做法:

1) **去重表(推荐)**:把“处理过”写进 DB(可持久化、可审计)

2) **业务表状态机**:利用订单状态、库存流水状态等天然幂等

3) **Redis 去重(不推荐做唯一手段)**:适合短期窗口去重,不适合交易最终一致

交易系统里,我更喜欢“去重表 + 业务流水”双保险。

### 5.2 去重表:用唯一键把重复挡在门口

```sql

CREATE TABLE consumer_dedup (

id BIGINT PRIMARY KEY,

consumer VARCHAR(64) NOT NULL,

event_id BIGINT NOT NULL,

biz_type VARCHAR(64) NOT NULL,

biz_key VARCHAR(128) NOT NULL,

processed_at DATETIME NOT NULL,

UNIQUE KEY uk_consumer_event (consumer, event_id),

UNIQUE KEY uk_consumer_biz (consumer, biz_type, biz_key)

);

```

处理逻辑:

- 先尝试插入去重表

- 插入成功 → 说明第一次处理 → 执行业务

- 插入失败(唯一键冲突)→ 说明处理过 → 直接 ACK

```java

@Transactional

public void onOrderCreated(OrderCreatedEvent evt) {

boolean first = dedupRepository.tryInsert(

"inventory", evt.getEventId(), "OrderCreated", evt.getOrderId()

);

if (!first) {

return; // 幂等命中

}

inventoryService.reserve(evt.getOrderId(), evt.getItems());

}

```

这里的关键是:**去重插入与业务写入同一个本地事务**。否则你会得到另一只幽灵:

- 去重插入成功

- 业务处理失败回滚

- 下一次重复消息来时,因为去重已存在,业务永远不再执行(你亲手把自己坑进“丢业务”)

所以要么:

- 去重表与业务更新同事务(推荐)

- 或者把去重表改成“处理状态”,失败可重试(更复杂)

### 5.3 用“业务流水”实现幂等:库存预占示例

库存服务通常会有一张流水表(ledger),天然就适合幂等:

```sql

CREATE TABLE inv_reservation (

id BIGINT PRIMARY KEY,

order_id BIGINT NOT NULL,

sku_id BIGINT NOT NULL,

qty INT NOT NULL,

status TINYINT NOT NULL, -- 0 INIT, 1 RESERVED, 2 RELEASED

UNIQUE KEY uk_order_sku (order_id, sku_id)

);

```

当重复消息进来,`uk_order_sku` 直接告诉你:

- 已经 RESERVED → 不重复扣

- INIT → 继续处理

工程上,**能用业务状态机幂等,就别另起一张“我处理过了表”**;但交易链路长、服务多时,去重表更通用。

---

## 6. 从“至少一次”到“几乎恰好一次”:把边界讲清楚

很多人面试时会说:“我们做到了 exactly-once。”

我一般会追问一句:“你说的是 MQ 的 exactly-once,还是业务语义的 exactly-once?”

在我们这个方案里:

- MQ 投递语义:**至少一次**

- 业务语义:**幂等 + 状态机** → 对用户表现为“几乎恰好一次”

为什么叫“几乎”?

- 因为极端情况下(DB 主从切换、回滚边界、运维误操作)仍可能出现人工修复

- 但我们把概率压到足够低,并且把修复成本压到可控

这就是工程:不是追求神话,而是追求可运营。

---

## 7. 监控与告警:Outbox 不是写了就完事

Outbox 最怕两件事:

1) 事件堆积(relay 挂了、MQ 不通)

2) 死信增长(消费端长期失败)

建议的指标:

- outbox NEW 数量、最大滞留时间

- relay 每分钟发送成功/失败数、重试次数分布

- dead 状态数量

再补一个很务实的 SQL:

```sql

SELECT COUNT(*) AS backlog,

MAX(TIMESTAMPDIFF(SECOND, created_at, NOW())) AS max_delay_sec

FROM tx_outbox

WHERE status IN (0, 1);

```

当 `max_delay_sec` 超过阈值(比如 60s),你就该去看 relay 了。

---

## 8. 让故事继续:下一篇我们会遇到“库存热点”

在第 1 篇,我们把“下单 → 支付 → 发货”串起来;

在第 2 篇,我们把“事件不丢 + 重复不怕”打牢。

接下来(第 3 篇)会更刺激:**秒杀/闪购的库存热点**。

- 一个 SKU 的库存被 10 万人同时抢

- MySQL 一行库存记录被打成“热铁”

- 你以为加索引能救你?索引只会更烫

我们会聊:预扣减、库存分片、异步修复、最终一致对账。

---

## 9. 小剧场:当你以为“重试”是温柔的

周五晚上 23:40,报警响了。

产品同学问:“怎么又重复扣券了?”

我说:“网络抖了一下。”

产品说:“那你们别抖。”

我沉默三秒,回了一句:

> “我们不抖,我们只是会重试。真正抖的是世界。”

后来我把这句话写进了 Outbox 的 README 里——

不是为了装文艺,是为了提醒每个写 `retry()` 的人:**重试不是补救,它是放大器。**