一个没做幂等的消息队列消费者,因为一次网络抖动把同一条扣款消息消费了两次,给用户活生生扣了两次钱:一次消息重复消费与幂等缺失的深度复盘

半夜被叫醒处理资损:一笔订单被扣了两次款,有两条几乎一致的扣款流水,可同一笔订单我们只发了一条扣款消息。翻 MQ 投递日志才看清:消息队列是 at-least-once(至少一次)投递、宁可重复不愿丢消息,那次扣款服务已成功扣款,但返回 ack 时网络抖动、ack 没送达,MQ 以为没处理成功便重新投递,而消费者没做幂等、又扣了一次。本文讲透 MQ 三种投递语义与为何必须幂等,给出数据库唯一约束/去重表/状态机条件更新实现幂等的正解,梳理消息队列常见坑,最后落到'分布式下重复是常态、用 MQ 不丢+消费幂等分层协作达成恰好一次'的认知。

一个没做幂等的消息队列消费者,因为一次网络抖动把同一条扣款消息消费了两次,给用户扣了两次钱:一次消息重复消费的深度复盘

那是一个让我半夜被电话叫醒的资损事故:有用户投诉一笔订单被扣了两次款。我查了支付记录,确实有两条一模一样的扣款流水,金额、订单号、时间都几乎一致。我们的扣款是走消息队列异步处理的——下单后发一条"扣款消息"到 MQ,扣款服务消费这条消息去扣钱。我一开始百思不得其解:同一笔订单,我们只发了一条扣款消息啊,怎么会扣两次?直到我翻了 MQ 的投递日志,才看清真相,后背发凉:消息队列为了保证"消息不丢",采用的是"至少投递一次(at-least-once)"的语义——它宁可重复投递,也不愿丢消息。那次正好赶上一阵网络抖动:扣款服务已经成功消费并扣了款,但在向 MQ 返回"我处理完了(ack)"的确认时,网络抖了一下、ack 没传回去;MQ 没收到确认,就以为这条消息没被成功处理,于是把它又投递了一次。而我们的消费者没有做任何幂等处理——它收到这条"重复的"消息,傻乎乎地又扣了一次款问题的根,不是 MQ 投递错了(它的重复投递是它的设计、是为了不丢消息),而是我们的消费者没有"对重复消息免疫"的能力。这篇就把这次"消息重复消费、消费者没幂等"的坑,从头到尾复盘一遍。

故障现场:一个没有幂等保护的扣款消费者

问题代码,是一个想当然地认为"一条消息只会消费一次"的消费者:

// ✗ 出问题的消费者: 没有任何幂等处理, 收到消息直接扣款
@RabbitListener(queues = "payment.deduct")
public void onMessage(DeductMsg msg) {
    // msg = { orderId: 1001, userId: 88, amount: 100 }
    account.deduct(msg.getUserId(), msg.getAmount());   // ✗ 直接扣款!
    paymentRecord.save(msg.getOrderId(), msg.getAmount());
    // 处理完后, 框架自动向MQ发送 ack 确认
}

// 灾难时序:
// 1. MQ 投递扣款消息(orderId=1001) 给消费者;
// 2. 消费者成功扣了款、存了流水;
// 3. 消费者向MQ发ack确认"我处理完了"——但此时【网络抖动, ack没送达MQ】;
// 4. MQ等不到ack, 认为"这条消息没被成功消费", 于是【重新投递】这条消息;
// 5. 消费者【再次】收到 orderId=1001 的消息, 又【傻乎乎地扣了一次款】→ 重复扣款!

// 为什么会重复投递:
// - MQ普遍采用"at-least-once(至少一次)"投递语义: 宁可重发, 绝不丢消息;
// - 触发重复投递的情况很多: ack丢失(网络抖)、消费超时、消费者重启、消费组重平衡(rebalance)...
// - → 这些都是【正常会发生】的, 不是异常; 所以"消息会被重复消费"是【必须假设】的常态。

// 关键: MQ保证"至少投递一次"(可能重复); 消费者若不做幂等, 重复消息就会导致重复执行
//       (重复扣款/重复下单/重复发货)——幂等是消费者【必须】具备的能力, 不是可选项。

第一次理清这个时序时,我又懊恼又后怕:"我一直以为'发一条消息就消费一次'是理所当然的,原来 MQ 为了不丢消息,会重复投递,而这是它的正常设计?"这个坑最容易被忽视的地方在于:平时几乎不发生——绝大多数时候 ack 都正常送达、消息只消费一次,功能完全正常;它只在ack 丢失、消费超时、消费者重启、重平衡这些"偶发但必然会发生"的时刻才触发重复正因为它低频,开发测试时几乎遇不到,很容易让人误以为"消息就是消费一次"而不做幂等;可一旦它发生在扣款、下单、发货这种有副作用的操作上,后果就是资损。下面就来拆解,MQ 的投递语义和幂等。

第一件事:搞懂 MQ 的投递语义,以及为什么必须幂等

我认真研究了消息队列的投递语义,才彻底理解"重复消费"为什么是必然要面对的。

MQ 的三种投递语义, 以及为什么消费者必须幂等

【核心: 主流MQ是"at-least-once(至少一次)", 会重复投递; 消费者必须靠"幂等"来抵御重复】

1. 三种投递语义:
   - at-most-once(至多一次): 可能丢消息, 但不会重复。→ 不可靠, 很少用于重要业务。
   - at-least-once(至少一次): 不会丢, 但【可能重复】。→ 主流MQ的默认/常用语义。
   - exactly-once(恰好一次): 不丢不重。→ 理想, 但【极难且代价高】, 多数靠"at-least-once+消费幂等"近似实现。

2. 为什么主流是 at-least-once(会重复):
   - MQ的首要目标是"不丢消息"(丢了就是数据丢失);
   - 要"不丢", 它就必须"没收到ack就重发"——而"没收到ack"不代表"没处理成功"
     (可能是处理成功了、只是ack在路上丢了);
   - → 于是它"宁可错杀(重发), 不可放过(丢失)" → 必然可能重复投递。

3. 什么时候会重复投递(都是常态, 不是异常):
   - 消费成功但ack丢失(网络抖动);
   - 消费处理时间过长, 超过MQ的ack超时, MQ以为失败而重发;
   - 消费者宕机重启, 未ack的消息会被重投;
   - 消费组重平衡(rebalance), 分区/队列重新分配时可能重投。

4. 所以: "exactly-once的效果", 要靠"at-least-once + 消费端幂等"来实现:
   - MQ负责"至少送到一次"(不丢);
   - 消费者负责"同一条消息处理多次, 效果等同于处理一次"(幂等)——重复的就忽略/不重复执行;
   - 两者配合 = 业务上的"恰好一次"。

幂等(idempotent)是什么: 一个操作执行一次和执行多次, 产生的效果相同。
   (如: "把状态设为已支付"是幂等的; "余额减100"不是幂等的, 多执行一次就多扣一次)

一句话: 主流MQ是at-least-once、会重复投递(ack丢/超时/重启/重平衡都会触发, 是常态);
   消费者必须做幂等(同消息处理多次=处理一次), 用"at-least-once+消费幂等"实现业务恰好一次。

这套语义,是整个坑的根。三种投递语义:at-most-once(可能丢不重复)、at-least-once(不丢但可能重复,主流默认)、exactly-once(不丢不重,极难、多靠前者+幂等近似)。为什么主流是 at-least-once?MQ 首要目标是不丢消息,要不丢就必须"没收到 ack 就重发",而"没收到 ack"不代表"没处理成功"(可能成功了只是 ack 丢了),于是它"宁可重发不可丢失"、必然可能重复什么时候重复?ack 丢失、消费超时、消费者重启、消费组重平衡——都是常态不是异常所以"exactly-once 的效果"靠"at-least-once + 消费端幂等"实现:MQ 负责至少送到一次(不丢),消费者负责"同一条消息处理多次效果等同处理一次"(幂等),两者配合=业务恰好一次。幂等就是一个操作执行一次和多次效果相同("设为已支付"幂等、"余额减 100"不幂等)。一句话:主流 MQ 是 at-least-once、会重复投递(ack 丢/超时/重启/重平衡都触发、是常态);消费者必须做幂等,用"at-least-once+消费幂等"实现业务恰好一次。

第二件事:正解——给消费做幂等(唯一约束/去重表/状态机)

搞懂了原理,正解就清晰了:给每条消息一个唯一的业务标识(如 orderId/messageId),消费时先判重——用数据库唯一约束、去重表、或状态机,保证同一条消息处理多次只生效一次

// ====== 正解一(推荐): 用数据库唯一约束做幂等(最简单可靠) ======
// 给"扣款流水表"的 orderId 加唯一索引: UNIQUE KEY uk_order (order_id)
@RabbitListener(queues = "payment.deduct")
@Transactional
public void onMessage(DeductMsg msg) {
    try {
        // ★ 先尝试插入流水(orderId唯一); 重复消息会因唯一约束冲突而插入失败
        paymentRecord.insert(msg.getOrderId(), msg.getUserId(), msg.getAmount());
    } catch (DuplicateKeyException e) {
        // ★ 插入失败 = 这条消息已经处理过了 → 直接忽略, 不再扣款(幂等!)
        log.info("订单 {} 已处理过, 忽略重复消息", msg.getOrderId());
        return;
    }
    account.deduct(msg.getUserId(), msg.getAmount());   // 插入成功才扣款
    // 整个方法在一个事务里: 插流水和扣款要么都成功、要么都回滚
}
// → 靠"orderId唯一约束": 第一次插入成功并扣款; 重复消息插入冲突→直接忽略→不会重复扣款。
//   数据库的唯一约束是"原子的判重", 比"先查再插"(有并发竞态)更可靠。
// ====== 正解二: 用"去重表/Redis"记录已处理的消息ID ======
public void onMessage(DeductMsg msg) {
    String key = "dedup:deduct:" + msg.getMsgId();   // 消息的唯一ID
    // setIfAbsent: 不存在才设置成功(原子); 已存在=处理过了
    Boolean first = redis.opsForValue().setIfAbsent(key, "1", Duration.ofDays(7));
    if (Boolean.FALSE.equals(first)) {
        return;   // ★ 这个msgId处理过了, 忽略
    }
    // 第一次处理: 执行业务
    account.deduct(msg.getUserId(), msg.getAmount());
    // 注意: 要处理好"标记已处理"和"业务执行"的一致性(最好放一个事务/或先业务后标记+可重入)
}

// ====== 正解三: 用状态机, 让操作天然幂等 ======
// 把"扣款"设计成"状态流转": 订单状态 待支付 → 已支付;
// 消费时: UPDATE order SET status='已支付' WHERE id=? AND status='待支付';
// → 第一次: 影响1行(状态从待支付变已支付); 重复消息: 影响0行(状态已是已支付)→ 天然幂等。
// 这种"基于条件的状态更新"是实现幂等非常优雅的方式。

// ====== 关键原则 ======
// 1. 幂等的核心: 找一个"业务唯一标识"(orderId/msgId), 用它判重;
// 2. 判重要"原子": 用唯一约束/setIfAbsent/条件更新, 别用"先查再做"(有并发竞态);
// 3. 重复就忽略, 但仍要ack(告诉MQ处理完了, 别再投了)。

// 核心: 消费者必须幂等; 用业务唯一标识判重, 靠数据库唯一约束/去重表/状态机原子地"只生效一次";
//   重复消息直接忽略并ack; 别用"先查再做"(并发下会失效)。

修复的核心,是"用业务唯一标识原子地判重,让重复消息只生效一次"正解一(推荐):数据库唯一约束——给流水表 orderId 加唯一索引,消费时先插流水,重复消息因唯一约束冲突插入失败→直接忽略不扣款;唯一约束是"原子的判重",比"先查再插"(有竞态)可靠;插流水和扣款放一个事务正解二:去重表/Redis——setIfAbsent(msgId) 原子判重,已存在就忽略正解三:状态机——UPDATE ... SET status='已支付' WHERE id=? AND status='待支付',第一次影响 1 行、重复影响 0 行,天然幂等关键原则:找业务唯一标识判重、判重要原子(唯一约束/setIfAbsent/条件更新,别"先查再做")、重复就忽略但仍要 ack归根结底:消费者必须幂等;用业务唯一标识判重,靠数据库唯一约束/去重表/状态机原子地"只生效一次";重复消息直接忽略并 ack。

第三件事:消息队列使用的其他常见坑

排查后我把消息队列相关的其他常见坑也系统梳理了一遍。

消息队列使用的其他常见坑

# 1. 消费不幂等(本文): 重复投递导致重复执行。→ 业务唯一标识+唯一约束/去重/状态机。

# 2. 以为消息不会丢: 生产端发送失败、broker宕机也可能丢。→ 发送确认+持久化+(必要时)本地消息表。

# 3. 消息顺序问题: 多分区/多消费者下消息不保证全局有序。→ 按key分区保证局部有序, 或单分区。

# 4. 消费失败没有重试/死信: 处理失败的消息直接丢或无限重试。→ 重试+退避+死信队列(DLQ)。

# 5. 消息积压: 消费速度跟不上生产, 队列堆积。→ 扩消费者/提升消费性能/限流生产。

# 6. 大消息: 直接把大对象塞进消息体。→ 消息只放引用(如文件id), 大数据另存。

# 7. 没处理消费的事务一致性: "扣款"和"标记已处理"不在一个事务, 中间崩溃导致不一致。

# 8. 把MQ当数据库/RPC用: MQ适合异步解耦, 不适合要求强一致/即时返回的场景。

# 共同根源: 分布式消息系统在"不可靠网络+可能宕机的节点"下工作, 为了不丢消息选择了"可能重复";
#   把它当成"可靠、有序、恰好一次"的理想管道, 而不针对它的真实语义做防御, 就会踩坑。

# 核心: 理解MQ的真实语义(至少一次、可能重复、不保证全局有序、可能积压); 消费做幂等、
#   配重试死信、保证消费事务一致、按需保证顺序; 针对它"会重复会失败"的特性做工程防御。

排查让我把 MQ 的其他坑也梳理清了。一、消费不幂等(本文)。二、以为消息不会丢(发送确认+持久化+本地消息表)。三、消息顺序问题(按 key 分区保证局部有序)。四、消费失败没重试/死信(重试+退避+DLQ)。五、消息积压六、大消息(只放引用)。七、消费事务一致性八、把 MQ 当数据库/RPC 用它们的共同根源是:分布式消息系统在"不可靠网络+可能宕机的节点"下工作,为了不丢消息选择了"可能重复";把它当成"可靠、有序、恰好一次"的理想管道而不针对真实语义防御,就会踩坑核心是:理解 MQ 的真实语义(至少一次、可能重复、不保证全局有序、可能积压);消费做幂等、配重试死信、保证消费事务一致、按需保证顺序;针对它"会重复会失败"的特性做工程防御下面这张图,是这次重复消费坑的成因与解法:

第四件事:几种幂等实现方案对比表

这次踩坑后,我把常见的幂等实现方案整理成一张表,按场景选用。

方案 怎么做 适用
数据库唯一约束 业务唯一键加唯一索引, 冲突即重复 有天然唯一键(orderId), 最可靠
去重表/Redis 记录已处理的msgId, setIfAbsent 通用, 注意过期和一致性
状态机条件更新 UPDATE...WHERE status=旧状态 有状态流转的业务, 优雅
乐观锁(版本号) UPDATE...WHERE version=? 更新类操作
token机制(防重提交) 预发token, 用一次即失效 前端防重复提交

这张表把幂等方案钉清了。核心是:实现幂等的方案虽多,本质都是"找一个能唯一标识'这次操作'的东西,并原子地判断它有没有被执行过"——唯一约束用业务键、去重表用 msgId、状态机用状态、乐观锁用版本号;关键都在"唯一标识 + 原子判重"它给我的最大启发是:"幂等"是分布式系统里一个极其核心、无处不在的概念——因为分布式环境下"重复"是常态(重试、重发、重复提交、网络抖动都会带来重复),而很多操作(扣款/下单/发货)重复执行就是事故;所以"让操作幂等"(重复执行无害)就成了抵御这一切"重复"的通用护盾这让我把幂等提到了一个设计原则的高度:在分布式系统里,凡是"有副作用、且可能被重复触发"的操作,都应该被设计成幂等的——接口要能防重复提交、消费要能抗重复投递、重试要安全、回调要可重入;"幂等"不是某个场景的小技巧,而是构建可靠分布式系统的一块基石能力;养成"写有副作用的操作,先想它幂等吗"的习惯,能避开一大类重复执行的事故掌握幂等的"唯一标识+原子判重"本质、把幂等当分布式基石能力——是这个坑带给我的核心认知。

第五件事:这个坑暴露的"分布式下重复是常态"

这次让我深刻认识到,分布式系统里"重复"几乎无处不在。我把常见的重复来源整理成表。

重复来源 怎么产生 怎么防
MQ重复投递(本文) ack丢/超时/重启/重平衡 消费幂等
接口重复提交 用户连点/前端重发 防重token/唯一约束
超时重试 调用方超时重发, 但其实成功了 幂等+幂等键
网络抖动重发 请求/响应丢失触发重发 幂等
定时任务重复触发 多实例都跑了同一个任务 分布式锁/幂等

这张表道出了一个分布式系统的本质特征。核心是:在分布式系统里,"同一个操作被触发不止一次"是常态而非异常——MQ 会重投、用户会连点、调用会超时重试、网络抖动会重发、多实例会重复跑任务;"重复"是分布式环境的固有属性,来自它的不可靠和冗余它给我的深刻启发是:这背后是分布式系统一个绕不开的根本矛盾——为了可靠(不丢),系统普遍采用"重试/重发/冗余";而"重试/重发"的代价,就是"可能重复";"不丢"和"不重"在分布式下难以同时轻松保证,系统往往选择"宁可重复(可补救)、不可丢失(难补救)"这给了我一种面对分布式的清醒预期:设计分布式系统时,要把"任何操作都可能被重复执行"当成一个默认前提来接受和应对——不要幻想"它只会执行一次",而要主动地让关键操作"即使被重复执行也安全"(幂等);"拥抱重复、用幂等化解它",而不是"假设没有重复、被重复打个措手不及"——这是分布式思维和单机思维的一个重要分水岭认清分布式下重复是常态、用幂等主动化解——是这个重复扣款坑,带给我的关于分布式系统的本质认知。

第六件事:写有副作用的操作时,我现在的检查习惯

现在每当我写一个"有副作用"的操作(扣款、下单、发消息、改状态),我都会按这张图先想一遍:

这张图的精髓,是"有副作用且可能重复的操作,一律做幂等"判断操作会不会被重复触发(MQ/接口/重试/回调都会)、有就用业务唯一标识做幂等(唯一约束/状态机/幂等键)、原子判重、重复就忽略但仍 ack/返回成功、判重和业务放一个事务这套习惯,让我从"默认操作只执行一次"变成了"有副作用的操作先想它幂等吗"——核心始终是:分布式下重复是常态,有副作用的操作必须设计成幂等的。

我立下的几条规矩

这场"重复消费、重复扣款"的事故,换来了我做分布式系统时,刻进骨子里的几条铁律:

  1. MQ 是 at-least-once,会重复投递。ack 丢/超时/重启/重平衡都会触发,是常态。
  2. 消费者必须做幂等。同一条消息处理多次,效果等同处理一次。
  3. 用业务唯一标识原子判重。唯一约束/setIfAbsent/条件更新,别"先查再做"。
  4. 重复消息直接忽略,但仍要 ack。否则 MQ 会一直重投。
  5. 判重和业务执行放一个事务。保证一致性,别中间崩溃留下不一致。
  6. 有副作用且可能重复的操作都要幂等。接口防重、重试安全、回调可重入。
  7. 分布式下重复是常态。为可靠而重试的代价就是可能重复,用幂等化解。

写在最后

回头看,这场由"消费者没做幂等"引发的、重复扣款的资损事故,真正教给我的,远不止"MQ 消费要做幂等"这一个技巧。它让我对"在一个不可靠的世界里追求可靠,往往要靠'重复尝试',而重复尝试的代价,必须用'幂等'来兜住",有了一次刻骨的体会。我栽跟头,根源在于我对 MQ 这个组件有一个"理想化"的期待:我以为它是一根"可靠的、恰好一次的"管道——我发一条,它就给消费者一条,不多不少。可这个期待忽视了一个深刻的现实:MQ 之所以能做到"可靠(不丢消息)",恰恰是以"可能重复"为代价的——它无法分辨"消费失败"和"消费成功但 ack 丢了",为了不冤枉任何一条可能没成功的消息(不丢),它只能选择"没确认就重发"(可能重);它的"可靠",是用"宁可重复"换来的——这不是它的缺陷,而是它在不可靠网络下,为了"不丢"所能做的、最负责任的选择这让我领悟到一个关于分布式系统的深刻权衡:在不可靠的分布式环境里,"可靠性(不丢)"和"不重复(不重)"常常是一对需要权衡的目标——要"不丢",几乎必然要"重试/重发",而重试就带来"可能重";要"绝对不重",又难以同时保证"绝对不丢";聪明的系统设计,是把这对矛盾拆开、分层解决:让传输层(MQ)负责"不丢(代价是可能重)",让应用层(消费者的幂等)负责"消化掉重复"——两层配合,共同达成业务上既不丢也不重的"恰好一次"这给了我一种构建可靠系统的成熟思路:不要奢望某一层、某个组件能"完美地"解决所有问题,而要理解每一层的能力与代价、并通过分层协作来弥补单层的不足——"MQ 保证不丢 + 消费者保证幂等 = 业务恰好一次",正是这种"分层协作、各补其短"的典范;看清每个组件"用什么代价换了什么保证",并设计好上下层的配合,才能在不可靠的基础上,搭建出可靠的系统理解可靠与不重的权衡、用"MQ 不丢+消费幂等"分层协作达成恰好一次——这,是我用一次重复扣款的资损事故,换来的、关于架构、也关于如何在不可靠之上构建可靠的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写 MQ 消费者时,第一反应就是"这消息会被重复投递,我得做幂等",那我那个被半夜叫醒处理资损的夜晚,就没白熬。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个直接把大模型返回当 JSON 来解析的接口,在线上偶发地解析崩溃——因为模型有时会贴心地多说几句话:一次 LLM 结构化输出的深度复盘

2026-6-2 15:52:23

技术教程

一个每次请求都 new 一个 HttpClient 再 Dispose 的写法,在高并发下把服务器端口耗尽、抛出无法分配地址的异常:一次 HttpClient 误用的深度复盘

2026-6-2 16:03:46

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索