RabbitMQ深层浅讲【通俗易懂】

在之前我们讲了rabbitmq基础的五种消息模型,接下来我们来谈谈它的防护机制,具体的来说就是我们怎么做来确保消息的可靠性?

在这个架构中,一共有三个角色,那么我们就要从这三个方面来确保消息的可靠完整

  • 生产者的可靠性
  • MQ的可靠性
  • 接收者的可靠性

1.生产者的可靠性怎么实现

1.1.生成者重连

这种方式仅仅是由于网络问题或者其他原因导致服务连接不上MQ的一种重试机制,比如在连接MQ的时候断网了,那么就可以通过配置属性来让其重新连接MQ

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

来讲讲这个配置

  • connection-timeout表示当服务连接MQ的时候,如果在规定时间内没有连上,那么就表示连接超时,失败了;
  • retry以下的配置都是重试机制;
  • enabled:true首先要打开重试机制,默认是关闭的;
  • initial-interval表示第一次连接失败后要等待规定的时长,然后再进行重连,而不是一失败就立马进行重连;
  • multiplier表示一次重连失败了,那么到下一次重连之间等待的时长会成倍增加,例如,我第一次重连失败之后,到第二次重连中间间隔1000ms,第二次重连失败,到第三次重连中间间隔(1000ms×倍数)
  • max-attempts表示最大重连次数

SpringAMQP给我们提供的这个重试机制,是阻塞式的,在多次等待重连中,这个线程是被阻塞的,会影响业务性能,一般情况下我们是不使用这个重试机制的,或者用的话,要合理的设置重试的等待时间和次数

 1.2.生产者确认

这个机制就侧重于消息发送失败之后的防护,RabbitMQ提供了Publisher ConfirmPublisher Return两种确认机制。开启确认机制后,在MQ成功收到消息后会返回确认消息给生产者,返回的结果有以下几种情况:

  • 消息投递到了MQ,但是路由失败,此时会通过Publisher Return返回路由异常原因,然后通过Publisher Confirm返回ACK告知生产者投递成功。那么奇怪了,明明消息都路由失败了为啥还会返回ACK告知生产者投递成功呢?刚刚上面都说了MQ收到消息就会返回给生产者消息,那么在这只是路由失败了,但是MQ确确实实是收到消息了,所以会返回ACK(这个路由失败,多数都是人为造成的,比如Routing Key没有写对之类的)
  • 临时消息投递到了MQ,并且成功入队,通过Publisher Confirm返回ACK告知投递成功
  • 持久消息投递到了MQ,并且成功入队,通过Publisher Confirm返回ACK,告知投递成功
  • 其他情况都会通过Publisher Confirm返回NACK,告知投递失败

代码怎么实现? 

先在publisher服务中的application.yml中添加配置,这里publisher-confirm-type有三种模式可选择:

  • none:关闭confirm机制
  • simple:同步阻塞等待MQ的回执消息,会占用当前线程,一直等到接收到回执消息
  • correlated:MQ异步回调方式返回回执消息,我们一般使用这种方式来接收回执信息

publisher-returns默认是关闭的,ture就是开启return机制 

自定义Publisher Return机制 

@Slf4j
@Configuration
public class MqConfirmConfig {

    @Autowired
    private RabbitTemplate rabbitTemplate;


    @Bean
    public void getMessage() {
        //配置回调函数
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returnedMessage) {
                log.info("收到消息的return callback,exchange:{},key:{},message:{}",
                        returnedMessage.getExchange(),
                        returnedMessage.getRoutingKey(),
                        returnedMessage.getMessage()
                );
            }
        });
    }
}

由于是所有的RabbitTemplate共用一个publisher return,所以这个机制的编写,我们放在配置类中,在这个配置类中,我们获取了RabbitTemplate对象后,会创建一个回调函数

setReturnsCallback,这个回调函数的参数是通过匿名内部类的方式创建了ReturnsCallback接口的实例,然后我们就要实现其内部的方法,也就是这个returnedMessage,它用来接收mq给我们传来的回执消息,然后我们就来定义自己的returnedMessage方法,在这个方法中我们采用日志的方式,输出接收到的回执消息,别忘了在配置文件中配置一下日志级别,否则无法输出


刚刚是我们定义了publisher return的机制,接下来我们再来定义publisher confirm的机制

自定义Publisher Confirm机制

由于每一个消息都有自己独立的回执信息,所以我们这个Publisher Confirm应在每次发送消息的时候定义

    @Test
    public void testConfirmCallback() throws InterruptedException {
        //1.创建cd
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());

        //2.创建confirmCallBack回调函数,这个回调函数用来接受MQ传回来的消息
        cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            //很少会用到,这个失败指的是spring出错
            @Override
            public void onFailure(Throwable ex) {
                System.out.println("消息发送失败" + ex);
                log.info("消息发送失败",ex);
            }

            //MQ成功接受到消息
            @Override
            public void onSuccess(CorrelationData.Confirm result) {
                if (result.isAck()){
                   
                    log.info("消息发送成功,收到ack");
                }else {
                   
                    log.error("消息发送失败,收到nack,原因:{}",result.getReason());
                }
            }
        });
        rabbitTemplate.convertAndSend("itcast.direct","blue11","hello",cd);
    }

首先创建一个CorrelationData来接收回执消息,由于刚刚上面说了RabbitTemplate发送的不同的消息,都应该有其独立的消息回馈,所以这里用来接收消息容器应该也有独一无二的UUID;

getFuture()方法返回一个ListenableFuture对象,这个对象用于异步处理消息发送的结果。ListenableFuture是Spring的一个扩展,它允许你添加回调来处理异步操作的结果,使用其中的addCallback方法创建一个回调并且确认泛型是Confirm(confirm是CorrelationData的一个静态内部类,用来确认消息是ack还是nack,并且还有方法能查看reason)在回调内部,实现接口的方法onFailureonSuccess,其中这个onFailure方法我们一般不使用,它的这个失败指的是spring在运行的时候报错,在onSuccess方法中接收回执消息然后输出日志,最后在发送消息的时候,将这个Correlation对象作为convertAndSend的参数一并传给mq

接下来让我们测试一下

先发送一个正确的消息:

 Publisher Confirm机制会给我们返回ack 


 当我把路由的Routing Key更改成错误的时候:

Publisher Return机制会给我返回这个消息具体情况,Publisher Confirm机制会给我们返回ack 


当我将交换机名称更改成错误的,消息进入不到mq里面时:

 Publisher Confirm机制给我们返回nack

面试题:在rabbitmq中如何确保生产者的可靠性

首先,我们可以在配置文件中配置生产者重试机制,确保不会因网络问题导致服务连接不上rabbitmq从而导致消息发送失败,然后可以在配置文件中开启生产者确认机制,然后定义publisher return和publisher confirm机制,当发送消息到mq时,他就会发送ack或者nack的回执消息,但是以上手段都会增加系统的负担和额外资源的开销,一般情况下我们不会开启消息的确认机制

2.增强MQ的可靠性

在默认情况下,RabbitMQ会将接收到的消息保存在内存中,来降低消息收发的延迟,那么这样就会导致两个问题:

  1. MQ一旦宕机,那么内存中的消息也会随之消失
  2. 内存空间是有限的,当消费者故障或者处理速度慢于生产者发送消息的速度,会导致内存消息积压,那么MQ就会将内存中的消息腾一部分到磁盘中,完成持久化操作,这样内存就又会有空间来堆积消息,这个过程叫做page out,但是在这个期间,MQ是阻塞的,也就意味着,在进行page out的时候MQ是不会处理消息的,这会导致效率过于低下

接下来,来演示下第一种情况,利用MQ的可视化界面给一个队列发送消息: 

队列成功接收到消息: 

 当我重启RabbitMQ之后,再来检查下该队列:

可以看见,队列里的消息没了,因为该消息存在内存中,重启MQ之后内存里的东西也会随之丢失 


接下来看看第二种情况,我们模拟一种情况,当一直向MQ发送消息,但是不让消费者接收,看看MQ的处理情况如何

    /**
     * 发送一百万条消息
     * @throws InterruptedException
     */
    @Test
    public void testMessageToSimpleQueue1() throws InterruptedException {

        String queueName = "simplequeue";

        Message message = MessageBuilder.
                withBody("你好,今天是周三".getBytes(StandardCharsets.UTF_8)).
                setDeliveryMode(NON_PERSISTENT).
                build();
        for (int i = 0; i < 1000000; i++) {
            rabbitTemplate.convertAndSend(queueName,message);
        }
    }

这里,我采用springamqp向MQ发送了一百万条消息,然后不让消费者来处理,我们来看看MQ的处理情况

  • 注意:SpringAMQP默认发送的消息都是持久化的,这里的持久化我们在下面就会讲到,这里你可以认为是这个持久化就是解决这个内存不够用问题的方式,如果消息都是持久化的,那么就不会发生MQ的page out问题,所以我们要使用这个MessageBuilder来将发送的消息更改成非持久化,来模拟一下内存不够用的情况 

重点关注红框子的部分,可以看到,这个时候它处理的556600条消息,其中放在内存中的有16384条消息,而通过page out放进磁盘的消息有540216条,每次page out的时候,我们可以看到左边的消息处理效率图,其速度会降到0,这也就是上面说的,MQ在page out的时候,当前线程会阻塞,MQ不会处理新进来的消息,这样的速度大打折扣

为了解决这两种情况,现有以下方案可以实现

2.1.数据持久化

RabbitMQ实现数据持久化包括三个方面

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

 其中将交换机和队列持久化之后,就不会因为MQ的宕机而导致存进去的消息丢失了

 


我们在使用MQ提供的可视化界面创建交换机和队列的时候,将其更改成持久化的格式即可,而通过SpringAMQP创建的交换机和队列,默认是持久化的 

同样的,消息持久化,当我们用MQ提供的可视化界面发送消息的时候,将Delivery mode改成持久化的即可,采用SpringAMQP发送的消息同样是默认是持久化的,当消息是持久化的了,MQ在读取消息的时候,不仅会将消息放在内存中,而且会将其再放入磁盘中,那么MQ就不会因为内存堆满之后,将一部分数据从内存中写入磁盘里,进而就不会发生阻塞了,顶多是内存堆满了之后,他会将内存里的一部分消息删除,并不会通过page out写入磁盘,为啥?因为磁盘里已经有消息了,它就可以大大方方的在内存中做删除操作了

发送非持久化消息:

发送持久化消息: 


 2.2.Lazy Queue

刚刚在上面讲的持久化操作,会将MQ的性能提高一截,但是和现在这种Lazy Queue的方式比起来,还是差点,现在来看看Lazy Queue(惰性队列)的处理机制是怎么样的?

  • 从RabbitMQ的3.6.0版本开始,就引入了Lazy Queue的概念,在3.12版本之后,所有队列都默认是Lazy Queue模式
  • 接收到消息后会直接存进磁盘而不会经过内存(刚刚在上面介绍的持久化消息,它虽然会存进磁盘,但是也会存放在内存中),当消费者要消费时,才会从磁盘里取出来并加载到内存中,其中在底层的读写,官方肯定是做了io的优化,导致读写的速度大幅提升

如何创建一个Lazy Queue?

 在使用可视化界面创建的时候,点击下面提供的Lazy Queue模式即可


    @Bean
    public Queue lazyQueue(){
        return QueueBuilder.durable("lazy.queue").lazy().build();
    }

使用配置类来创建Lazy Queue,要使用QueueBuilder来设置Lazy Queue模式


 @RabbitListener(queuesToDeclare = @Queue(
            name = "lazy.queue",
            durable = "true",
            arguments = @Argument(name = "x-queue-mode",value = "lazy")
    ))
    public void ListenLazyQueue(String msg){
          log.info("接收到消息了:{}",msg);
    }

使用注解来创建Lazy Queue,要用Argument注解来设置


使用持久化和Lazy Queue不冲突,两者搭配使用,既提高了MQ的可靠性,又提高了消息的处理速度

3.消费者可靠性

前面讲述了生产者和MQ的可靠性该如何设置,现在来讲讲如何提升消费者的可靠性

3.1.消费者确认

前面讲了生产者确认机制,那么显然,消费者也是有确认机制的,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement),当消费者处理消息结束后,应该向MQ发送一个回执,告知MQ自己消息的状态,回执有以下三种:

  1. ack  成功处理消息,RabbitMQ从队列中删除消息
  2. nack  消息处理失败,RabbitMQ会再次投递消息
  3. reject  消息处理失败并拒绝处理该消息,RabbitMQ会从队列中删除消息

我们只需要在配置文件中开启确认机制,SpringAMQP已经实现了消息确认机制,允许我们通过配置文件选择ack处理方式,有三种:

  • none  不处理,即消息投递给消费者之后,立刻返回ack,那么这个消息会立即从MQ中删除,非常不安全,容易造成消息的丢失
  • manual   手动模式,需要在自己的业务代码中调用API来发送ack或者reject
  • auto   自动模式,SpringAMQP利用AOP对我们的消息逻辑做了环绕增强,当业务正常时返回ack,当业务出现异常,根据异常不同返回不同结果
  1. 业务异常,返回nack
  2. 消息处理或校验异常,返回reject

 开启消费者确认机制,并将模式配置成自动


当我把确认机制的模式改成none,然后我在接收消息的时候故意加一个异常,表示我们在处理消息的时候发生异常了,我们来看看MQ是怎么处理的

队列已经收到消息了,如下所示: 

我们来看看消费者接收到消息又是怎么样的

 虽然成功接收到了消息,但是发生处理异常,这个时候其实是需要MQ重新给我们传消息的,但是我们来看看MQ中是否还存有这个消息

 答案是没了!所以才说这种模式非常的不安全,容易造成消息的丢失


那么现在将模式改为auto,我们再来看看MQ是怎么应对的

队列已经收到消息了

看看消费者接收消息之后,MQ的处理方式

返回nack之后,消息还在队列里 

由于开启的auto模式,那么消费者在处理消息的时候抛出了异常,会返回给MQ一个nack,那么MQ就会把消息重新回收然后重新发送消息,一直到消费者返回ack为止,想想,这样是不是也不是很好?如果我一直没有排错,那这个消息就会一直发,那迟早会把资源消耗完,那该怎么做呢?

  • 之前在上面讲过生产者重连机制,可以配置重试的次数,那么在这里也是有重试机制的,我们也可以配置重试次数等相关属性

这些属性的含义和生产者重连中配置的属性含义是一致的,上面有解释,这里就不解释了(上面重试机制的值都是spring默认的值,我们只需要开启这个重试机制就可以了),通过这个配置,我们将重试次数设置到了3次


试了一下,确实是这样,只重试了三次,但是SpringAMQP还给我们提供了失败消息处理的策略,消息重试发送失败了,那它总要有个归处吧

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

  • RejectAndRequeueRecoverer:重试耗尽后,直接reject,丢弃消息,默认是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机,(将重试失败之后的消息统一放进某个专门存放这样一类消息的队列中)推荐使用这种方式来处理 

接下来,演示下第三种方式来处理重试耗尽后的消息

首先,创建交换机和队列

@Configuration
@ConditionalOnProperty(prefix = "spring.rabbitmq.listener.simple.retry",value = "enabled",havingValue = "true")
public class ErrorConfig {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    //创建交换机
    @Bean
    public DirectExchange errorExchange(){
        return new DirectExchange("error.exchange");
    }

    //创建队列
    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue");
    }

    //绑定队列和交换机
    @Bean
    public Binding queueToExchange(DirectExchange errorExchange,Queue errorQueue){
        return BindingBuilder.bind(errorQueue).to(errorExchange).with("error");
    }

    //开启RepublishMessageRecoverer策略
    @Bean
    public MessageRecoverer errorMessage(){
        return new RepublishMessageRecoverer(rabbitTemplate,"error.exchange","error");
    }
}
  1. @ConditionOnProperty注解,表明,只有在后面标注的属性开启之后,这个类才会被创建,后面这个属性就是刚刚在上面谈到的重试机制的属性
  2. RepublishMessageRecoverer的三个构造参数是RabbitTemplate,交换机名称,routing key

消费者接收消息之后我们来看看MQ怎么处理

可以看到,在重试了三次之后,它就将这个消息通过我们设置的交换机,发送给我们设置的队列

果然收到了三次重试都失败的消息

4.延时消息

4.1.什么是延时消息

生产者发送消息时指定一个时间,消费者不会立即收到该消息,而是在指定时间之后才会收到消息

这有什么应用场景呢?其实是非常多的,比如大家在12306上抢车票的时候,当我锁定一张车票的时候,后台业务就会将商品数量减一,然后页面会跳转到支付页面,但是我迟迟不付款,那么我就会一直把这张票占着,因为订单是我抢到的,所以别人也没办法获取到该订单,就这样,当车都开了,我还是没付款,那是不是商家就会亏损了一张车票了

正常的业务逻辑:

 用户成功下单,库存减一,用户也成功付款了,这算是正常的业务逻辑

存在问题的业务逻辑: 

我抢到一个商品,然后后台把商品库存减一(其他用户就不会买到这件商品了),但是我迟迟不付款,商家就会少赚一件商品的钱,商家肯定不同意

添加上延时消息的业务逻辑: 

当用户下单后,向MQ发送一条延时消息,时长30分钟,30分钟后收到消息,我们再去检查订单状态,如果还是未支付,那么不好意思,库存加一,订单取消 

4.2.怎么实现延时消息发送

RabbitMQ给我们提供了一个叫做rabbitmq-delayed-message-exchange的插件,专门来完成延时消息的发送,下载地址如下:

Releases · rabbitmq/rabbitmq-delayed-message-exchange (github.com)

在其中选择适合的版本进行下载,将下载好的文件,放在docker在rabbitmq容器上挂载的数据卷中,操作如下:

4.2.1.创建文件专门来放插件
mkdir rabbitmq-plugins
  • 创建一个文件夹叫作rabbitmq-plugins,等会挂载到rabbitmq容器专门用来放置配置的文件上
4.2.2.挂载插件目录
docker run \
>  -e RABBITMQ_DEFAULT_USER=itcast \
>  -e RABBITMQ_DEFAULT_PASS=123321 \
>  -v rabbitmq-plugins:/plugins \
>  --name mq \
>  --hostname mq1 \
>  -p 15672:15672 \
>  -p 5672:5672 \
>  -d \
>  rabbitmq:3-management
  • 我们在运行rabbitmq容器的时候,将刚刚创建的rabbitmq目录挂载到rabbitmq容器内部的plugins目录下
  • 注意:当我们如果已经有了这个rabbitmq的容器,但是并没有挂载文件目录,那么我们可以将原来的容器删掉,然后重新创建一个容器并挂载文件目录,docker操作可以看看我之前的博客

docker常见命令-****博客

  • 将刚刚下载的插件放进刚才创建的目录中,并且安装然后启动该插件,脚本如下

docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange

这样就成功启动了该插件了


以上内容是来安装并启动该插件的,接下来我们用代码实现怎么发送延时消息 

首先我们先创建延时交换机和队列 

@Configuration
public class DelayConfig {

    //创建一个延时交换机
    @Bean
    public DirectExchange delayExchange(){
        return ExchangeBuilder.directExchange("delay.exchange").durable(true).delayed().build();
    }


    //创建一个队列,用来接收延时消息
    @Bean
    public Queue delayQueue(){
        return new Queue("delay.queue",true);
    }

    //绑定队列和交换机
    @Bean
    public Binding binding(DirectExchange delayExchange,Queue delayQueue){
        return BindingBuilder.bind(delayQueue).to(delayExchange).with("hi");
    }
}

在创建交换机的时候,我们使用建造者模式,用ExchangeBuilder方法,来链式创建延时队列,其中delayed()方法就是开启了交换机的延时功能

发送延时消息

    @Test
    public void sendDelayMessage(){
        rabbitTemplate.convertAndSend("delay.exchange", "hi", "hello delayed message", new MessagePostProcessor() {
            @Override
            public Message postProcessMessage(Message message) throws AmqpException {
                message.getMessageProperties().setDelay(10000);
                return message;
            }
        });
      log.info("发送消息了,请注意查收");
    }
}
  • 我们在发送消息的时候,采用实现MessagePostProcessor接口的方式来自定义消息内容,这里采用了匿名内部类的方式
  • 通过setDelay方法设置延时消息10秒钟

接收消息

    @RabbitListener(queues = "delay.queue")
    public void ListenDelayQueue(String msg){
        log.info("接收到延迟消息了:{}",msg);
    }

 测试一下

成功在十秒之后收到消息

4.3.延时消息的实际最佳应用

rabbitmq的延时消息的机制是其内置的计时器来计时的,这将单独消耗cp的资源,假如我们把延时时间设置的长一点,那么cpu的开销会非常大。所以我们要如何用最佳方式实现这个延时消息呢?

假如有这么一个实际场景,用户下单之后,有十分钟的时间来进行支付,当用户支付了之后,我们就把订单的支付状态改成已支付,然后结束该订单,假如用户在十分钟之内没有支付,那么十分钟到了之后,我们接收到延时消息,来检查订单,发现是未支付,那么就取消该用户的订单

但是有没有想过,正常的多数用户一般都会在抢到商品之后的十几秒中之内支付订单,当支付成功了,但是在支付时发送的延时消息并没有结束,这样还会继续占用MQ内部资源,当并发量一大,延时队列的资源就会被占完


  • 我们可不可以将延时的时间缩短一点,同样是十分钟,我们可以把这个十分钟分成多个小时间段

  • 当用户下单之后,我们先发送一个20秒延时的消息,时间一到我们检查一下订单,若是未支付状态,我们就再发送一个40秒延时的消息,这样依次类推,当在某个延时消息接收到之后,检查订单状态是已支付,那么就不再发送延时消息

 

业务逻辑大致就是这个样子,这样拆分延时时间的方法成功缓解了MQ的压力

上一篇:[JAVAEE] 线程安全的案例(二) - 阻塞队列 生产者消费者模型


下一篇:Spring6梳理15——Bean的作用域