我用消息队列解耦订单处理,以为一条消息只会被消费一次,结果某条扣库存的消息被重复消费了两次,库存莫名其妙被多扣了一份:一次没为消息重复投递做幂等、误把至少一次当成恰好一次的深度复盘
那次库存对不上账,排查了整整一天。我用消息队列(MQ)解耦订单流程:订单服务下单后发一条消息,库存服务消费这条消息去扣减库存。我想当然地以为"一条消息发出去,就只会被消费一次",于是消费逻辑写得很直白:收到消息→直接扣库存。线上跑了一阵,对账时发现:有少量订单,库存被扣了两次——一个只买了 1 件的订单,库存却少了 2 件。我查了半天业务逻辑都没问题,直到翻消费日志才发现:那几条消息,确实被消费了两次——同一条消息、同一个 messageId,消费者处理了两遍。复盘 MQ 的投递语义,我才恍然大悟,后背发凉:问题出在我对消息队列投递保证的根本性误解。绝大多数消息队列(Kafka、RabbitMQ、RocketMQ 等)默认提供的是"至少一次(at-least-once)"投递保证,不是"恰好一次(exactly-once)";也就是说,MQ 保证"消息至少会被投递成功一次",但不保证"不会重复";而重复几乎必然会发生:消费者处理完消息、但在 ack(确认)之前就崩溃/重启/超时了,MQ 没收到 ack,就会把这条消息重新投递给别的消费者——于是这条消息被处理了两次;此外消费者组 rebalance(重平衡)、网络抖动、生产者重试,也都会造成重复投递;而我的消费逻辑没有做任何"幂等"处理——同一条扣库存的消息,来一次扣一次,来两次就扣两次。根本原因是:消息队列默认是"至少一次"投递、重复投递是常态而非异常;而我误以为是"恰好一次",消费逻辑没做幂等,导致重复消费时库存被重复扣减。问题的根,是把消息队列的"至少一次"保证错当成了"恰好一次",没有为消息的重复投递设计消费幂等。这篇就把这次"消息重复消费"的坑,从头到尾复盘一遍。
故障现场:同一条消息,扣了两次库存
问题在于消费逻辑没做幂等,而 MQ 会重复投递:
// 我的库存服务消费者(想当然地以为消息只来一次):
@KafkaListener(topics = "order-created")
public void onOrderCreated(OrderMessage msg) {
// ↓↓↓ 直接扣库存, 没有任何防重复处理 ↓↓↓
inventoryService.deduct(msg.getSkuId(), msg.getQty()); // ✗ 来一次扣一次, 重复消费=重复扣!
log.info("已扣减库存 sku={} qty={}", msg.getSkuId(), msg.getQty());
}
/*
库存被多扣是怎么发生的:
1. MQ 把"订单123扣1件"的消息投给消费者A;
2. 消费者A 执行 deduct() 成功(库存-1), 但在向MQ发送ack之前, 实例崩溃/重启/超时了;
3. MQ 没收到ack → 认为"这条消息没消费成功" → 重新投递(投给消费者B 或 重启后的A);
4. 消费者B 又执行了一遍 deduct() (库存又-1) → 同一订单, 库存被扣了2次!
为什么会重复(MQ的投递语义):
- at-most-once (至多一次): 可能丢消息, 不会重复 (发了就不管, 适合可容忍丢失的场景);
- at-least-once (至少一次): 不丢消息, 但可能重复 (绝大多数MQ默认, 也是最常用) ← 我踩的;
- exactly-once (恰好一次): 不丢不重 (理想, 但代价高、有很多前提限制, 不能想当然依赖);
→ 默认的at-least-once下, 重复投递是"必然会发生的常态", 不是"偶发的异常"!
触发重复的常见原因(都很常见, 躲不掉):
- 消费成功但ack前崩溃/超时(最典型);
- 消费者组rebalance(扩缩容、心跳超时), 分区重新分配时未提交的消息被重投;
- 生产者发送超时重试(其实下游已收到), 导致消息本身就发了两条;
- 网络抖动导致ack丢失。
★ 关键: MQ默认"至少一次", 重复是常态; 消费者必须自己做"幂等"——
即"同一条消息处理一次和处理多次, 结果相同"; 否则重复消费就会导致重复扣款/下单/扣库存。
*/
看着日志里同一个 messageId 出现两次、库存对应少了两份,我又懊恼又后怕:"我一直以为消息发出去就消费一次,压根没想过它会重投……更没想过这是 MQ 的'正常行为'而不是 bug。要是这是扣款,就是真金白银的重复扣了!"这个坑最隐蔽的地方在于:它平时不发生(消费者不崩溃、不 rebalance 时,消息确实只消费一次),给你"消息只来一次"的错觉;只有在崩溃、重启、扩缩容、网络抖动这些"偶发但必然会有"的时刻才重复;而且数据出错(多扣)是悄悄发生的,不报错、不告警,往往要等对账才发现,危害极大。下面就来拆解,消费端到底该怎么做幂等。
第一件事:搞懂投递语义与消费幂等
我顺着这次事故,把消息队列的投递保证和幂等设计彻底理清了。
消息队列为什么会重复投递? 消费端怎么做幂等?
【核心: MQ默认"至少一次"投递、重复是常态(消费成功未ack就崩溃/rebalance/重试都会重投);
消费者必须做幂等(同一消息处理一次和多次结果相同)——用唯一业务ID去重/状态机/唯一约束/乐观锁】
1. 三种投递语义(先认清你用的是哪种):
- at-most-once: 不重复但可能丢(很少用);
- at-least-once: 不丢但可能重复(绝大多数MQ默认, 必须自己防重) ← 最常见;
- exactly-once: 不丢不重, 但需要MQ+消费端配合(如事务、幂等写), 代价高、有前提, 别想当然依赖。
2. 为什么at-least-once会重复(机制决定, 躲不掉):
- "处理成功"和"ack成功"不是原子的, 中间崩溃 → MQ重投;
- rebalance、重试、网络抖动 → 重投; 这些是分布式系统的常态。
3. 幂等的定义: 同一个操作执行一次和执行多次, 对系统的影响相同
- 扣库存不幂等(扣两次少两份); 但"把订单状态设为已支付"是幂等的(设两次还是已支付);
- 目标: 让消费逻辑无论这条消息来几次, 最终结果都和来一次一样。
4. 做幂等的几种常用手段:
① 唯一业务ID + 去重表: 每条消息带全局唯一ID(messageId或业务幂等键), 处理前先查/插一条
去重记录(用数据库唯一约束或Redis SETNX), 已处理过就直接跳过;
② 数据库唯一约束: 给业务表加唯一索引(如订单号唯一), 重复插入直接失败, 天然防重;
③ 状态机: 操作前检查当前状态, 只在"合法的前置状态"才执行(如只有"待支付"才能转"已支付");
④ 乐观锁/版本号: 带version更新, 重复的旧version更新会失败;
⑤ 把操作设计成天然幂等: 如"设置为X"(幂等) 而非 "增加X"(不幂等)。
5. 还要注意:
- 去重判断和业务操作要尽量原子(否则判重和执行之间崩溃仍可能重复), 用事务或唯一约束兜底;
- 不只MQ, HTTP重试、定时任务、回调通知, 都可能重复, 都要幂等;
- 幂等键要选对(能唯一标识"同一个业务操作", 而非每次请求都不同的随机ID)。
一句话: MQ默认at-least-once、重复投递是常态; 消费端必须做幂等(唯一业务ID去重/唯一约束/状态机/
乐观锁), 让消息处理一次和多次结果相同; 别把"至少一次"当"恰好一次", 别假设消息只来一次。
这套认知,是整个坑的根。三种投递语义:at-least-once(不丢但可能重复)是绝大多数 MQ 的默认,必须自己防重;exactly-once 代价高有前提、别想当然依赖。为什么会重复:"处理成功"和"ack 成功"不原子、中间崩溃就重投,rebalance/重试/网络抖动也会重投,是分布式常态。幂等的定义:同一操作执行一次和多次影响相同——"扣库存"不幂等、"设状态为已支付"幂等。做幂等的手段:唯一业务 ID + 去重表、数据库唯一约束、状态机、乐观锁/版本号、把操作设计成天然幂等。一句话:MQ 默认 at-least-once、重复投递是常态;消费端必须做幂等(唯一业务 ID 去重/唯一约束/状态机/乐观锁),让消息处理一次和多次结果相同;别把"至少一次"当"恰好一次",别假设消息只来一次。
第二件事:正解——用唯一业务键 + 去重/唯一约束做幂等
知道了重复是常态,正解就清楚了:给消费逻辑加上幂等保护。
// 正解1: 唯一业务键 + 去重表(用数据库唯一约束保证原子性, 最稳)
@KafkaListener(topics = "order-created")
@Transactional
public void onOrderCreated(OrderMessage msg) {
String idempotentKey = msg.getOrderId() + ":deduct"; // 幂等键: 标识"这个订单的扣库存操作"
try {
// 先插一条去重记录, 表上 idempotent_key 有唯一索引
dedupMapper.insert(new Dedup(idempotentKey));
} catch (DuplicateKeyException e) {
// 插入失败 = 这条消息已处理过 → 直接跳过, 不再扣库存
log.info("消息已处理过, 跳过: {}", idempotentKey);
return;
}
// 插入成功 = 首次处理 → 执行扣库存(和上面的插入在同一事务, 保证原子)
inventoryService.deduct(msg.getSkuId(), msg.getQty());
}
// 正解2: 数据库唯一约束兜底(业务表本身防重)
// 订单表 order_no 加唯一索引: 重复消息想再创建同一订单 → 唯一约束冲突 → 天然防重。
// ALTER TABLE orders ADD UNIQUE KEY uk_order_no (order_no);
// 正解3: 状态机(只在合法前置状态才执行)
public void pay(String orderId) {
Order o = orderMapper.selectForUpdate(orderId); // 加锁查
if (o.getStatus() != Status.WAIT_PAY) { // 只有"待支付"才能转"已支付"
return; // 已经是"已支付"了(重复消息) → 直接跳过, 幂等
}
o.setStatus(Status.PAID);
orderMapper.update(o);
}
// 正解4: 乐观锁/版本号(并发+重复双保险)
// UPDATE inventory SET qty=qty-1, version=version+1 WHERE sku=? AND version=?
// 重复消息拿的是旧version → 更新影响0行 → 不会重复扣。
// 正解5: 用 Redis 做轻量去重(高频、可容忍极小概率)
// SET idempotentKey 1 NX EX 86400 → 返回成功才处理, 已存在则跳过;
// 注意: Redis去重不如DB唯一约束强一致(极端情况可能失效), 关键业务用DB兜底。
// 核心: 消费端必须幂等; 首选"唯一业务键 + DB唯一约束(去重表或业务表), 且与业务操作同事务";
// 配合状态机、乐观锁; 幂等键要能唯一标识"同一个业务操作"。
这套正解的关键,是给每个"不幂等的操作"套上一层"同一操作只生效一次"的保护。唯一业务键 + 去重表:用能唯一标识"这个业务操作"的幂等键,处理前先插一条带唯一索引的去重记录、插失败就说明处理过、跳过——且与业务操作放同一事务保证原子,这是最稳的做法。数据库唯一约束:给业务表(如订单号)加唯一索引,重复插入直接冲突,天然防重。状态机:只在合法前置状态才执行,重复消息因状态已变而跳过。乐观锁/版本号:重复消息拿旧 version 更新影响 0 行。核心是:幂等键要能唯一标识"同一个业务操作",去重判断与业务操作尽量原子(用事务/唯一约束兜底)。
第三件事:其他几个"假设了理想情况"的分布式坑
顺着这次重复消费,我把"想当然假设分布式系统是理想的"的几类坑也一并理了:
几类"误把理想当保证"的分布式坑:
坑1: 假设消息"不丢"——某些配置下(如生产者不等ack、消费自动提交offset)消息会丢;
正解: 要可靠就开生产者确认+持久化+手动提交offset, 想清你需要的可靠级别。
坑2: 假设消息"有序"——多分区/多消费者下消息可能乱序到达;
正解: 需要有序就用单分区或按key路由到同一分区, 别假设全局有序。
坑3: 假设"调用一定成功或一定失败"——网络超时下, 你不知道对方到底执行没执行(状态未知);
正解: 超时不等于失败; 配合幂等+查询确认, 别盲目重试或盲目放弃(同583重试)。
坑4: 假设"分布式锁绝对可靠"——锁可能因GC/网络/时钟问题被两个节点同时持有(同562);
正解: 锁要加超时、用fencing token、关键操作仍靠幂等/唯一约束兜底。
坑5: 假设"读到的就是最新的"——主从延迟下从库可能读到旧数据(同346);
正解: 强一致读走主库, 或接受最终一致并设计容忍。
坑6: 假设"时间是单调、各机器一致的"——分布式下时钟会漂移、回拨;
正解: 别用本地时间戳做强依赖的排序/唯一性, 用逻辑时钟/数据库自增/雪花算法。
共同的根: 分布式系统里, 很多我们"默认成立的理想假设"(不丢、不重、有序、成功或失败、
锁可靠、读到最新、时钟一致)其实都不成立或只在特定配置下成立; 要认清系统"实际提供的保证
(往往比你以为的弱)", 并为"重复、乱序、丢失、超时、不一致"这些常态显式设计。
这些坑看似分散,根却是同一个:我们在单机、理想环境里养成的"默认假设"(消息不丢不重、调用非成即败、读到的是最新的、时钟一致),在分布式系统里大多不成立;而系统实际提供的保证,往往比我们以为的弱。认清这个根("认清系统实际的保证、为非理想的常态显式设计"),才能写出真正健壮的分布式代码。
第四件事:投递语义与幂等手段——两张对照表
我把三种投递语义、以及几种幂等手段,整理成对照表,贴在了团队的 MQ 使用规范里:
| 投递语义 | 会丢吗 | 会重吗 | 适用 / 注意 |
|---|---|---|---|
| at-most-once 至多一次 | 可能丢 | 不重复 | 可容忍丢失(如日志埋点) |
| at-least-once 至少一次 | 不丢 | 可能重复 | 绝大多数默认,消费端必须幂等 |
| exactly-once 恰好一次 | 不丢 | 不重复 | 代价高有前提,别想当然依赖 |
| 幂等手段 | 原理 | 适用场景 |
|---|---|---|
| 唯一业务键 + 去重表 | 插去重记录,唯一约束防重 | 通用,首选,与业务同事务 |
| 数据库唯一约束 | 重复插入直接冲突失败 | 有天然唯一键(订单号) |
| 状态机 | 只在合法前置状态执行 | 有明确状态流转(支付) |
| 乐观锁 / 版本号 | 旧版本更新影响 0 行 | 更新类操作,兼防并发 |
| 天然幂等设计 | "设为 X"而非"加 X" | 能改写成幂等的操作 |
| Redis SETNX 去重 | 键存在则跳过 | 高频、可容忍极小概率失效 |
这两张表的核心,第一张是认清你用的 MQ 是哪种语义——几乎一定是 at-least-once,意味着重复必然发生、消费端必须幂等;第二张是幂等的多种手段中,"唯一业务键 + 数据库唯一约束 + 同事务"是最稳的首选,其他按场景选用。记住一条:用 MQ(以及任何可能重复的调用),"消费端幂等"不是可选项,而是必选项。
第五件事:关于消息队列的几组容易想当然的认知
这次事故也让我厘清了几组关于 MQ 的、容易想当然的概念:
| 直觉以为 | 实际上 |
|---|---|
| 一条消息只会被消费一次 | 默认至少一次,崩溃/重平衡会重复投递 |
| 重复消费是 MQ 的 bug | 是 at-least-once 的正常行为,要自己防 |
| 消费成功了就不会再来 | 成功但 ack 前崩溃,照样会重投 |
| 开了 exactly-once 就高枕无忧 | 有诸多前提限制,且常只覆盖部分链路 |
| 消息一定按发送顺序到达 | 多分区/多消费者下可能乱序 |
| 幂等就是加个去重判断 | 判重与业务操作要原子,否则仍可能重复 |
| 只有 MQ 才需要担心重复 | HTTP 重试、回调、定时任务都可能重复 |
这张表里,我栽的是第一行和第二行:把"消息只消费一次"当成了理所当然,甚至一度以为重复消费是 MQ 出了 bug,完全不知道这是 at-least-once 语义下的正常、必然现象。厘清这些,核心是一个意识:消息队列(和分布式系统的大多数组件)提供的保证,往往比直觉以为的要弱;要去查清、认准它"实际承诺的语义",并为这个真实(而非理想)的语义做设计。
第六件事:用消息队列 / 设计消费逻辑时,我现在的自检习惯
现在每当我要消费一条消息、或设计任何"可能被重复触发"的逻辑,我都会先按这张图问自己:
这张图的精髓,是"有害的操作必须幂等、认清 at-least-once、用唯一业务键去重"。先问多执行一次有害吗、再认MQ 是至少一次、然后用唯一业务键 + 去重/唯一约束做幂等、并发再加乐观锁/状态机。这套习惯,让我从"消息只来一次,直接处理"变成了"消息会重复,先保证幂等"——核心始终是:MQ 默认至少一次、重复投递是常态;消费端必须做幂等,用唯一业务键去重/唯一约束/状态机/乐观锁,让消息处理一次和多次结果相同;别把至少一次当恰好一次。
我立下的几条规矩
这场"消息重复消费、库存被多扣"的事故,换来了我做消息驱动设计时,刻进骨子里的几条铁律:
- 消息队列默认是"至少一次"投递,重复投递是常态、不是 bug,绝不能假设消息只来一次。
- 消费成功但 ack 前崩溃、消费者 rebalance、生产者重试、网络抖动,都会导致重复投递。
- 消费端必须做幂等:同一条消息处理一次和多次,结果必须相同。
- 首选"唯一业务键 + 数据库唯一约束(去重表/业务表),且与业务操作同事务"。
- 配合状态机、乐观锁/版本号;幂等键要能唯一标识"同一个业务操作"。
- exactly-once 有前提和代价,别想当然依赖它而省掉幂等。
- 不只 MQ,HTTP 重试、回调通知、定时任务,凡可能重复触发的都要幂等。
附:一个通用幂等消费的封装
借这次的坑,我给团队封装了一个通用的"幂等消费"工具,把"先去重、再执行、同事务"的套路固化下来,新消费者直接套用,不必每次手写防重。
// 通用幂等消费封装: 传入幂等键和真正的业务逻辑, 自动处理去重
@Service
public class IdempotentConsumer {
@Autowired private DedupMapper dedupMapper;
/**
* 幂等执行: 同一个 idempotentKey 只会真正执行一次 action
* @param idempotentKey 唯一业务键(如 "order:123:deduct")
* @param action 真正的业务逻辑
*/
@Transactional
public void executeOnce(String idempotentKey, Runnable action) {
try {
dedupMapper.insert(new Dedup(idempotentKey)); // dedup表 key 有唯一索引
} catch (DuplicateKeyException e) {
log.info("幂等拦截, 已处理过: {}", idempotentKey);
return; // 重复消息, 直接跳过
}
action.run(); // 首次处理, 与insert同事务
}
}
// 业务消费者这样用, 简洁且不会忘记防重:
@KafkaListener(topics = "order-created")
public void onOrderCreated(OrderMessage msg) {
idempotentConsumer.executeOnce(
msg.getOrderId() + ":deduct",
() -> inventoryService.deduct(msg.getSkuId(), msg.getQty())
);
}
// 原则: 把"幂等"从"每个消费者各自记得手写"变成"框架/封装强制保证";
// 让"正确的做法"成为"最省事的默认做法", 比依赖每个人的自觉可靠得多。
这个封装本身不复杂,但它体现了一个原则:对于"容易忘、忘了就出事"的横切关注点(幂等、限流、重试、鉴权),与其依赖每个开发者"记得手写",不如把它沉淀成框架/封装/默认配置,让"做对"成为"最省事的路径"——这比反复强调"大家记得做幂等"有效得多。
写在最后
回头看,这场由"误把至少一次当恰好一次"引发的、库存被重复扣减的事故,真正教给我的,远不止"消费端要做幂等"这一个技巧。它让我对"我们在'简单、理想、可控的环境'里养成的那些'不言自明的默认假设', 一旦进入'复杂、真实、不可控的环境', 往往悄悄地不再成立; 而我们却常常浑然不觉地把它们带着走",有了一次刻骨的体会。我栽跟头,是因为我把一个在"单机、理想"世界里成立的假设("我让它做一次, 它就做一次"),不假思索地带进了"分布式、真实"的世界——在单机里, 我调一次函数它就执行一次, 这是天经地义的;可在分布式的消息世界里, "我发一条消息" 和 "它被执行一次" 之间, 隔着网络、崩溃、重试、重平衡这一堆不确定, "恰好一次" 反而成了最难、最昂贵的奢侈品;我用旧世界的"常识", 想当然地理解了新世界的"规则", 于是踩了坑。这让我领悟到一个关于"假设与环境"的深刻认知:我们脑子里有大量"习以为常、从不质疑"的隐含假设(操作只执行一次、读到的是最新的、调用非成即败、顺序不会乱、时钟是准的),它们的成立, 都依赖于某个我们没意识到的'环境前提'(通常是简单、单机、理想的环境);当环境改变(变成分布式、高并发、不可靠网络), 这些假设会悄无声息地失效, 而我们因为"从没质疑过它们", 根本想不到去检查——最危险的, 恰恰是那些"我们没意识到自己正在依赖的假设"。这给了我一种进入任何新领域时的清醒:当我从一个熟悉的环境进入一个新的、更复杂的环境(从单机到分布式、从小规模到大规模、从理想到真实)时,要刻意地把那些"想当然的默认假设"翻出来、逐一追问"在这个新环境里, 它还成立吗?这个系统实际承诺的保证, 是不是比我以为的弱?"——去查清组件"实际提供的语义"(而非我希望的语义), 并为真实(往往更弱、更混乱)的保证而非理想的保证做设计;"识别并审视自己的隐含假设、用'系统实际的保证'而非'我默认的理想'来设计",是从一个领域的新手成长为真正理解它的人的关键一步。认清隐含假设依赖环境前提、新环境里想当然的默认常悄悄失效、要为系统实际(更弱)的保证而非理想做设计——这,是我用一次消息重复消费的事故,换来的、关于分布式架构、也关于如何审视自身假设的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写消费逻辑时,在动手之前先问一句"这条消息要是来两次,会出事吗",并顺手加上幂等,那我对着那份"库存莫名少了一份"的对账单排查的这一天,就值了。
—— 别看了 · 2026