RabbitMQ 重复投递源于“至少一次”语义,未及时 ACK 时会重发;需用 Redis SETNX 实现幂等消费,并辅以 DB 唯一索引兜底,避免事务与 Redis 耦合。

为什么 RabbitMQ 会重复投递消息?
不是 RabbitMQ 故意“发两遍”,而是它默认采用“至少一次”(at-least-once)投递语义。只要消费者没在 channel.basicAck() 前正常返回 ACK,Broker 就认为消费失败,会重新入队或投递给其他消费者。常见触发点包括:网络超时、JVM Full GC 导致消费线程卡住、消费者进程突然 kill -9、手动调用 channel.basicNack(requeue=true)。这些都不是 Bug,是分布式系统为保障可靠性做的权衡。
- 消费者处理耗时 >
consumer_timeout(RabbitMQ 3.8+ 默认 30 分钟),Broker 主动 requeue - 消费者在
basicConsume()后崩溃,未发送任何 ack/nack,消息重回 ready 状态 - 集群主从切换期间,部分 unacked 消息被误判为“未确认”,触发重发
用 Redis + SETNX 实现最简幂等消费
这是生产环境最常用、落地最快的方式。核心就一条:收到消息后,先用消息 ID 向 Redis 写一个带过期时间的 key;写成功才执行业务逻辑;写失败(key 已存在)直接跳过。不用事务、不查 DB,性能损耗极小。
-
messageId必须全局唯一且稳定——优先用业务字段(如order_id),其次用MessageProperties.getMessageId(),慎用内容哈希(同一业务逻辑多次发相同内容,但语义不同) - 必须设过期时间(如 24 小时),否则 Redis 内存无限增长;过期时间要远大于单次业务最大耗时,但不宜超过业务生命周期(比如订单超时关单是 30 分钟,幂等 key 过期设 2 小时足够)
- 用
StringRedisTemplate.opsForValue().setIfAbsent(key, "1", Duration.ofHours(2)),不要用hasKey()+set()两步操作(非原子,竞态失败)
@Autowired
private StringRedisTemplate redisTemplate;
public void handleMessage(Message message) {
String messageId = extractMessageId(message); // 如:从 headers 取 "x-order-id"
String dedupKey = "mq:dedup:" + messageId;
Boolean isFresh = redisTemplate.opsForValue()
.setIfAbsent(dedupKey, "1", Duration.ofHours(2));
if (Boolean.TRUE.equals(isFresh)) {
processBusiness(message); // 执行下单、扣库存等真实逻辑
} else {
log.warn("Duplicate message ignored: {}", messageId);
}
}
数据库唯一索引兜底,不是替代方案
有人觉得“我 DB 有唯一索引,插入失败就说明重复”,这只能作为第二道防线,绝不能当主方案。因为:唯一索引冲突是业务异常,会打断正常流程;高并发下大量 insert 失败再 catch,对 MySQL 是无效压力;且无法防止“已插入但事务未提交时重复消费”的中间态问题。
- 适合场景:最终一致性要求极高、且业务本身天然带唯一键(如支付流水号、退款单号),可配合
INSERT IGNORE或ON DUPLICATE KEY UPDATE - 必须搭配 Redis 去重使用——Redis 判断“该不该进 DB”,DB 索引只拦住漏网之鱼
- 注意:MySQL 的
INSERT ... SELECT或自增 ID 不构成幂等保障,别被误导
Spring AMQP 的 @RabbitListener 怎么加幂等?
别在 listener 方法里裸写去重逻辑。Spring AMQP 提供了更干净的切面式接入方式:用 ChannelAwareMessageListener 或自定义 AdviceChain,但最推荐的是封装一个幂等代理 Consumer Bean。
立即学习“Java免费学习笔记(深入)”;
- 把去重逻辑抽成独立组件(如
DeduplicationService),所有@RabbitListener方法第一行调用deduplicationService.checkAndMark(messageId) - 避免在 listener 上加
@Transactional后再去 Redis——事务和 Redis 不在一个上下文,回滚不联动;应确保 Redis 操作在事务外完成 - 若用 Spring Retry,必须关闭
requeue = true,否则重试会不断触发重复消费;建议改用死信队列 + 人工干预










