消息队列如何处理重复消息(保证消息的幂等性)?
- 标签:消息队列、后端
🏛 业务架构师
先立原则:消息重复不可避免,幂等是消费端的责任。MQ 的 at-least-once 语义(生产重试、消费 ack 超时重投、rebalance 重平衡)注定消息会再来一次,防的思路不是"杜绝重发"而是"重复到达不出错"。
我的幂等实现按可靠性分档:
- 业务唯一键 + 唯一索引(最硬的防线):消息携带业务单号(订单号+动作类型),消费落库时唯一约束拦截重复——数据库层兜底,不依赖任何中间状态。积分发放表 unique(订单号, 动作)、红包发放表同理,冲突即捕获忽略;
- 状态机幂等:订单状态流转"待支付→已支付",重复消息到达时状态已是"已支付",直接忽略(
update orders set status='PAID' where id=? and status='UNPAID',影响行数为 0 即重复)——把幂等藏在业务规则里,顺理成章; - 去重表/Redis 标记:消费前 SETNX 消息 ID(带 TTL),成功才处理。要注意"标记成功但处理失败"的一致性——标记与处理要么同事务(去重表),要么接受标记后失败走人工补偿;
- 版本号乐观锁:适合更新类消费(余额、库存带 version)。
工程闭环三件事:
- 消息 ID 要全局稳定(生产端生成、重投不变),别用消费端随机 ID;
- 幂等维度对齐业务语义:"同一订单发一次券"防的是订单级,不是消息级——粒度搞错,防了重复没防住业务重复;
- 对账兜底:幂等是降低概率,对账是抓住漏网,日对账 + 差异自动修复才是完整闭环。
一句话:没有幂等的 MQ 接入等于给资损开直通车,这是我评审所有消息消费方案的必查项。
🏗 系统架构师
消息重复不可避免(底层网络重传、生产者重试、消费者 ack 丢失后重投都产生),所以标准答案是不去消灭重复,而是让消费幂等——同一条消息处理一次和多次结果相同。
幂等实现层次(按我实践优先级):
- 数据库唯一约束:消息带唯一业务键(订单号 + 事件类型),消费落库时唯一索引冲突即忽略,天然防重,最可靠的兜底;
- 去重表 / 状态机:消费前 insert 去重表(msgId 或 bizId 唯一),成功才处理;或按状态机流转(待支付→已支付),重复消息因状态不满足直接跳过——
update orders set status='PAID' where id=? and status='UNPAID',影响行数 0 即重复; - Redis SET NX:
set dedup:{msgId} 1 NX EX 86400高频去重,性能好,但 Redis 与 DB 非同一事务,存在"标记成功但业务失败"的窗口,要配补偿清理或作为第一层过滤 + DB 约束兜底; - 业务天然幂等:set 固定值、覆盖写、绝对值更新天然幂等,设计时优先选幂等操作。
架构要点:去重范围要覆盖"重试窗口 + 消费超时重投";msgId 要用业务语义 ID(broker 生成的 ID 在生产者重试时会变,防不住业务重复);幂等键的选择 = 唯一业务标识,而不是消息流水号。原则:at-least-once 传输 + 幂等消费 = 效果上的 exactly-once。
💻 全栈开发专家
先明确:MQ 都是至少一次(at-least-once)投递,重复不可避免,幂等必须做在消费端。生产端重试、消费端超时重投、Rebalance 重平衡都会产生重复。
我落地的三层防线:
1. 数据库唯一索引(强防线,兜底)
sql
ALTER TABLE coupon ADD UNIQUE KEY uk_user_activity (user_id, activity_id);重复消费再插入直接 DuplicateKeyException,捕获视为成功。
2. 去重表 / Redis 标记(快速过滤,省执行)
java
String msgId = message.getKeys(); // 业务唯一键(orderId+类型)
Boolean first = redis.setIfAbsent("dedup:" + msgId, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(first)) { ack(); return; } // 重复,直接确认
try {
doBusiness(); // 本身也要幂等(第3层)
} catch (Exception e) {
redis.delete("dedup:" + msgId); // 失败放行下次重试
throw e;
}3. 业务状态机幂等:UPDATE orders SET status='PAID' WHERE id=? AND status='UNPAID'——条件更新天然幂等,影响行数 0 说明已处理。
关键取舍:去重 key 用业务语义键(orderId)而不是 messageId——重发的新 messageId 不同但业务是同一笔,用 messageId 去重会漏。Redis 去重要处理"标记成功但业务崩溃"的窗口(TTL 别太长 + 第 1 层兜底)。永远相信会重复,设计时就当它一定发生。