背景
我带团队做订单-支付-库存这条链路时,被问得最多的一句话就是:
"都 at-least-once 了,再加幂等不就行了吗?为什么还要上事务消息 / Outbox 表?"
这话听起来没毛病,也确实是我们早期 90% 场景的做法。但去年一次「支付成功、库存扣减消息在 broker 持久化前宕机」的故障,让我开始认真质疑:at-least-once + 幂等消费,到底在哪些边界上会失守?
那次故障的真实数据(补充)
2025 年 11 月大促前压测后的一次 broker 滚动重启:
- Kafka 3.4,3 broker,acks=all,min.insync.replicas=2
- 故障窗口:14:32:07 ~ 14:32:11(约 4 秒),broker-2 在 flush 阶段 OOM 被 kill
- 丢失消息:
inventory-deducttopic 的 17 条库存扣减消息(已 ack 给 producer 但未落盘) - 影响:17 笔订单「支付成功、库存未扣」→ 超卖 23 件(因为 17 笔中 6 笔是同一 SKU)
- 发现时间:T+1 凌晨对账脚本报警,距故障 9.5 小时
- 恢复:人工补单 + 客户致歉,资损 0(退货率兜住了),但客诉 4 起
复盘后我们改了两个决策:
- 核心资金链路从「at-least-once + 幂等」切到 Outbox + Debezium CDC(轮询改 CDC 后 P99 延迟从 400ms+ 降到 45ms)
- 旁路通知类(短信、邮件)保留 at-least-once + 幂等,但加了一条 5 分钟窗口对账(以前是 T+1)
几种常见做法与分歧
| 做法 | 核心思路 | 典型适用 | |------|---------|---------| | 纯 at-least-once + 业务幂等 | 消费端用唯一键 / 状态机去重,允许重复投递 | 通知、日志、异步通知类"丢了不致命"的旁路 | | Outbox(本地事务 + 轮询/Debezium) | 业务写库和写 outbox 表在同一个本地事务,CDC 再发到 MQ | 订单-库存-账户等"不能丢"的核心链路 | | Kafka 事务 / RocketMQ 事务消息 | broker 侧保证 prepare-commit 原子性 | 需要跨 partition 原子写、或要求消费端 exactly-once 的流处理 | | TCC / Saga 补偿 | 不用 MQ,用编排 + 补偿回滚 | 跨服务、跨数据库、业务语义复杂到"一条消息说不清"的场景 |
我的分歧观察:
- "幂等"经常被高估。 业务幂等能防"重复执行",但防不了"漏执行"。at-least-once 保证至少投一次,但如果 producer 在 send() 返回前 crash,那条消息就永远丢了——幂等表帮不上忙,因为它压根没收到。
- Outbox 不是银弹。 它把"消息不丢"从 broker 侧挪到了本地事务侧,代价是 outbox 表的轮询延迟 + CDC 组件的运维复杂度。我们最初用轮询(500ms 间隔),P99 延迟直接多了 400ms+,业务方不接受。切到 Debezium 后 P99 稳定在 40-60ms(取决于 MySQL binlog 的 flush 节奏),但多了一个有状态组件要运维(offset 丢失 = 丢消息)。
- Kafka 事务消息的语义陷阱。 很多人以为开了
transactional.id就是 exactly-once,实际上它只保证"同一事务内的多 partition 写入是原子的",消费端仍要自己做幂等。"exactly-once"是个端到端概念,不是 broker 单方面能给的。 - (新增)幂等键的选择是隐蔽的坑。 我后来发现,很多团队的幂等表唯一键用的是
message_id(producer 生成的 UUID),而不是业务键。当 producer 在 retry 循环里重新生成 UUID 时(Kafka 的 transactional producer 在 abort 后重试就会这样),同一业务事件会有两个不同的 message_id → 幂等表全部放行 → 本地多写。权威幂等键必须是业务键(如tenant_id|order_id|event_type|version),message_id 只做 trace 用。
我的倾向与理由(修订版)
在核心资金链路上,我现在的判断标准是:
- 丢一条 = 资损 or 客诉(单条资损 > 50 元 或 涉及用户账户余额)→ 必须 Outbox 或事务消息,不能裸 at-least-once
- 丢一条 = 可以 T+1 对账补(通知类、日志类)→ at-least-once + 幂等 + 5 分钟窗口对账(不是 T+1,T+1 太慢)
- 需要跨 3+ 个下游原子生效 → 评估 Saga,别硬塞一条消息
- (新增)幂等表唯一键 = 业务键,不是 message_id——这是 review 红线
但我也承认,这个"够用 / 不够用"的边界,很大程度上取决于你的对账和补偿体系有多靠谱。如果每天凌晨跑全量对账、5 分钟内能自动补单,那 at-least-once 的"丢"其实被兜住了;如果对账只是"看看",那丢一条就是真丢。
想听大家聊聊
- 你们团队怎么定义"丢一条消息的代价"? 有没有量化的标准(比如"单条消息资损 > X 元就必须用强一致")?
- Outbox 的轮询延迟你们怎么解的? 纯 CDC(Debezium / Canaler)还是混合(关键消息走推送、旁路走轮询)?Debezium offset 丢失你们有兜底吗?
- Kafka 事务消息你们实际用了吗? 开了之后 consumer 侧的幂等逻辑有没有简化,还是照样要写?
- 有没有"当年觉得 at-least-once 够了,后来被一次故障打脸"的经历? 想听故事 🙂
- (新增)你们的幂等表唯一键是 message_id 还是业务键? 如果 producer 重试会重新生成 message_id,你们怎么处理的?
不预设标准答案,想看看不同业务规模(日单量 1k vs 1000w)下,大家实际怎么取舍的。