为什么消息会被消费两次?后果有多严重?怎么彻底解决?
下面用可交互的流程带你把来龙去脉看清。
重复消费(Duplicate Consumption):同一条业务消息,被消费者成功处理了一遍以上。它不是"消息在队列里多了一份"那么简单,而是下游真真切切地执行了多次。
点击每条展开机制。标 🔴 高频的是你日常 90% 会遇到的。
消费者采用「先处理、后提交位移」的至少一次写法。刚处理完消息、还没来得及提交 offset 就宕机/发布重启。重启后 Broker 认为这条消息"没消费过",重新投递 → 又被处理一次。这是重复消费的头号来源。
生产者发送后没收到 ACK(网络超时),触发重试。但 Broker 其实第一次就已经收到并落盘了,只是 ACK 丢了。重试导致同一条消息在 Topic 里存在两份,消费者自然会消费两次。
消费者组内有成员加入/退出/心跳超时,触发分区重新分配。某个分区从消费者 A 划给 B。如果 A 已经处理了消息但还没提交位移,B 接管后会从旧位移开始,把已处理的消息再消费一遍。
消费者处理到一半抛异常,或主动 reject / nack 消息,MQ 把它重新入队。若异常发生在部分副作用已产生之后(比如先扣了库存再调接口失败),重投就会让这部分副作用再发生一次。
运维为了修复数据,手动重放历史消息、或把死信队列(DLQ)里的消息重新投回主队列。这种"我主动再发一次"若没做去重,就会和原处理撞车。
如果你用的是默认的至少一次(Kafka/RocketMQ 默认就是),重复是"预期内"的。只有显式启用幂等生产者 + 事务(精确一次),才能从中间件层面减少重复——但跨系统的重复仍需业务幂等兜底。
选一个触发原因,点「运行模拟」,看同一条消息如何被消费 2 次。
重复消费最可怕的地方是:它很安静。没有报错,日志看起来都"成功",但业务已经被悄悄执行了多次。
同一笔支付被扣两次、同一订单生成两单。用户直接找客服,资损实打实。
库存扣成负数或超卖。下游发货不了,纠纷和赔付接踵而至。
同一条短信/推送发好几遍,用户体验崩坏,还可能被运营商限流。
财务流水多出一笔,对账永远对不上,排查成本极高。
幂等(Idempotent)=同一个操作做 1 次和做 N 次,最终效果一样。只要消费逻辑幂等,消息被投 100 次也不怕——多余的都是"空打"。
下面模拟"同一条消息被投递 2 次",看账户余额的变化。点「投递同一条消息 ×2」。
用订单号 / 消息 ID 作为唯一键写入去重表,处理前先查。存在就跳过,不存在才处理并落库。可借助数据库唯一约束兜死。
INSERT INTO dedup (msg_id, status) VALUES (?, 'DONE');
-- 唯一约束冲突 = 已处理过 → 直接返回,跳过业务
业务表上加唯一索引(如 order_no)。重复插入直接抛 DuplicateKeyException,catch 后忽略即可。无需额外表。
try { INSERT INTO orders(order_no, ...) VALUES(...); }
catch (DuplicateKeyException e) { /* 重复,忽略 */ }
用消息 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 直接返回上次结果。
if (tokenStore.exists(token)) return lastResult;
tokenStore.put(token, result); // 首次处理并缓存
告诉我是哪种场景,我直接给你推荐幂等方案。