RabbitMQ 消息可靠投递:confirm 确认 + 消费重试 + 死信队列

RabbitMQ 消息可靠投递:confirm 确认 + 消费重试 + 死信队列

导读:消息队列最怕丢消息。简历投递、站内信这些场景,丢一条用户就收不到反馈。我系统性地把 RabbitMQ 的可靠投递做了一遍:生产端 confirm 确认、消费端手动 ack + 重试、失败进死信队列。这篇记录完整的配置和踩坑。

先说丢消息的三个环节:生产者发送失败、MQ 自己挂了、消费者处理失败。三个环节都要兜底,缺一个都不算可靠。

生产端:publisher confirm 确认

RabbitMQ 的 confirm 机制:发送消息后,Broker 会异步回调确认这条消息是否成功落盘。配置:

yaml 复制代码
spring:
  rabbitmq:
    host: 127.0.0.1
    port: 5672
    publisher-confirm-type: correlated  # 开启 confirm
    publisher-returns: true            # 消息不可路由时回调
    template:
      mandatory: true

publisher-confirm-type 有三个值:none(不开启)、simple(同步等待)、correlated(异步回调)。生产用 correlated,别用 simple 阻塞发消息的线程。

发送消息并处理确认:

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class MqProducer {

    private final RabbitTemplate rabbitTemplate;

    // 发送简历投递通知
    public void sendResumeDelivered(ResumeDeliveredEvent event) {
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
        rabbitTemplate.convertAndSend(
                "job.exchange", "resume.delivered", event, cd);

        // 异步回调确认结果
        cd.getFuture().whenComplete((ack, ex) -> {
            if (ack != null && ack.isAck()) {
                log.info("消息确认成功 id={}", cd.getId());
            } else {
                log.error("消息确认失败 id={}, ack={}, cause={}",
                        cd.getId(), ack, ex == null ? "unknown" : ex.getMessage());
                // 落库待重发:把消息存到本地表,定时任务补偿
                resendStore.save(cd.getId(), event);
            }
        });
    }
}

关键点:confirm 回调是异步的 ,ack.isAck() 为 false 表示发送失败,这时消息不能直接扔掉,要落一张本地表,定时任务扫表重发。

还有一个细节:publisher-returns: true + mandatory: true 是处理"消息发到了 exchange 但路由不到 queue"的情况(比如 routing key 写错)。这种情况 confirm 是成功的(消息确实到了 broker),但永远进不了队列。需要在 RabbitTemplate 设置 ReturnsCallback:

java 复制代码
rabbitTemplate.setReturnsCallback(returned -> {
    log.error("消息路由失败: exchange={}, routingKey={}, body={}",
            returned.getExchange(), returned.getRoutingKey(),
            new String(returned.getMessage().getBody()));
});

消费端:手动 ack + 重试

消费者默认是自动 ack------消息一取出来就确认,不管处理成不成功。必须改成手动 ack:

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: manual     # 手动确认
        retry:
          enabled: true              # 消费重试
          max-attempts: 3            # 最多重试 3 次
          initial-interval: 1000     # 重试间隔 1s
        default-requeue-rejected: false

default-requeue-rejected: false 很关键:重试 3 次还失败的消息,不进原队列无限重试,而是走死信。

消费者代码:

java 复制代码
@Slf4j
@Component
@RequiredArgsConstructor
public class ResumeDeliveredConsumer {

    private final ResumeService resumeService;

    @RabbitListener(queues = "job.resume.delivered.queue")
    public void onMessage(ResumeDeliveredEvent event, Channel channel, Message message) {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            resumeService.handleDelivered(event);
            channel.basicAck(deliveryTag, false);
        } catch (Exception e) {
            log.error("处理简历投递消息失败: {}", event, e);
            // 抛异常,走 Spring 重试;重试耗尽后进死信
            throw new AmqpRejectAndDontRequeueException(e);
        }
    }
}

注意 catch 里不要自己吞异常 。我之前犯过:try-catch 里打了日志就返回,结果消息自动 ack 了,业务没处理,消息丢了。正确做法:处理失败抛 AmqpRejectAndDontRequeueException,Spring 会按配置重试,重试 3 次失败后拒绝入队(不重新放回原队列),转死信。

死信队列

死信队列声明:

java 复制代码
@Configuration
public class MqConfig {

    // 业务队列,绑定死信交换机
    @Bean
    public Queue resumeDeliveredQueue() {
        return QueueBuilder.durable("job.resume.delivered.queue")
                .deadLetterExchange("job.exchange.dlx")
                .deadLetterRoutingKey("resume.delivered.dead")
                .build();
    }

    // 死信交换机 + 死信队列
    @Bean
    public DirectExchange dlxExchange() {
        return new DirectExchange("job.exchange.dlx");
    }

    @Bean
    public Queue resumeDeliveredDeadQueue() {
        return QueueBuilder.durable("job.resume.delivered.dead.queue").build();
    }

    @Bean
    public Binding deadBinding() {
        return BindingBuilder.bind(resumeDeliveredDeadQueue())
                .to(dlxExchange()).with("resume.delivered.dead");
    }
}

死信队列里的消息,就是重试 3 次还失败的,人工排查或者定时任务捞出来补偿。

踩坑记录

坑一:confirm 回调里直接抛异常

现象:ack.isAck() 为 false 时,我在回调里直接 throw,结果消息没落库,还是丢了。回调线程是 MQ 的线程池,抛异常没人接,业务补偿逻辑根本没执行。解决:回调里只做落库和日志,不抛异常。

坑二:ack 之后才写完数据库,进程崩了

现象:先 channel.basicAck 再写业务表,恰好进程崩溃,消息确认了但业务没落库。解决:先处理业务,成功后再 ack(上面代码的顺序)。ack 之后业务已经完成,才算真正可靠。

坑三:消费者异常重试 3 次全在同一秒

现象:重试配置了 initial-interval: 1000,但日志里 3 次重试间隔几乎为 0。排查发现 retry.enabled 配在 listener.simple 下,但项目里用的是容器工厂手动创建的 listener,配置没生效。解决:确认 spring.rabbitmq.listener.simple.retry 的配置层级正确,或者直接注入 RetryInterceptorBuilder 手动配置。

坑四:死信消息又回到原队列死循环

现象:死信队列消息一直堆积,同时原队列也有重复消息。定位:default-requeue-rejected 没配,重试失败的消息被重新放回原队列,无限循环。解决:配 default-requeue-rejected: false,失败进死信而不是原队列。

可直接复用

  • 生产端:publisher-confirm-type: correlated + confirm 回调落库补偿
  • 路由失败:publisher-returns: true + mandatory: true + ReturnsCallback
  • 消费端:手动 ack,业务成功才 ack,失败抛 AmqpRejectAndDontRequeueException
  • 重试 3 次失败进死信队列,default-requeue-rejected: false
  • 死信队列消息做人工补偿或定时重放

消息可靠性本质是"发送确认 + 消费确认 + 兜底补偿"三件套,每一环都不能省。这套在求职招聘系统里扛住了线上流量,照着配就行。

相关推荐
驭渊的小故事1 小时前
RabbitMQ 环境配置和 DirectRabbitConfig 的 SpringBoot 4.0.X 版本
spring boot·rabbitmq·java-rabbitmq
ly768911 小时前
RabbitMQ 消费确认与预取:manual ack、reject、nack 与 QoS 的取舍
rabbitmq·死信队列·消息可靠性·消费确认·qos 预取
灯澜忆梦11 小时前
【RabbitMQ #7】 | 消息转换器
分布式·rabbitmq·ruby
灯澜忆梦18 小时前
【RabbitMQ #4】 | Work 任务模型
分布式·rabbitmq
imDwAaY8 天前
消息队列四大核心问题:顺序性、幂等性、可靠性与一致性
学习·kafka·rabbitmq
手握风云-9 天前
一条消息的旅程:RabbitMQ 学习与实践(五)
rabbitmq·java-rabbitmq
szephyr10 天前
消息队列入门:RabbitMQ 和 Kafka 到底怎么选,什么时候不该用
后端·架构·kafka·消息队列·rabbitmq
Msshu12311 天前
什么是PD快充诱骗取电芯片,PD快充取电芯片如何选型
flink·rabbitmq·ambari·mariadb·storm·talkingdata
程序员黎剑11 天前
RabbitMQ-消息可靠性-publisher-confirm持久化与手动ack
分布式·rabbitmq·ruby