分类:数据库与中间件 · 作者:站长 · 发布于 2026-09-16 · 标签:RabbitMQ / 消息队列 / 可靠性 / 幂等 / 死信队列

痛点:消息丢了,业务对不上账

电商中台每天产生数十万订单消息——下单发邮件、支付回调同步库存、退款触发逆向流程。任何一个环节丢消息,轻则漏发通知,重则库存与财务对不上账。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 头记录了消息被扔进死信队列的次数,以此来限制重试上限,避免死循环。

四、整体链路时序

  1. 生产者:发送消息并记录本地消息表(状态=待确认),Confirm 回调后更新为已确认;定时任务捞取超时未确认的记录,重新发送。
  2. 消费者:收到消息后立即进行幂等判断(Redis 或 DB 唯一键),已处理则 ACK 跳过;未处理则执行业务逻辑。
  3. 异常处理:执行业务抛异常 → 消息被 RabbitMQ 重新投递(重试次数在 consumer 端由 retry 配置控制)→ 超过次数后进入 DLQ → 死信监听器检查 x-death 决定继续重试还是转人工。
  4. 最终兜底:超过最大重试次数后,将消息写入数据库补偿表或发送到报警群,由运维/开发手动排查。

五、最佳实践总结

  • 生产者侧:必须启用 Publisher Confirm + ReturnCallback,配合本地消息表实现最终一致性。
  • 消费者侧:禁用 autoAck,手动 ACK,并实现幂等处理(数据库唯一键 + Redis 锁双层保障)。
  • 重试策略:设置有限重试 + 死信队列,不要无限重试以防消息堆积导致 RabbitMQ 内存撑爆。
  • 监控:监控 DLQ 积压数量和 x-death 分布,超过阈值自动告警。
  • 测试:模拟网络中断(iptables DROP 目标端口)、消费者停机、Broker 重启等故障场景,验证每个环节的可靠性。

本文基于电商中台项目中 RabbitMQ 的使用经验整理,适用于大多数 Spring Boot + RabbitMQ 的生产场景。