RabbitMQ 死信队列

原文(部分调整):【RabbitMQ】一文带你搞定 RabbitMQ 死信队列

一、引言

RabbitMQ 是一个流行的开源消息队列系统,基于 Erlang 语言开发。它以其活跃的社区、快速的更新维护、稳定的性能,赢得了众多企业的青睐。

在处理订单等核心业务时,为确保消息数据不丢失,RabbitMQ 的死信队列机制显得尤为重要。当消息消费发生异常时,可以将消息投入死信队列中进行后续处理。然而,由于对死信队列的概念及配置不熟悉,笔者在初次实践时曾遇到诸多困扰。许多看似可行的解决方案并未能有效解决实际问题。最终,通过查阅 RabbitMQ 官方文档,我获得了所需的准确信息,并发现之前对死信队列存在一些误解,这导致了配置过程中的诸多挑战。

因此,本文旨在记录并分享死信队列的概念与配置方法,希望能帮助遇到同样问题的朋友们。

二、本文大纲

本文的结构大纲如下:

RabbitMQ 死信队列配置流程图

本文侧重于实战配置讲解,建议读者对 RabbitMQ 有基本了解。

三、死信队列是什么?

在 RabbitMQ 官方文档中,死信对应的英文术语是Dead Letter。那么,何为死信?

“死信”是 RabbitMQ 中的一种消息机制。当消息在队列中出现以下情况时,它将被视为“死信”:

  1. 消息被否定确认(negative acknowledged),即消费者使用channel.basicNackchannel.basicReject方法,并且此时requeue属性被设置为false
  2. 消息在队列中的存活时间超过了设置的TTL(Time-To-Live)时间。
  3. 消息队列的消息数量已经超过了最大队列长度(max-length)。

此时,该消息将成为“死信”。如果已配置死信队列,该消息将被投递至死信队列;否则,该消息将被丢弃。

四、如何配置死信队列

本节将详细阐述 RabbitMQ 死信队列的配置方法。其配置步骤概括如下:

  1. 配置业务队列,并将其绑定到业务交换机上。
  2. 为业务队列配置死信交换机和死信路由 key。
  3. 为死信交换机配置死信队列。

值得注意的是,死信消息并非自动汇集到一个公共死信队列中。正确的做法是为每个需要使用死信机制的业务队列配置一个死信交换机。在同一项目中,多个业务队列可以共用一个死信交换机,但需为每个业务队列分配一个独立的路由 key。

在确定了死信交换机和路由 key 后,接下来需要配置死信队列,并将其绑定到对应的死信交换机上,其配置方式与业务队列类似。换言之,死信队列并非特殊队列,它只是绑定到死信交换机上的普通队列;同样,死信交换机也并非特殊交换机,它仅用于接收死信消息,可以是任何类型的交换机(如 Direct、Fanout、Topic)。通常情况下,我们会为每个业务队列分配一个独有的路由 key,并相应地配置一个死信队列进行监听。这意味着,对于重要的业务队列,通常会为其配置一个专属的死信队列。

在理解上述概念后,我们将进入实战配置环节,本文将省略 RabbitMQ 环境的部署与搭建过程。

首先,创建一个 Spring Boot 项目。然后在 POM 文件中添加spring-boot-starter-amqpspring-boot-starter-web依赖。然后创建一个 Config 类,这是配置的关键部分:

@Configuration
public class RabbitMQConfig {

    public static final String BUSINESS_EXCHANGE_NAME = "dead.letter.demo.simple.business.exchange";
    public static final String BUSINESS_QUEUEA_NAME = "dead.letter.demo.simple.business.queuea";
    public static final String BUSINESS_QUEUEB_NAME = "dead.letter.demo.simple.business.queueb";
    public static final String DEAD_LETTER_EXCHANGE = "dead.letter.demo.simple.deadletter.exchange";
    public static final String DEAD_LETTER_QUEUEA_ROUTING_KEY = "dead.letter.demo.simple.deadletter.queuea.routingkey";
    public static final String DEAD_LETTER_QUEUEB_ROUTING_KEY = "dead.letter.demo.simple.deadletter.queueb.routingkey";
    public static final String DEAD_LETTER_QUEUEA_NAME = "dead.letter.demo.simple.deadletter.queuea";
    public static final String DEAD_LETTER_QUEUEB_NAME = "dead.letter.demo.simple.deadletter.queueb";

    // 声明业务 Exchange
    @Bean("businessExchange")
    public FanoutExchange businessExchange(){
        return new FanoutExchange(BUSINESS_EXCHANGE_NAME);
    }

    // 声明死信 Exchange
    @Bean("deadLetterExchange")
    public DirectExchange deadLetterExchange(){
        return new DirectExchange(DEAD_LETTER_EXCHANGE);
    }

    // 声明业务队列 A
    @Bean("businessQueueA")
    public Queue businessQueueA(){
        Map<String, Object> args = new HashMap<>(2);
        // x-dead-letter-exchange:声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        // x-dead-letter-routing-key:声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", DEAD_LETTER_QUEUEA_ROUTING_KEY);
        return QueueBuilder.durable(BUSINESS_QUEUEA_NAME).withArguments(args).build();
    }

    // 声明业务队列 B
    @Bean("businessQueueB")
    public Queue businessQueueB(){
        Map<String, Object> args = new HashMap<>(2);
        // x-dead-letter-exchange:声明当前队列绑定的死信交换机
        args.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        // x-dead-letter-routing-key:声明当前队列的死信路由 key
        args.put("x-dead-letter-routing-key", DEAD_LETTER_QUEUEB_ROUTING_KEY);
        return QueueBuilder.durable(BUSINESS_QUEUEB_NAME).withArguments(args).build();
    }

    // 声明死信队列 A
    @Bean("deadLetterQueueA")
    public Queue deadLetterQueueA(){
        return new Queue(DEAD_LETTER_QUEUEA_NAME);
    }

    // 声明死信队列 B
    @Bean("deadLetterQueueB")
    public Queue deadLetterQueueB(){
        return new Queue(DEAD_LETTER_QUEUEB_NAME);
    }

    // 声明业务队列 A 绑定关系
    @Bean
    public Binding businessBindingA(@Qualifier("businessQueueA") Queue queue,
                                    @Qualifier("businessExchange") FanoutExchange exchange){
        return BindingBuilder.bind(queue).to(exchange);
    }

    // 声明业务队列 B 绑定关系
    @Bean
    public Binding businessBindingB(@Qualifier("businessQueueB") Queue queue,
                                    @Qualifier("businessExchange") FanoutExchange exchange){
        return BindingBuilder.bind(queue).to(exchange);
    }

    // 声明死信队列 A 绑定关系
    @Bean
    public Binding deadLetterBindingA(@Qualifier("deadLetterQueueA") Queue queue,
                                      @Qualifier("deadLetterExchange") DirectExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(DEAD_LETTER_QUEUEA_ROUTING_KEY);
    }

    // 声明死信队列 B 绑定关系
    @Bean
    public Binding deadLetterBindingB(@Qualifier("deadLetterQueueB") Queue queue,
                                      @Qualifier("deadLetterExchange") DirectExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with(DEAD_LETTER_QUEUEB_ROUTING_KEY);
    }
}

上述配置声明了两个 Exchange:一个业务 Exchange 和一个死信 Exchange。业务 Exchange 下绑定了两个业务队列,这两个业务队列都配置了同一个死信 Exchange,并分别配置了不同的死信路由 key。最后,在死信 Exchange 下绑定了两个死信队列,其路由 key 分别与业务队列中配置的死信路由 key 相对应。图示如下:

image-20251211132651497

下面是 application.yml 配置文件:

spring:
  rabbitmq:
    host: localhost
    password: guest
    username: guest
    listener:
      type: simple
      simple:
          default-requeue-rejected: false
          acknowledge-mode: manual

请注意将default-requeue-rejected属性设置为false

接下来,是业务队列的消费代码:

@Slf4j
@Component
public class BusinessMessageReceiver {

    @RabbitListener(queues = BUSINESS_QUEUEA_NAME)
    public void receiveA(Message message, Channel channel) throws IOException {
        String msg = new String(message.getBody());
        log.info("收到业务消息 A:{}", msg);
        boolean ack = true;
        Exception exception = null;
        try {
            if (msg.contains("deadletter")){
                throw new RuntimeException("dead letter exception");
            }
        } catch (Exception e){
            ack = false;
            exception = e;
        }
        if (!ack){
            log.error("消息消费发生异常,error msg:{}", exception.getMessage(), exception);
            channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
        } else {
            channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
        }
    }

    @RabbitListener(queues = BUSINESS_QUEUEB_NAME)
    public void receiveB(Message message, Channel channel) throws IOException {
        log.info("收到业务消息 B:{}", new String(message.getBody()));
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

然后配置死信队列的消费者:

@Component
public class DeadLetterMessageReceiver {

    @RabbitListener(queues = DEAD_LETTER_QUEUEA_NAME)
    public void receiveA(Message message, Channel channel) throws IOException {
        log.info("收到死信消息 A:{}", new String(message.getBody()));
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }

    @RabbitListener(queues = DEAD_LETTER_QUEUEB_NAME)
    public void receiveB(Message message, Channel channel) throws IOException {
        log.info("收到死信消息 B:{}", new String(message.getBody()));
        channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
    }
}

最后,为了方便测试,编写一个简单的消息生产者,并通过 Controller 层来发送消息。

@Component
public class BusinessMessageSender {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    public void sendMsg(String msg){
        rabbitTemplate.convertSendAndReceive(BUSINESS_EXCHANGE_NAME, "", msg);
    }
}
@RequestMapping("rabbitmq")
@RestController
public class RabbitMQMsgController {

    @Autowired
    private BusinessMessageSender sender;

    @RequestMapping("sendmsg")
    public void sendMsg(String msg){
        sender.sendMsg(msg);
    }
}

一切准备就绪,启动应用程序!

可以从 RabbitMQ 的管理后台中看到一共有四个队列,以及除了默认 Exchange 外,还声明的两个 Exchange。

RabbitMQ Queues
RabbitMQ Exchanges

接下来,访问以下 URL 来测试:

http://localhost:8080/rabbitmq/sendmsg?msg=msg

日志输出:

收到业务消息 A:msg
收到业务消息 B:msg

这表示两个消费者都正常收到了消息。这代表正常消费的消息,ack 后正常返回。然后我们再来测试被 nack 的消息。

http://localhost:8080/rabbitmq/sendmsg?msg=deadletter

这将会触发业务队列 A 的 nack 操作。按照预期,消息被 nack 后,会投递到死信队列中,因此死信队列将会出现这个消息。日志如下:

收到业务消息 A:deadletter
消息消费发生异常,error msg:dead letter exception
java.lang.RuntimeException: dead letter exception
...
收到死信消息 A:deadletter

可以看到,死信队列的消费者接收到了这个消息,至此流程已打通。

五、死信消息的变化

那么,当“死信”被投递到死信队列后,其消息属性会发生哪些变化呢?

如果队列配置了参数x-dead-letter-routing-key的话,死信的路由 key 将会被替换成该参数对应的值。如果没有设置,则保留该消息原有的路由 key。

例如:

如果原有消息的路由 key 是testA,被发送到业务 Exchange 中,然后被投递到业务队列QueueA中。如果该队列没有配置参数x-dead-letter-routing-key,则该消息成为死信后,将保留原有的路由 key testA。如果配置了该参数,并且值设置为testB,那么该消息成为死信后,路由 key 将会被替换为testB,然后被投递到死信交换机中。

此外,由于被投递到死信交换机,所以消息的 Exchange 名称也会被替换为死信交换机的名称。

消息的 Header 中会添加一些额外的字段。修改一下上面的代码,在死信队列的消费者中添加一行日志输出:

log.info("死信消息 properties:{}", message.getMessageProperties());

然后重新运行一次,即可得到死信消息 Header 中被添加的信息:

死信消息 properties:MessageProperties [headers={x-first-death-exchange=dead.letter.demo.simple.business.exchange, x-death=[{reason=rejected, count=1, exchange=dead.letter.demo.simple.business.exchange, time=Sun Jul 14 16:48:16 CST 2019, routing-keys=[], queue=dead.letter.demo.simple.business.queuea}], x-first-death-reason=rejected, x-first-death-queue=dead.letter.demo.simple.business.queuea}, correlationId=1, replyTo=amq.rabbitmq.reply-to.g2dkABZyYWJiaXRAREVTS1RPUC1DUlZGUzBOAAAPQAAAAAAB.bLbsdR1DnuRSwiKKmtdOGw==, contentType=text/plain, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=false, receivedExchange=dead.letter.demo.simple.deadletter.exchange, receivedRoutingKey=dead.letter.demo.simple.deadletter.queuea.routingkey, deliveryTag=1, consumerTag=amq.ctag-NSp18SUPoCNvQcoYoS2lPg, consumerQueue=dead.letter.demo.simple.deadletter.queuea]

Header 中看起来有很多信息,实际上并不多,只是值比较长而已。下面简单说明一下 Header 中的关键字段:

字段名 含义
x-first-death-exchange 第一次被投递到死信交换机的名称。
x-first-death-reason 第一次成为死信的原因。rejected:消息在重新进入队列时被拒绝,由于default-requeue-rejected参数被设置为falseexpired:消息过期。maxlen:队列内消息数量超过队列最大容量。
x-first-death-queue 第一次成为死信前所在队列的名称。
x-death 历次被投入死信交换机的信息列表。同一个消息每次进入一个死信交换机,这个数组的信息就会被更新。

六、死信队列应用场景

通过上述内容,我们已经了解了死信队列的使用方法。那么,死信队列通常适用于哪些场景呢?

死信队列主要用于确保未被正确消费的消息不会丢失,尤其在重要的业务队列中。消费异常的常见原因可能包括消息信息本身存在错误导致处理异常、处理过程中参数校验异常,或者因网络波动导致的查询异常等。显然,每次都通过日志获取原始消息并请求运维人员重新投递是低效且不可取的(这曾是笔者团队的处理方式)。通过配置死信队列,可以将未能正确处理的消息暂存起来。待问题排查清楚后,可以编写相应的处理代码来处理这些死信消息,这种方式相比手动恢复数据具有显著优势。

七、总结

死信队列并非神秘机制,它本质上是绑定到死信交换机上的普通队列,而死信交换机也只是一个用于专门处理死信的普通交换机。

死信消息的生命周期可总结如下:

  1. 业务消息被投递到业务队列。
  2. 消费者消费业务队列的消息,由于处理过程中发生异常,于是进行了 nack 或 reject 操作。
  3. 被 nack 或 reject 的消息由 RabbitMQ 投递到死信交换机中。
  4. 死信交换机将消息投递到相应的死信队列。
  5. 死信队列的消费者消费死信消息。

死信消息是 RabbitMQ 为我们提供的一层可靠性保证。实际上,我们也可以选择不使用死信队列,而是在消息消费异常时,主动将消息投递到另一个交换机。理解这些基本原理后,Exchange 和 Queue 的组合使用将变得更加灵活。例如,可以从死信队列拉取消息,并通过邮件、短信或钉钉等方式通知开发人员关注;或者将消息重新投递到一个设置了过期时间的队列,以实现延迟消费。

posted @ 2025-12-11 13:44  Higurashi-kagome  阅读(15)  评论(0)    收藏  举报