RabbitMQ 防止重复消费需要从 生产者幂等、消息去重、消费者幂等 三层防护,核心在于业务幂等设计而非依赖 MQ 本身。
一、重复消费的根源
表格
| 环节 | 场景 | 结果 |
|---|---|---|
| 生产者重试 | 网络超时未收到 ACK,重复发送 | 消息内容相同,ID 不同 |
| MQ 投递 | 消费者处理成功但 ACK 超时,MQ 重新入队 | 同一消息 ID 再次投递 |
| 消费者异常 | 处理成功但崩溃未 ACK,或手动 NACK | 消息重回队列重复消费 |
关键认知:RabbitMQ 的 At-Least-Once 语义保证消息不丢,但不保证不重复。
二、三层防护策略
第一层:生产者幂等(防重复发)
java
复制
// 方案:业务唯一 ID(如订单号)作为 MessageId
Message message = MessageBuilder.withBody(json.getBytes())
.setMessageId(orderNo) // 业务唯一键
.setContentType("application/json")
.build();
// 配合 Broker 去重(RabbitMQ 插件或业务表)
rabbitTemplate.convertAndSend("order.exchange", "order.routing", message);
进阶:启用 RabbitMQ Deduplication 插件(或自研),Broker 层根据 MessageId 去重。
第二层:消息去重(消费者入口)
java
复制
@Component
public class OrderConsumer {
@Autowired
private StringRedisTemplate redisTemplate;
@RabbitListener(queues = "order.queue")
public void onMessage(Message message, Channel channel) throws IOException {
String msgId = message.getMessageProperties().getMessageId();
// 1. Redis 去重(SETNX 原子操作)
Boolean isNew = redisTemplate.opsForValue()
.setIfAbsent("mq:consumer:" + msgId, "1", 24, TimeUnit.HOURS);
if (!isNew) {
log.warn("重复消息,直接 ACK: {}", msgId);
channel.basicAck(deliveryTag, false); // 确认消费,不处理业务
return;
}
// 2. 执行业务
try {
processOrder(message);
channel.basicAck(deliveryTag, false);
} catch (Exception e) {
// 失败不 ACK,进入死信队列或重试
channel.basicNack(deliveryTag, false, false);
}
}
}
去重存储选型:
表格
| 方案 | 适用场景 | TTL 设置 |
|---|---|---|
| Redis SETNX | 高频、短周期(24h) | 消息有效期 + 冗余 |
| MySQL 唯一索引 | 低频、长周期、需审计 | 永久或定期归档 |
| 布隆过滤器 | 超大规模、允许极小误判 | 无,内存高效 |
第三层:消费者业务幂等(最终防线)
无论消息是否重复,业务操作本身必须幂等。
java
复制
@Service
public class OrderService {
// 方案1:数据库唯一约束(推荐)
@Transactional
public void createOrder(OrderDTO dto) {
try {
orderMapper.insert(new Order(dto.getOrderNo(), ...));
// 唯一索引冲突时自动抛异常,捕获后视为成功
} catch (DuplicateKeyException e) {
log.info("订单已存在,幂等返回: {}", dto.getOrderNo());
return; // 视为成功,不抛异常
}
}
// 方案2:状态机幂等(复杂业务)
@Transactional
public void payOrder(String orderNo, BigDecimal amount) {
Order order = orderMapper.selectForUpdate(orderNo);
// 状态机校验:已支付直接返回
if (order.getStatus() == OrderStatus.PAID) {
log.info("订单已支付,幂等返回: {}", orderNo);
return;
}
// 只有待支付状态才执行扣款
if (order.getStatus() != OrderStatus.PENDING) {
throw new IllegalStateException("订单状态异常: " + order.getStatus());
}
// 执行支付...
orderMapper.updateStatus(orderNo, OrderStatus.PAID);
}
}
三、关键配置:ACK 模式与重试
yaml
复制
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: manual # 手动 ACK,控制精确
retry:
enabled: true
max-attempts: 3 # 本地重试3次
initial-interval: 1000ms
default-requeue-rejected: false # 重试失败不入队,进死信
template:
retry:
enabled: true # 生产者重试
为什么不用自动 ACK?
auto:消息一出队列就 ACK,消费者崩溃则消息丢失 ❌manual:业务成功后 ACK,失败可 NACK/重试/进死信 ✅
四、极端场景:Exactly-Once 实现
RabbitMQ 原生不支持 Exactly-Once,需业务层实现:
plain
复制
生产者 ──► 开启事务/Confirm ──► 发送消息 ──► 本地事务表记录"已发送"
│
▼
消费者 ──► 拉取消息 ──► 开启本地事务 ──► 业务处理 + 插入消费记录
│
▼
同一事务内 ACK 消息
代价:吞吐量下降 10 倍以上,仅金融级场景使用。
五、面试完美回答
"防止重复消费分三层:生产者用业务唯一 ID 保证消息不重复发;消费者入口用 Redis SETNX 或数据库去重表拦截重复消息;业务层必须幂等,数据库唯一约束或状态机校验兜底。ACK 模式用手动确认,失败进死信队列避免无限重试。核心原则是业务幂等为主,MQ 去重为辅,因为网络抖动不可避免,不能依赖 MQ 保证 Exactly-Once。"
加分点:
- 提到 手动 ACK + 死信队列 防止消息堆积
- 对比 Redis SETNX vs 布隆过滤器 的选型场景
- 说明 Exactly-Once 的业务代价,体现工程权衡