痛点:消息丢了,业务对不上账
电商中台每天产生数十万订单消息——下单发邮件、支付回调同步库存、退款触发逆向流程。任何一个环节丢消息,轻则漏发通知,重则库存与财务对不上账。RabbitMQ 默认的 autoAck 模式下,一旦消费者宕机或代码抛出运行时异常,消息就被标记为已消费,再也拿不回来。
本文基于生产实践经验,完整覆盖 RabbitMQ 消息可靠性的三个环节:生产者确认 → 消费者幂等消费 → 死信重试,附带可运行的 Spring Boot 示例。
一、生产者确认:确保消息抵达 Broker
1.1 事务方案(不推荐)
RabbitMQ 的 txSelect + txCommit 确实能保证消息 100% 到达 Broker,但吞吐量骤降 50% 以上,业务中台基本不用。
1.2 Publisher Confirm 模式(推荐)
Spring RabbitMQ 将 Confirm 封装为 CorrelationData 回调:
@Configuration
public class RabbitConfig {
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate template = new RabbitTemplate(connectionFactory);
// 启用生产者确认
template.setConfirmCallback((correlationData, ack, cause) -> {
if (ack) {
// 消息已正确到达 Exchange,标记成功
log.info("消息确认成功, id={}", correlationData.getId());
} else {
// 消息到达 Exchange 失败,记录日志并走本地补偿
log.error("消息确认失败, id={}, cause={}", correlationData.getId(), cause);
// 补偿策略见下文
}
});
// 消息路由不到 Queue 时回调
template.setReturnsCallback(returned -> {
log.error("消息未路由到队列, exchange={}, routingKey={}, msg={}",
returned.getExchange(), returned.getRoutingKey(),
new String(returned.getMessage().getBody()));
});
template.setMandatory(true);
return template;
}
}
发送时传入 CorrelationData:
CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
rabbitTemplate.convertAndSend("order.exchange", "order.created", orderMsg, cd);
// 在本地消息表记录这条消息的发送状态,Confirm 回调后更新状态
// 定时任务捞取 "待确认" 超时消息,重新发送或人工介入
踩坑记录:ConfirmCallback 默认是异步线程,不要在回调中做数据库写操作却不切数据源——容易引发「连接在异步线程中被关闭」的异常。建议回调中只改内存状态,或使用独立的线程池。
二、消费者幂等:消息不能重复处理
RabbitMQ 的 at-least-once delivery(至少一次投递)意味着消费者可能在处理完后、应答 ACK 之前宕机,重启后再次收到同一条消息。业务层必须自己解决幂等。
2.1 方案一:业务表唯一键去重
最简洁的做法——用消息中的业务唯一标识(如订单号)作为数据库唯一索引:
@RabbitListener(queues = "order.pay.queue")
public void handlePayMessage(PayMessage msg) {
// INSERT INTO pay_log(order_no, status) VALUES(?, 'PROCESSING')
// 唯一索引(order_no),主键冲突 = 已经处理过,直接返回
try {
payLogMapper.insert(msg);
// 执行业务逻辑
orderService.updateStatus(msg.getOrderNo(), "PAID");
} catch (DuplicateKeyException e) {
log.info("消息已处理, 幂等跳过, orderNo={}", msg.getOrderNo());
}
}
2.2 方案二:Redis 分布式锁 + 处理标记
场景:同一消息可能被多个消费者实例同时拉取(未配置 exclusive)。引入 Redis 锁:
@RabbitListener(queues = "order.pay.queue")
public void handleWithLock(PayMessage msg) {
String lockKey = "pay:lock:" + msg.getOrderNo();
// 尝试获取锁,过期时间 5 秒
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(5));
if (!Boolean.TRUE.equals(locked)) {
log.info("消息正被其他实例处理, 跳过, orderNo={}", msg.getOrderNo());
return; // 这条消息会重新投递
}
try {
// 先检查是否已处理
String processed = (String) redisTemplate.opsForValue()
.get("pay:processed:" + msg.getOrderNo());
if (processed != null) {
log.info("消息已处理, 幂等跳过");
return;
}
// 执行业务逻辑
orderService.updateStatus(msg.getOrderNo(), "PAID");
// 标记已处理(过期时间略长于业务处理时间)
redisTemplate.opsForValue()
.set("pay:processed:" + msg.getOrderNo(), "1", Duration.ofHours(1));
} finally {
redisTemplate.delete(lockKey);
}
}
踩坑记录:Redis 锁的超时时间一定要结合业务执行时间来定。一个支付回调中有三方查询(银联/微信),网络抖动可能撑到 5-10 秒,锁超时太短会导致并发穿透,锁超时太长又影响容错。建议设 5-10 秒 + 自动续期(Redisson 看门狗),或用数据库唯一索引作为兜底。
三、死信队列 + 重试:失败的兜底方案
业务代码抛异常时,不能无限重试也不能直接丢弃。标准做法是 死信队列(DLQ)+ 有限重试。
3.1 声明死信队列
@Bean
public Queue orderPayQueue() {
return QueueBuilder.durable("order.pay.queue")
.withArgument("x-dead-letter-exchange", "order.dlx.exchange")
.withArgument("x-dead-letter-routing-key", "order.pay.dlq")
.build();
}
@Bean
public Queue orderPayDlq() {
return QueueBuilder.durable("order.pay.dlq")
.withArgument("x-dead-letter-exchange", "order.dlx.exchange")
.withArgument("x-dead-letter-routing-key", "order.pay.queue")
.build();
}
3.2 有限重试监听器
@Component
@Slf4j
public class RetryDlqListener {
// 从 DLQ 中消费,最多重试 3 次
@RabbitListener(queues = "order.pay.dlq")
public void handleDlq(PayMessage msg,
@Header(name = "x-death", required = false) List<Map<String, Object>> xDeath) {
long retryCount = 0;
if (xDeath != null && !xDeath.isEmpty()) {
retryCount = (Long) xDeath.get(0).get("count");
}
if (retryCount >= 3) {
log.error("消息重试已达上限, 转入人工处理队列, orderNo={}", msg.getOrderNo());
// 发送到特殊报警队列,或写入本地补偿表人工介入
return;
}
try {
// 重新执行业务
orderService.updateStatus(msg.getOrderNo(), "PAID");
} catch (Exception e) {
log.warn("重试第{}次仍失败, 重回DLQ等待下次, orderNo={}", retryCount + 1, msg.getOrderNo());
// 抛出 AmqpRejectAndDontRequeueException 避免无限循环
throw new AmqpRejectAndDontRequeueException("重试失败, 保持DLQ");
}
}
}
x-death 头记录了消息被扔进死信队列的次数,以此来限制重试上限,避免死循环。
四、整体链路时序
- 生产者:发送消息并记录本地消息表(状态=待确认),Confirm 回调后更新为已确认;定时任务捞取超时未确认的记录,重新发送。
- 消费者:收到消息后立即进行幂等判断(Redis 或 DB 唯一键),已处理则 ACK 跳过;未处理则执行业务逻辑。
- 异常处理:执行业务抛异常 → 消息被 RabbitMQ 重新投递(重试次数在 consumer 端由
retry配置控制)→ 超过次数后进入 DLQ → 死信监听器检查x-death决定继续重试还是转人工。 - 最终兜底:超过最大重试次数后,将消息写入数据库补偿表或发送到报警群,由运维/开发手动排查。
五、最佳实践总结
- 生产者侧:必须启用 Publisher Confirm + ReturnCallback,配合本地消息表实现最终一致性。
- 消费者侧:禁用
autoAck,手动 ACK,并实现幂等处理(数据库唯一键 + Redis 锁双层保障)。 - 重试策略:设置有限重试 + 死信队列,不要无限重试以防消息堆积导致 RabbitMQ 内存撑爆。
- 监控:监控 DLQ 积压数量和
x-death分布,超过阈值自动告警。 - 测试:模拟网络中断(
iptables DROP目标端口)、消费者停机、Broker 重启等故障场景,验证每个环节的可靠性。
本文基于电商中台项目中 RabbitMQ 的使用经验整理,适用于大多数 Spring Boot + RabbitMQ 的生产场景。