【RabbitMQ】重试机制 && TTL && 死信队列

文章目录

一、重试机制

在消息传递过程中,可能会遇到各种问题,如网络故障、服务不可用、资源不足等,这些问题可能导致消息处理失败。为了解决这些问题,RabbitMQ 提供了重试机制,允许消息在处理失败后重新发送

但如果是程序逻辑引起的错误,那么多次重试也是没有用的,当然也可以设置重试次数。

这里的 retry(重试)机制 通常指的是 消息从队列发送给消费者后,消费失败或者未 ack 时触发的重发。而不是生产者发送给交换机时候失败触发的,这种触发是发布确认机制来解决的。

一、自动确认下的重试机制

  1. 重试配置

💥要启用 消费者消息重试机制 ,必须打开消息确认机制中的 AUTO 才行!

模式 Spring自动重试机制 说明
NONE ❌ 无法重试(已确认) RabbitMQ立即确认消息
AUTO ✅ 生效 Spring 捕获异常自动重试
MANUAL ❌ 不生效 需自己实现重试逻辑
yaml 复制代码
spring:
  rabbitmq:
    addresses: amqp://liren:123123@127.0.0.1/lirendada
    listener:
      simple:
        acknowledge-mode: auto  # 消息确认设置为auto,如果处理消息出现异常,会自动进行重试
        retry:
          enabled: true             # 开启消费者失败重试
          initial-interval: 5000ms  # 初始失败等待时长为5秒
          max-attempts: 5           # 最大重试次数(包括自身消费的一次)
  1. 配置交换机 && 队列

首先是常量类:

java 复制代码
// 重试机制
public static final String RETRY_EXCHANGE_NAME = "retry_exchange";
public static final String RETRY_QUEUE = "retry_queue";

然后配置以及绑定交换机和队列:

java 复制代码
// 重试机制
@Bean("retryQueue")
public Queue retryQueue() {
    return QueueBuilder.durable(Constants.RETRY_QUEUE).build();
}

@Bean("retryExchange")
public DirectExchange retryExchange() {
    return ExchangeBuilder.directExchange(Constants.RETRY_EXCHANGE_NAME).durable(true).build();
}

@Bean("retryBinding")
public Binding retryBinding(@Qualifier("retryQueue")Queue queue,
                            @Qualifier("retryExchange")Exchange exchange) {
    return BindingBuilder.bind(queue).to(exchange).with("retry").noargs();
}
  1. 发送消息
java 复制代码
@RequestMapping("/retry")
public String retry() {
    rabbitTemplate.convertAndSend(Constants.RETRY_EXCHANGE_NAME, "retry", "retry test...");
    return "发送成功!";
}
  1. 消费消息
java 复制代码
import com.bite.rabbitmq.constant.Constant;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
public class RetryQueueListener {
    //指定监听队列的名称
    @RabbitListener(queues = Constant.RETRY_QUEUE)
    public void ListenerQueue(Message message) throws Exception {
        System.out.printf("接收到消息: %s, deliveryTag: %d%n", new String(message.getBody(),"UTF-8"),
            message.getMessageProperties().getDeliveryTag());
        //模拟处理失败
        int num = 3/0;
        System.out.println("处理完成");
    }
}
  1. 运行程序,观察结果

http://127.0.0.1:8080/product/retry

但是如果对异常进行捕获了,那么就不会进行重试!

二、手动确认下的重试机制

将消息确认机制改为手动 manual 模式,然后修改消费者代码:

java 复制代码
@RabbitListener(queues = Constants.RETRY_QUEUE)
public void ListenerQueue(Message message, Channel channel) throws IOException, InterruptedException {
    long deliveryTag = message.getMessageProperties().getDeliveryTag();
    System.out.printf("接收到消息:%s,deliveryTag:%d \n", new String(message.getBody()), deliveryTag);

    try {
        // 模拟出现异常
        int a = 3 / 0;
        System.out.println("处理完成!");
        channel.basicAck(deliveryTag, true); // 手动确认一下
    } catch (Exception e) {
        System.out.println("出现异常!");
        Thread.sleep(1000);
        channel.basicNack(deliveryTag, true, true); // 设置重新入队
    }
}

可以看到,手动确认模式时,重试次数的限制不会像在自动确认模式下那样直接生效,因为是否重试以及何时重试更多地取决于应用程序的逻辑和消费者的实现。

自动确认模式下,RabbitMQ 会在消息被投递给消费者后自动确认消息。如果消费者处理消息时抛出异常,RabbitMQ 根据配置的重试参数自动将消息重新入队,从而实现重试。重试次数和重试间隔等参数可以直接在 RabbitMQ 的配置中设定,并且 RabbitMQ 会负责执行这些重试策略。

而在手动确认模式下,消费者需要显式地对消息进行确认。如果消费者在处理消息时遇到异常,可以选择不确认消息使消息可以重新入队。重试的控制权在于应用程序本身,而不是 RabbitMQ 的内部机制。应用程序可以通过自己的逻辑和利用 RabbitMQ 的高级特性来实现有效的重试策略。

💡 使用重试机制时需要注意:

  1. 自动确认模式下:程序逻辑异常,多次重试还是失败,消息会自动确认,然后丢失
  2. 手动确认模式下:程序逻辑异常,多次重试消息依然处理失败,无法被确认,就一直是 unacked 的状态,导致消息积压

二、TTL

TTL(Time to Live)即过期时间。RabbitMQ 可以对消息和队列设置 TTL。

当消息达到存活时间之后,若还没有被消费,就会被自动清除。

咱们在网上购物,经常会遇到一个场景,当下单超过24小时还未付款,订单会被自动取消。还有类似的,申请退款之后,超过7天未被处理,则自动退款。

有两种方法设置消息的TTL:

  1. 队列级 TTL(队列中所有消息生效)

    yaml 复制代码
       # 配置文件
       arguments:
         x-message-ttl: 60000   # 毫秒
  2. 消息级 TTL(每条消息单独设置)

    java 复制代码
       MessageProperties props = new MessageProperties();
       props.setExpiration("60000"); // 毫秒

如果两种方法一起使用,则消息的 TTL 以两者之间较小的那个数值为准

一、单独设置消息的TTL

针对每条消息设置 TTL 的方法是在发送消息的方法中设置 expiration 属性参数,单位为毫秒

  • 如果不设置 TTL,则表示此消息不会过期
  • 如果将 TTL 设置为 0,则表示除非此时可以直接将消息投递到消费者,否则该消息会被立即丢弃
  1. 常量类:

    java 复制代码
       // TTL
       public static final String TTL_TIME = "10000"; // 10s
       public static final String TTL_EXCHANGE_NAME = "ttl_exchange";
       public static final String TTL_QUEUE = "ttl_queue";
  2. 配置以及绑定交换机与队列:

    java 复制代码
       // TTL
       @Bean("ttlQueue")
       public Queue ttlQueue() {
           return QueueBuilder.durable(Constants.TTL_QUEUE).build();
       }
       
       @Bean("ttlExchange")
       public DirectExchange ttlExchange() {
           return ExchangeBuilder.directExchange(Constants.TTL_EXCHANGE_NAME).durable(true).build();
       }
       
       @Bean("ttlBinding")
       public Binding ttlBinding(@Qualifier("ttlQueue")Queue queue,
                                   @Qualifier("ttlExchange")Exchange exchange) {
           return BindingBuilder.bind(queue).to(exchange).with("ttl").noargs();
       }
  3. 发送消息:

    java 复制代码
       @RequestMapping("/ttl")
       public String ttl() {
           MessagePostProcessor messagePostProcessor = new MessagePostProcessor() {
               @Override
               public Message postProcessMessage(Message message) throws AmqpException {
                   message.getMessageProperties().setExpiration(Constants.TTL_TIME);
                   return message;
               }
           };
       
           rabbitTemplate.convertAndSend(Constants.TTL_EXCHANGE_NAME, "ttl", "ttl test...", messagePostProcessor);
           return "发送成功!";
       }
       
       // 另一种写法:因为 MessagePostProcessor 是函数式接口,所以可以用lambda简化
       @RequestMapping("/ttl")
       public String ttl() {
           rabbitTemplate.convertAndSend(Constants.TTL_EXCHANGE_NAME, "ttl", "ttl test...", message -> {
               message.getMessageProperties().setExpiration(Constants.TTL_TIME);
               return message;
           });
           return "发送成功!";
       }

发送消息后可以到管理页面观察队列,可以发现 10s 后队列中的消息就消失了!

二、设置队列的TTL

直接在创建队列的时候使用封装好的 ttl() 方法即可设置队列中的消息过期时间,单位是毫秒

java 复制代码
@Bean("ttlQueue2")
public Queue ttlQueue2() {
    return QueueBuilder.durable(Constants.TTL_QUEUE).ttl(10000).build();
}

实际上设置队列 TTL 的原理,是在创建队列时加入 x-message-ttl 参数实现的,下面是源码:

三、两者区别

  • 设置队列 TTL 属性的方法,一旦消息过期,就会从队列中删除
  • 单独设置消息 TTL 的方法,即使消息过期,也不会马上从队列中删除,而是当投递到消费者之前进行判定为过期了才删除的

为什么这两种方法处理的方式不一样???

因为 RabbitMQ 的队列不是扫描式的,而是顺序读取式队列

消息存放在队列中时,只有在它到达队列头部(准备被投递给消费者)时,RabbitMQ 才会检查它是否已过期。

换句话说:RabbitMQ 不会遍历整个队列去找 "已经过期" 的消息。只有 "轮到要被投递的消息" 时,才判断是否过期。

因此两者处理结果不同:

  • 如果消息在队列中排得很靠后,它可能在过期后很久才被清理掉。
  • 清理时是 "惰性删除":当检测到已过期 → 丢弃并(可选)发送到死信交换机(DLX)。

三、死信队列

一、死信的概念

死信就是因为种种原因,而导致的无法被消费的信息

有死信,自然就有死信队列。当消息在一个队列中变成死信之后,它能被重新被发送到另一个交换器中,这个交换器就是 DLX(Dead Letter Exchange),绑定 DLX 的队列,就称为死信队列 DLQ(Dead Letter Queue)。

消息变成死信通常有以下几种可能:

  1. 消息被拒绝(Basic.Reject/Basic.Nack),并且设置 requeue 参数为 false
  2. 消息过期
  3. 队列达到最大长度,消息溢出

二、代码示例

1. 声明配置队列和交换机

包含两部分:

  • 声明正常的队列和正常的交换机
  • 声明死信队列和死信交换机

死信交换机/队列 和 普通的交换机/队列 没有区别,只是处理的事情不同罢了!

首先是常量类:

java 复制代码
// 死信
public static final String NORMAL_EXCHANGE = "normal_exchange";
public static final String NORMAL_QUEUE = "normal_queue";
public static final String DL_EXCHANGE = "dl_exchange";
public static final String DL_QUEUE = "dl_queue";

然后声明以及配置交换机和队列:

java 复制代码
// 正常队列
@Bean("normalQueue")
public Queue normalQueue() {
    return QueueBuilder
            .durable(Constants.NORMAL_QUEUE)
            .deadLetterExchange(Constants.DL_EXCHANGE) // 绑定死信交换机
            .deadLetterRoutingKey("dlk")               // 绑定死信路由键
            .ttl(10000)                                // 过期时间设置10s,方便测试
            .maxLength(10L)                            // 队列最大长度设为10,方便测试
            .build();
}

@Bean("normalExchange")
public DirectExchange normalExchange() {
    return ExchangeBuilder.directExchange(Constants.NORMAL_EXCHANGE).durable(true).build();
}

@Bean("normalBinding")
public Binding normalBinding(@Qualifier("normalQueue")Queue queue,
                          @Qualifier("normalExchange")Exchange exchange) {
    return BindingBuilder.bind(queue).to(exchange).with("normal").noargs();
}

// 死信队列
@Bean("dlQueue")
public Queue dlQueue() {
    return QueueBuilder.durable(Constants.DL_QUEUE).build();
}

@Bean("dlExchange")
public DirectExchange dlExchange() {
    return ExchangeBuilder.directExchange(Constants.DL_EXCHANGE).durable(true).build();
}

@Bean("dlBinding")
public Binding dlBinding(@Qualifier("dlQueue")Queue queue,
                         @Qualifier("dlExchange")Exchange exchange) {
    return BindingBuilder.bind(queue).to(exchange).with("dlk").noargs();
}

2. 发送消息

java 复制代码
@RequestMapping("/dlx")
public String dlx() {
    // 1. 测试过期时间, 当时间达到TTL, 消息自动进入到死信队列
    rabbitTemplate.convertAndSend(Constants.NORMAL_EXCHANGE, "normal", "dlx test...");

    // 2. 测试队列长度溢出,消息自动进入到死信队列
    for(int i = 0; i < 20; ++i) {
        rabbitTemplate.convertAndSend(Constants.NORMAL_EXCHANGE, "normal", "dlx test...");
    }

    return "发送成功!";
}

3. 测试死信

① 程序启动之后,观察队列
  • D:队列设置了持久化机制
  • TTL:队列设置了消息过期时间
  • Lim:队列设置了长度(x-max-length)
  • DLX:队列设置了死信交换机(x-dead-letter-exchange)

DLK:队列设置了死信路由键(x-dead-letter-routing-key)

② 测试过期时间,到达过期时间之后,进入死信队列

发送消息:http://127.0.0.1:8080/product/dlx

发送之后:

10秒后,消息进入到死信队列:

生产者首先发送一条消息,然后经过交换器(normal_exchange)顺利地存储到队列(normal_queue)中。由于队列 normal_queue 设置了过期时间为 10s,在这 10s 内没有消费者消费这条消息,那么判定这条消息过期。由于设置了 DLX,过期之时,消息会被丢给交换器(dl_exchange)中,这时根据 RoutingKey 匹配,找到匹配的队列(dl_queue),最后消息被存储在 queue.dlx 这个死信队列中。

③ 测试达到队列长度,消息进入死信队列

队列长度设置为 10,我们发送 20 条数据,会有 10 条数据直接进入到死信队列

发送前,死信队列只有一条数据:

运行后,可以看到死信队列变成了 11 条:

过期之后,正常队列的 10 条也会进入到死信队列:

④ 测试消息拒收

写消费者代码,并强制异常,测试拒绝签收:

java 复制代码
@Component
public class DLListener {
    // 监听正常队列
    @RabbitListener(queues = Constants.NORMAL_QUEUE)
    public void normalQueue(Message message, Channel channel) throws InterruptedException, IOException {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        try {
            System.out.printf("接收到消息: %s, deliveryTag: %d\n", new String(message.getBody()), deliveryTag);

            // 模拟处理失败
            int num = 3/0;
            System.out.println("处理完成");

            // 手动确认
            channel.basicAck(deliveryTag, true);
        }catch (Exception e){
            // 第三个参数requeue决定是否重新入队,如果为true,则会重新发送;若为false,则直接丢弃,若此时设置了死信,会进入到死信队列
            channel.basicNack(deliveryTag, true,false);
        }
    }

    // 监听死信队列
    @RabbitListener(queues = Constants.DL_QUEUE)
    public void dlQueue(Message message) {
        long deliveryTag = message.getMessageProperties().getDeliveryTag();
        System.out.printf("死信队列接收到消息: %s, deliveryTag: %d\n", new String(message.getBody()), deliveryTag);
    }
}

三、常见面试题💥

1. 死信队列的概念

死信就是因为种种原因,而导致的无法被消费的信息

2. 死信的来源

  1. 消息被拒绝(Basic.Reject/Basic.Nack),并且设置 requeue 参数为 false
  2. 消息过期
  3. 队列达到最大长度,消息溢出

3. 死信队列的应用场景

对于 RabbitMQ 来说,死信队列是一个非常有用的特性。它可以处理异常情况下,消息不能够被消费者正确消费而被置入死信队列中的情况,应用程序可以通过消费这个死信队列中的内容来分析当时所遇到的异常情况,进而可以改善和优化系统。

比如:用户支付订单之后,支付系统会给订单系统返回当前订单的支付状态。

为了保证支付信息不丢失,需要使用到死信队列机制。当消息消费异常时,将消息投入到死信队列中,由订单系统的其他消费者来监听这个队列,并对数据进行处理(比如发送工单等,进行人工确认)。

场景的应用场景还有:

  • 消息重试:将死信消息重新发送到原队列或另一个队列进行重试处理。
  • 消息丢弃:直接丢弃这些无法处理的消息,以避免它们占用系统资源。
  • 日志收集:将死信消息作为日志收集起来,用于后续分析和问题定位。
相关推荐
indexsunny2 小时前
互联网大厂Java面试实战:核心技术与微服务架构解析
java·数据库·spring boot·缓存·微服务·面试·消息队列
想用offer打牌2 小时前
非常好用的工具: curl
java·后端·github
程序员清风2 小时前
贝壳一面:Spring是怎么实现的?谈谈你的理解?
java·后端·面试
季风11322 小时前
29.Axon框架-事件(七)
后端·领域驱动设计
白衣鸽子2 小时前
Java 内存模型(JMM):happens-before 原则
后端
talle20212 小时前
Hadoop分布式资源管理框架【Yarn】
大数据·hadoop·分布式
IMPYLH2 小时前
Lua 的 Package 模块
java·开发语言·笔记·后端·junit·游戏引擎·lua
sunnyday04262 小时前
API安全防护:签名验证与数据加密最佳实践
java·spring boot·后端·安全
Amos_Web2 小时前
Rust实战(五):用户埋点数据分析(前)
后端·架构·rust