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