首页 / 高并发 / 消息队列 / 重复消费
高并发 · 消息队列 · 故障排查

MQ 重复消费:前因后果与解法

为什么消息会被消费两次?后果有多严重?怎么彻底解决?
下面用可交互的流程带你把来龙去脉看清。

1 先定义:什么是重复消费 / What is it

重复消费(Duplicate Consumption):同一条业务消息,被消费者成功处理了一遍以上。它不是"消息在队列里多了一份"那么简单,而是下游真真切切地执行了多次

它从哪来? 回顾上一节:MQ 默认是至少一次(At-Least-Once)语义——为了绝不丢消息,系统宁可多投、也不愿漏投。而"多投"就是你看到的重复消费。所以:重复消费不是 bug,是"至少一次"语义的预期代价
0 丢失 1 正好 ≥2 重复 重复消费在这里
图 1:重复消费 = 消息落到了「≥2 次」区间。要避免它,就得把"多投"这一下消掉。

2 为什么会重复:六大触发原因 / Root causes

点击每条展开机制。标 🔴 高频的是你日常 90% 会遇到的。

💥

① 消费后未提交位移就崩溃(最高频)

🔴 高频

消费者采用「先处理、后提交位移」的至少一次写法。刚处理完消息、还没来得及提交 offset 就宕机/发布重启。重启后 Broker 认为这条消息"没消费过",重新投递 → 又被处理一次。这是重复消费的头号来源

🔁

② 生产者发送重试

🔴 高频

生产者发送后没收到 ACK(网络超时),触发重试。但 Broker 其实第一次就已经收到并落盘了,只是 ACK 丢了。重试导致同一条消息在 Topic 里存在两份,消费者自然会消费两次。

⚖️

③ 消费组重平衡(Rebalance)

🟠 中频

消费者组内有成员加入/退出/心跳超时,触发分区重新分配。某个分区从消费者 A 划给 B。如果 A 已经处理了消息但还没提交位移,B 接管后会从旧位移开始,把已处理的消息再消费一遍

↩️

④ 消费失败 / 手动 NACK 重投

🟠 中频

消费者处理到一半抛异常,或主动 reject / nack 消息,MQ 把它重新入队。若异常发生在部分副作用已产生之后(比如先扣了库存再调接口失败),重投就会让这部分副作用再发生一次

🛠️

⑤ 人为补发 / 消息重放

🔵 低频

运维为了修复数据,手动重放历史消息、或把死信队列(DLQ)里的消息重新投回主队列。这种"我主动再发一次"若没做去重,就会和原处理撞车。

🚫

⑥ 没开启精确一次语义

🔵 低频

如果你用的是默认的至少一次(Kafka/RocketMQ 默认就是),重复是"预期内"的。只有显式启用幂等生产者 + 事务(精确一次),才能从中间件层面减少重复——但跨系统的重复仍需业务幂等兜底。

3 交互模拟:重复是怎么发生的 / Live demo

选一个触发原因,点「运行模拟」,看同一条消息如何被消费 2 次

① 选择触发原因

首次消费 重复副本 故障点
📤生产者
🗄️Broker
📥消费者
📨
就绪——选择触发原因后点击运行

4 重复消费的后果:有多严重 / Consequences

重复消费最可怕的地方是:它很安静。没有报错,日志看起来都"成功",但业务已经被悄悄执行了多次。

💸

重复扣款 / 重复下单

同一笔支付被扣两次、同一订单生成两单。用户直接找客服,资损实打实。

📦

超卖 / 库存多扣

库存扣成负数或超卖。下游发货不了,纠纷和赔付接踵而至。

📣

重复通知骚扰

同一条短信/推送发好几遍,用户体验崩坏,还可能被运营商限流。

🧾

账目不平

财务流水多出一笔,对账永远对不上,排查成本极高。

关键认知: 比起"丢消息"(至少会有人发现少了),重复消费更阴险——它让你以为一切正常,直到对账或用户投诉才暴露。所以业界铁律是:消费逻辑必须幂等

5 核心解法:让消费「幂等」 / Idempotency

幂等(Idempotent)=同一个操作做 1 次和做 N 次,最终效果一样。只要消费逻辑幂等,消息被投 100 次也不怕——多余的都是"空打"。

解法总纲: 别去阻止 MQ 重投(那会影响"不丢"),而是在业务层把重复消化掉。这就是「至少一次 + 幂等」组合拳,解决了 99% 的重复问题。

🔬 交互对比:无幂等 vs 有幂等

下面模拟"同一条消息被投递 2 次",看账户余额的变化。点「投递同一条消息 ×2」。

❌ 没有幂等

¥100
每消费一次扣 ¥10

✅ 有幂等(去重表)

¥100
已处理的消息 ID 直接跳过
看结果: 无幂等时余额变成 ¥80(多扣了 ¥10,错误);有幂等时余额 ¥90(第二次被识别为重复,跳过,正确)。这就是幂等的价值。

6 实现幂等的 5 种武器 / How to build it

① 业务唯一键 + 去重表(最常用)

用订单号 / 消息 ID 作为唯一键写入去重表,处理前先查。存在就跳过,不存在才处理并落库。可借助数据库唯一约束兜死。

INSERT INTO dedup (msg_id, status) VALUES (?, 'DONE');
-- 唯一约束冲突 = 已处理过 → 直接返回,跳过业务

② 数据库唯一索引(最简单最强)

业务表上加唯一索引(如 order_no)。重复插入直接抛 DuplicateKeyException,catch 后忽略即可。无需额外表。

try { INSERT INTO orders(order_no, ...) VALUES(...); }
catch (DuplicateKeyException e) { /* 重复,忽略 */ }

③ Redis / 分布式锁(SETNX)

用消息 ID 做 key,SETNX 抢锁,抢到的才处理。适合高并发、不想查库的场景。注意设过期时间防死锁。

if (redis.setnx("msg:" + msgId, "1", 10s)) {
    process();   // 第一次,处理
} else {
    return;      // 已处理过,跳过
}

④ 状态机 / 版本号

业务对象有状态流转(待支付→已支付)。已处于目标状态时,重复事件直接忽略;或用版本号 CAS 更新,旧版本更新失败即丢弃。

UPDATE orders SET status='PAID' WHERE order_no=? AND status='UNPAID';
// 影响行数=0 说明已支付过 → 幂等生效

⑤ 幂等令牌(Token)

请求方携带唯一 token,服务端记录"token→已处理"。常用于对外接口(支付、提交),重放同一 token 直接返回上次结果。

if (tokenStore.exists(token)) return lastResult;
tokenStore.put(token, result);  // 首次处理并缓存
选型速记: 有 DB → 用 ①②;高并发不想查库 → 用 ;有明确状态流转 → 用 ;对外接口 → 用 。核心是同一句话:用"唯一标识"判断"这事我是不是已经干过了"

7 交互选型助手:你该用哪种 / Picker

告诉我是哪种场景,我直接给你推荐幂等方案。

8 一句话总结 / Recap

记忆闭环:
为什么重复? 因为 MQ 默认"至少一次",为了不丢而宁可重投。
谁最容易导致? 消费后未提交位移就崩溃、生产者重试、重平衡。
后果多严重? 静默重复,重复扣款/超卖/骚扰,阴险难查。
怎么解决? 别拦重投,消费逻辑做幂等——唯一键去重表 / 唯一索引 / Redis 锁 / 状态机 / 幂等令牌。
铁律: 只要消费逻辑幂等,消息被投多少次都不怕。