【RabbitMQ高级篇】生产者可靠性、MQ可靠性、消费者可靠性以及延迟队列的实现

【RabbitMQ高级篇】RabbitMQ从入门到实战


1,前言

在上一期的 【RabbitMQ基础篇】中,我们从零起步,详细拆解了RabbitMQ的核心概念,掌握了Spring AMQP环境下消息的收发、Work模型、三种交换机(Fanout/Direct/Topic)的路由策略以及JSON消息转换器的配置。

然而,基础的"能用"只是第一步。在实际生产环境中,我们更关心的是:消息发出去丢了怎么办?消费者挂了消息会不会没处理?如何实现订单超时自动取消?

本篇《RabbitMQ高级篇》将深入探索生产者与消费者的可靠性机制,逐步演示实现延迟消息,助你构建坚如磐石的异步通信架构。


2,消息可靠性问题

MQ的消息发送可能会存在可靠性问题。以如上开发场景为例:

用户支付成功之后,通过用户服务扣减余额,同时在支付服务中更新支付状态。随后用 MQ 通知交易服务更新订单状态。其中可能会出现可靠性问题,比如:

  • 支付成功之后 ,支付服务和 MQ 之间的网络交互出现故障,消息无法到达 MQ;
  • 支付服务成功发送消息到 MQ,但 MQ 还没来得及投递给交易服务自己就宕机了;
  • 交易服务成功监听到 MQ 中的消息,但处理到一半交易服务突然宕机;

此时就会导致:正常扣减了用户余额,成功更新了支付状态,但订单状态仍为未支付。出现状态不一致的问题。

消息可靠性要求是指一条消息从生产者发出,到被消费者成功处理,在整个链路中 "不丢失、不重复、且最终被正确消费" 的能力。

我们要解决消息丢失问题,保证MQ的可靠性,就必须从3个方面入手:

  • 确保生产者一定把消息发送到 MQ;
  • 确保 MQ 不会将消息弄丢;
  • 确保消费者一定要处理消息;

3,生产者的可靠性

生产者的可靠性 主要是确保生产者能正常把消息发送到MQ。生产者的可靠性主要通过生产者重连生产者确认 实现。


3.1,生产者重连

有时候由于网络波动,可能会出现客户端连接 MQ 失败的情况。为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。即:当RabbitTemplate与MQ连接超时后,多次重试。

修改publisher生产者模块的application.yaml文件,添加下面的内容:

具体配置信息如下:

yaml 复制代码
spring:
  rabbitmq:
    connection-timeout: 1s # 设置MQ的连接超时时间
    template:
      retry:
        enabled: true # 开启超时重试机制
        initial-interval: 1000ms # 失败后的初始等待时间。失败后等待多久时间重试
        multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
        max-attempts: 3 # 最大重试次数

增加配置之后,停掉 RabbitMQ 服务,执行如下单元测试方法向 MQ 发送消息:

java 复制代码
    @Test
    void testSendMessage2Queue() {
        String queueName = "simple.queue";
        String msg = "hello, amqp!";
        rabbitTemplate.convertAndSend(queueName, msg);
    }

此时查看控制台信息,如下图所示:

可以看到每隔2s重试一次(耗时1s发现超时,又等待1s再次进行重试),一共重试三次。和配置信息保持一致。

需要注意的是,这种重试是阻塞式重试,发生重试时后续的业务会阻塞,显然这样会业务影响性能。因此对业务性能有要求的场景下建议禁用重试机制,或考虑使用异步线程执行发送消息的代码。


3.2,生产者确认

RabbitMQ 提供了 Publisher ConfirmPublisher Return 两种确认机制,Publisher Confirm 用来返回回执信息,Publisher Return 返回路由失败信息。开启生产者确认机制后,在 MQ 成功收到消息后会返回确认消息给生产者。返回的结果有以下几种情况:

  • 当消息投递到MQ,但是路由失败。比如收到消息的交换机没有绑定队列,此时会通过 Publisher Return 返回路由异常信息,同时返回ack的确认信息,代表投递成功;
  • 临时消息(non durable)投递到了MQ,并且入队成功,返回ACK,告知投递成功;
  • 持久消息(durable)投递到了MQ,并且入队完成持久化保存,之后返回ACK ,告知投递成功;
  • 其它情况都会返回NACK,告知投递失败;

注意:由于开启生产者确认比较消耗MQ性能,一般不建议开启。但需了解原理。

配置生产者确认

在 publisher 模块的application.yaml中添加如下配置:

xml 复制代码
spring:
  rabbitmq:
    publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
    publisher-returns: true # 开启publisher return机制

其中,配置信息里的publisher-confirm-type有如下三种模式可选:

  • none:关闭confirm机制;
  • simple:同步阻塞等待MQ的回执。会影响性能;
  • correlated:MQ异步回调返回回执;

异步回调性能更好,因此一般我们推荐使用correlated,回调机制。

具体来说,异步回调的方式即:我们定义好一个 callBack 回调函数,然后发送消息给 MQ ,此时发完消息就可以去做别的事情无需阻塞等待。当 MQ 处理完消息后,会主动通过我们提前注册好的回调函数,异步地将处理结果返回,我们只需要在回调函数中根据结果进行相应的业务处理即可。

接下来我们看一下异步回调生产者确认的具体实现:

首先,每个RabbitTemplate只能配置一个ReturnCallback,因此我们可以在配置类中统一设置。

我们在 publisher 模块定义一个配置类:

java 复制代码
package com.itheima.publisher.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Configuration;

/**
* 监听并处理那些"成功到达了交换机,但无法被路由到任何队列"的消息。
* 一旦消息无法投递到队列,RabbitMQ 就会将消息退回,并触发这个回调方法,让你能在日志中捕获到这次失败(包含交换机名、路由键、消息内容、错误码等信息)。
*/
@Slf4j
@Configuration
public class MqConfirmConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);

        // 配置回调
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.debug("收到消息的return callback,exchange:{},key:{},msg:{},code:{},replyText:{}",
                        returned.getExchange(), returned.getRoutingKey(), returned.getMessage(),
                        returned.getReplyCode(), returned.getReplyText());
            }
        });
    }
}

然后,每次发消息都要定义 ConfirmCallback

由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义。

java 复制代码
	// 在发送消息后,异步监听并确认这条消息是否成功到达了 RabbitMQ 的交换机(Exchange)。
    @Test
    void testConfirmCallback() throws InterruptedException {
        // 创建correlationData需要给消息指定唯一id
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());

        // 给correlationData添加callback
        cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("消息回调失败", ex);   //这种情况概率很小
            }

            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                log.debug("收到消息 confirm callback 回执");
                if (result.isAck()){
                    // 消息发送成功
                    log.debug("消息发送成功,收到ack");
                }else {
                    // 消息发送失败
                    log.debug("消息发送失败,收到nack,原因:{}", result.getReason());
                }
            }
        });
        String exchangeName = "hmall.direct";
        String msg = "hello, everyone!";
        rabbitTemplate.convertAndSend(exchangeName, "red", msg, cd);  //发送消息需要额外传入CorrelationData对象

        Thread.sleep(2000);  //确保能观察到回调
    }

运行单元测试方法,控制台输出如下:

如果 routing key 写错,会导致消息投递到MQ,但是路由失败。此时会通过 Publisher Return 返回路由异常信息,同时返回ack的确认信息,代表投递成功;具体信息如下图所示:

如果发送消息时设置了一个不存在的交换机,会返回会NACK,告知投递失败:


4,MQ的可靠性

为了在保障消息队列的可靠性,RabbitMQ 提供了数据持久化 与 Lazy Queue(惰性队列) 两种核心解决方案。


4.1,数据持久化

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,可能会导致一些问题:

  • 一旦MQ 宕机,内存中的数据就会丢失;
  • 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发 MQ 阻塞;
    为了保证数据的可靠性,必须配置数据持久化。

因此,为了保证数据的可靠性,必须配置数据持久化。,包括:

  • 交换机持久化
  • 队列持久化
  • 消息持久化

交换机持久化

在 RabbitMQ 后台界面创建交换机时,Durability 属性可以指定 Durable (持久化)和 Transient(临时的)两种方式。Transient 类型的交换机会在 RabbitMQ 重启之后消失。

队列持久化

在 RabbitMQ 后台界面创建队列时,也可以选择 Durable (持久化)和 Transient(临时的)两种方式。Transient 类型的队列会在 RabbitMQ 重启之后消失。

消息持久化

在 RabbitMQ 后台界面发布消息时,可以设置 Delivery ,设置持久化 2-Persisent1-Non-Persistent ,两种方式。设为 1-Non-Persistent 的消息会在 RabbitMQ 重启之后消失。

不同于使用 RabbitMQ 管理界面操作,使用 Spring 框架创建交换机和队列时,会默认设置为 Durable 持久化类型的。

需要注意的是:在开启持久化机制以后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性。


4.2,Lazy Queue

持久化在一定程度解决了使用内存存储的一些弊端。但整体性能不是特别好。因此为了解决这个问题,从 RabbitMQ 的3.6.0版本开始,就增加了 Lazy Queues 惰性队列模式,这种方式更加优秀,性能更好。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存(内存中只保留最近的消息,默认2048条);
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载);
  • 支持数百万条的消息存储;

从 RabbitMQ 的 3.12 版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为 LazyQueue 模式。


控制台手动配置 Lazy 模式

在添加队列的时候,添加x-queue-mod=lazy参数即可设置队列为Lazy模式:

Java 代码手动配置 Lazy 模式

在利用SpringAMQP声明队列的时候,添加x-queue-mod=lazy参数也可设置队列为 Lazy 模式:

① 声明 Bean 方式:

java 复制代码
@Bean
public Queue lazyQueue(){
    return QueueBuilder
            .durable("lazy.queue")
            .lazy() // 开启Lazy模式
            .build();
}

其中QueueBuilderlazy()方法会在底层,添加x-queue-mod=lazy参数,其底层代码如下:

java 复制代码
    public QueueBuilder lazy() {
        return this.withArgument("x-queue-mode", "lazy");
    }

② 注解方式:

java 复制代码
@RabbitListener(queuesToDeclare = @Queue(
        name = "lazy.queue",
        durable = "true",
        arguments = @Argument(name = "x-queue-mode", value = "lazy") // 指定键值对
))
public void listenLazyQueue(String msg){
    log.info("接收到 lazy.queue的消息:{}", msg);
}

注意:从 RabbitMQ 的 3.12 版本之后,LazyQueue已经成为所有队列的默认格式,无需手动指定。

总结:RabbitMQ 如何保证消息的可靠性

  • 手续爱你通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息就会持久胡到磁盘,MQ 重启消息依然存在;
  • RabbitMQ 的3.6.0版本引入了 Lazy Queues 惰性队列,并在3.12 版本后设置为队列的默认模式。 Lazy Queues 会将所有消息都持久化;
  • 在开启持久化机制以后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性;

5,消费者的可靠性

通过前面的学习可以在一定程度上保证生产者消息能正常到达 MQ ,也尽量确保了 MQ 消息不丢失。而现在还有一种可能性是消息给了消费者,消费者不可靠导致消息丢失。因为消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:

  • 消息投递的过程中出现了网络故障
  • 消费者接收到消息后突然宕机
  • 消费者接收到消息后,因处理不当导致异常
  • ...

一旦发生上述情况,消息也会丢失。因此,RabbitMQ必须知道消费者的处理状态,一旦消息处理失败才能重新投递消息。

RabbitMQ通过三种方式保证消费者的可靠性,分别是:消费者确认机制、消费者失败处理机制和业务幂等性。


5.1,消费者确认机制

为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement )。即:当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ 可以从队列中删除该消息
  • nack:消息处理失败,RabbitMQ 需要再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ 可以从队列中删除该消息

一般reject方式用的较少,除非是消息格式有问题。

由于消息回执的处理代码比较统一,因此SpringAMQP帮我们实现了消息确认。并允许我们通过配置文件设置ACK处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。默认是此方式 ,但不安全,不建议使用;
  • manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活;
  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,如果是业务异常 ,会自动返回nack;如果是消息处理或校验异常,自动返回reject

通过下面的配置可以修改SpringAMQP的ACK处理方式:

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto # 可选:none、manual、auto

5.2,消费者失败重试机制

当消费者出现异常后,消息会不断requeue到队列(重入),再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力。 为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。

使用时此功能可以在消费者的yaml配置文件里增加如下配置:

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000ms # 初识的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
  • 开启本地重试后,消息处理过程中如果抛出异常,不会requeue到队列,而是在消费者本地重试;

开启消息重试模式后,如果重试次数耗尽,消息依然失败,则需要有 MessageRecoverer 接口来处理,它包含三种不同的实现:

  • RejectAndDontRequeueRecover:重试达到最大次数后,直接 reject ,丢弃消息。默认是这种方式;
  • ImmediateRequeueMessageRecoverer:本地重试达到最大次数后,返回 nack,消息重新入队,可以降低重新入队的频率
  • RepublishMessageRecoverer:重试达到最大次数后,将失败消息投递到指定的交换机处理失败消息(比如给开发人员发送告警邮件提醒收到处理) ,此方式可以作为一个保证可靠性的兜底策略,这种机制的架构如下图所示;

    RepublishMessageRecoverer 方式使用时,需要:
  • 首先,定义接收失败消息的交换机、队列及其绑定关系;
  • 然后,定义 RepublishMessageRecoverer,配置类中加入如下代码;
java 复制代码
@Configuration
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")  // 消费者配置文件中开启重试机制时才有效
public class ErrorMessageConfig {
    @Bean
    public DirectExchange errorMessageExchange(){
        return new DirectExchange("error.direct");
    }
    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue", true);
    }
    @Bean
    public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
        return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
    }

    @Bean
    public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
    		// error.direct是交换机,error是key
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
}

5.3,业务幂等性

幂等性是指在程序开发中,是指对同一个操作执行一次和执行多次,所产生的最终结果或影响是完全相同的。 我们可以通过唯一消息 ID 和业务状态判断保证消息处理的幂等性,避免消息被重复消费。


5.3.1,唯一消息ID

可以给每一条消息都生成一个唯一 id,与消息一起投递给消费者。消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库。如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。

SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。以Jackson的消息转换器为例:

java 复制代码
@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jjmc.setCreateMessageIds(true);
    return jjmc;
}

这种方式可能需要修改业务引入数据库操作,可能会降低性能。相比较而言,更推荐使用业务状态判断的方案。


5.3.2,业务状态判断

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。

开发场景举例:

处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以在执行业务时判断订单状态是否是未支付,如果不是则证明订单已经被处理过,无需重复处理。


6,基于RabbitMQ实现延迟消息

延迟消息是指,生产者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。比如常见的超时自动关闭订单场景就可以使用延迟消息解决。RabbitMQ中延迟消息的实现可以基于死信交换机,也可以使用延迟消息插件实现。


6.1,死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 消费者使用rejectnack声明消费失败,并且消息的requeue参数设置为false;
  • 消息是一个过期消息,即达到了队列或消息本身设置的过期时间,超时无人消费;
  • 要投递的队列消息堆满了,无法投递;

如果一个队列中的消息已经成为死信,并且这个队列通过dead-letter-exchange属性指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机就称为死信交换机。而此时如果有新队列与死信交换机绑定,则最终死信就会被投递到这个队列中。

死信交换机方式实现延迟队列比较繁琐,需要额外定义新的交换机、队列及绑定关系。因此不推荐使用此方式。更推荐使用 RabbitMQ 官方的延迟消息插件。


6.2,RabbitMQ延迟消息插件

RabbitMQ社区提供了一个延迟消息插件,支持延迟消息功能。该插件的原理是设计了一种支持延迟消息功能的交换机,当消息投递到交换机后可暂存一定时间,到期后再投递到队列。

使用时,需要将下载好的插件放到 RabbitMQ 的插件目录下,安装并重启 RabbitMQ 服务。

配置好延迟消息插件后,再声明交换机时,只需要设置 delayed 属性为 true 即可,代码示例如下:

基于注解方式声明延迟交换机

java 复制代码
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "delay.queue", durable = "true"),
        exchange = @Exchange(name = "delay.direct", delayed = "true"),
        key = "delay"
))
public void listenDelayMessage(String msg){
    log.info("接收到delay.queue的延迟消息:{}", msg);
}

基于@Bean的方式声明延迟交换机

java 复制代码
package com.itheima.consumer.config;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Slf4j
@Configuration
public class DelayExchangeConfig {

    @Bean
    public DirectExchange delayExchange(){
        return ExchangeBuilder
                .directExchange("delay.direct") // 指定交换机类型和名称
                .delayed() // 设置delay的属性为true
                .durable(true) // 持久化
                .build();
    }

    @Bean
    public Queue delayedQueue(){
        return new Queue("delay.queue");
    }
    
    @Bean
    public Binding delayQueueBinding(){
        return BindingBuilder.bind(delayedQueue()).to(delayExchange()).with("delay");
    }
}

发送消息时需要通过x-delay属性设定延迟时间

java 复制代码
@Test
void testPublisherDelayMessage() {
    // 1.创建消息
    String message = "hello, delayed message";
    // 2.发送消息,利用消息后置处理器添加消息头
    rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() {
        @Override
        public Message postProcessMessage(Message message) throws AmqpException {
            // 添加延迟消息属性
            message.getMessageProperties().setDelay(5000);  //5s
            return message;
        }
    });
}

需要注意的是:

延迟消息插件内部会维护一个本地数据库表,同时使用 Elang Timers 功能实现计时。如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。

因此,不建议设置延迟时间过长的延迟消息


相关推荐
吃好睡好便好7 小时前
用直接输入的方式创建矩阵
开发语言·人工智能·学习·线性代数·算法·matlab·矩阵
2401_833269307 小时前
Java异常处理入门
java·开发语言
憧憬成为java架构高手的小白7 小时前
苍穹外卖--day07(缓存商品,购物车)
java·spring boot
观无7 小时前
若依框架在window的打包部署
java
问心无愧05137 小时前
ctf show web入门 254
java·开发语言·笔记
逸Y 仙X8 小时前
文章三:Elasticsearch 集群恢复和索引分布
java·大数据·linux·服务器·elasticsearch·搜索引擎·全文检索
『昊纸』℃8 小时前
《C语言电子新-2026最新版》-编程语言与程序
数据结构·算法·程序设计·编程语言·软件开发
奋斗的小乌龟15 小时前
动态创建Agent02
java
吃好睡好便好15 小时前
用while循环语句求和
开发语言·学习·算法·matlab·信息可视化