> 上一篇(第 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()` 的人:**重试不是补救,它是放大器。**