Bootstrap

【MQ】如何确保RabbitMQ消息可靠性传递?

正在学RabbitMQ,特此记录一下,这里就不讲RabbitMQ基础了,直接进入主题。

TODO:记录下具体的springboot中具体如何配置

mq:消息中间件(一)MQ详解及四大MQ比较

我们都知道,消息从生产端到消费端消费要经过大致4个步骤(AMQP协议):

  1. 消息先从生产者Producer出发到达交换器Exchange;
  2. 交换器Exchange根据路由规则将消息转发对应的队列Queue之上;
  3. 消息在队列Queue上进行存储;
  4. 消费者Consumer订阅队列Queue并进行消费。

在这里插入图片描述

这4个步骤中的每一步都有可能导致消息丢失,消息丢失不可怕,可怕的是丢失了我们还不知道,所以要有一些措施来保证系统的可靠性。这里的可靠并不是一定就100%不丢失了,磁盘损坏,机房爆炸等等都能导致数据丢失,当然这种都是极小概率发生,能做到99.999999%消息不丢失,就是可靠的了。下面来具体分析一下问题以及解决方案。

1. Producer-to-Exchange

生产端可靠性投递,即生产端要确保将消息正确投递到RabbitMQ中。生产端投递的消息丢失的原因有很多,比如消息在网络传输的过程中发生网络故障消息丢失,或者消息投递到RabbitMQ时RabbitMQ挂了,那消息也可能丢失,而我们根本不知道发生了什么。针对以上情况,RabbitMQ本身提供了一些机制。

事务消息机制由于会严重降低性能,所以一般不采用这种方法,我就不介绍了,而采用另一种轻量级的解决方案——confirm消息确认机制。

confirm消息确认机制

什么是confirm消息确认机制?即发送方确认机制(publisher confirm)顾名思义,就是生产端投递的消息一旦投递到RabbitMQ后,RabbitMQ就会发送一个确认消息给生产端,让生产端知道我已经收到消息了,否则这条消息就可能已经丢失了,需要生产端重新发送消息了。
在这里插入图片描述
通过下面这句代码来开启确认模式:

channel.confirmSelect();// 开启发送方确认模式

然后异步监听确认和未确认的消息:

channel.addConfirmListener(new ConfirmListener() {
    //消息正确到达broker
    @Override
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {
        System.out.println("已收到消息");
        //做一些其他处理
    }

    //RabbitMQ因为自身内部错误导致消息丢失,就会发送一条nack消息
    @Override
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {
        System.out.println("未确认消息,标识:" + deliveryTag);
        //做一些其他处理,比如消息重发等
    }
});

springboot中配置

spring.rabbitmq.publisher-confirm-type=correlated

使用RabbitTemplate里需要实现内部接口ConfirmCallback

/**
 * @author [email protected]
 * @version v1.0
 * @description MyCallBack
 * @date 2022/7/5 10:09
 */
@Slf4j
@Component
public class MyCallBack implements RabbitTemplate.ConfirmCallback {

    @Autowired
    private RabbitTemplate rabbitTemplate;


    @PostConstruct  // 在Bean创建并完成赋值,执行初始化之前调用
    public void init() {
        // 注入到RabbitTemplate
        rabbitTemplate.setConfirmCallback(this);
    }

    /**
     * correlationData 保存回调消息的ID及相关信息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id = correlationData != null ? correlationData.getId() : null;
        if (ack) {
            log.info("交换机收到ID为:{}的消息!", id);
        } else {
            log.info("交换机还未收到ID为:{}的消息!", id);
        }
    }

}

这样就可以让生产端感知到消息是否投递到RabbitMQ中了,当然这样还不够,稍后我会说一下极端情况。

2. Exchange-to-Queue

当消息准确到达交换机后,由于路由键错误等问题,造成无法将消息路由到所绑定的队列,而此时生产者也无法感知,那么会出现消息丢失问题。我们可以使用以下两种方式来解决:

1)回退消息

springboot中通过设置 mandatory 参数可以在当消息传递过程中不可达目的地时将消息返回给生产者。同时需要开启rabbitmq的消息返回模式。

spring.rabbitmq.publisher-returns=true
spring.rabbitmq.template.mandatory=true
/**
 * @author [email protected]
 * @version v1.0
 * @description MyCallBack
 * @date 2022/7/5 10:09
 */
@Slf4j
@Component
public class MyCallBack implements RabbitTemplate.ReturnCallback {

    @Autowired
    private RabbitTemplate rabbitTemplate;


    @PostConstruct  // 在Bean创建并完成赋值,执行初始化之前调用
    public void init() {
        // 注入到RabbitTemplate
        rabbitTemplate.setReturnCallback(this);
    }

    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        // 可以在当消息传递过程中不可达目的地时将消息返回给生产者
        log.info("消息{},被交换机{}退回,退回原因:{},路由Key:{}", new String(message.getBody()),
                exchange, replyText, routingKey);
    }
}

原生api是添加ReturnListener或实现ReturnCallback

channel.addReturnListener(new ReturnListener() {
    public void handleReturn(int replyCode, String replyText, String exchange, String routingKey, AMQP
            .BasicProperties basicProperties, byte[] body) throws IOException {
        String message = new String(body);
        System.out.println("Basic.Return返回的结果是:" + message);
    }
});

2)备份交换机

  备份交换器,英文名称Alternate Exchange,简称AE,或者更直白的可以称之为“备胎交换器”。生产者在发送消息的时候如果不设置mandatory参数,那么消息在未被路由的情况下将会丢失,如果设置了mandatory参数,那么需要添加ReturnListener的编程逻辑,生产者的代码将变得复杂化。如果你不想复杂化生产者的编程逻辑,又不想消息丢失,那么可以使用备份交换器,这样可以将未被路由的消息存储在RabbitMQ中,再在需要的时候去处理这些消息。 可以通过在声明交换器(调用channel.exchangeDeclare方法)的时候添加alternate-exchange参数来实现,也可以通过策略的方式实现。如果两者同时使用的话,前者的优先级更高,会覆盖掉Policy的设置。(如果同时配置mandatory参数和备份交换机,则只有备份交换机生效)

在这里插入图片描述
以下不包含生产者和消费者代码:

/**
 * @author [email protected]
 * @version v1.0
 * @description ConfirmCOnfig
 * @date 2022/7/6 17:40
 */
public class ConfirmConfig {
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
    public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";
    public static final String BACKUP_QUEUE_NAME = "backup.queue";
    public static final String WARNING_QUEUE_NAME = "warning.queue";
    // 声明确认队列
    @Bean("confirmQueue")
    public Queue confirmQueue(){
        return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
    }
    //声明确认队列绑定关系
    @Bean
    public Binding queueBinding(@Qualifier("confirmQueue") Queue queue,
                                @Qualifier("confirmExchange") DirectExchange exchange){
        return BindingBuilder.bind(queue).to(exchange).with("key1");
    }
    //声明备份 Exchange
    @Bean("backupExchange")
    public FanoutExchange backupExchange(){
        return new FanoutExchange(BACKUP_EXCHANGE_NAME);
    }
    //声明确认 Exchange 交换机的备份交换机
    @Bean("confirmExchange")
    public DirectExchange confirmExchange(){
        ExchangeBuilder exchangeBuilder =
                ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
                        .durable(true)
                        //设置该交换机的备份交换机
                        .withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME);
        return (DirectExchange)exchangeBuilder.build();
    }
    // 声明警告队列
    @Bean("warningQueue")
    public Queue warningQueue(){
        return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
    }
    // 声明报警队列绑定关系
    @Bean
    public Binding warningBinding(@Qualifier("warningQueue") Queue queue,
                                  @Qualifier("backupExchange") FanoutExchange
                                          backupExchange){
        return BindingBuilder.bind(queue).to(backupExchange);
    }
    // 声明备份队列
    @Bean("backQueue")
    public Queue backQueue(){
        return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
    }
    // 声明备份队列绑定关系
    @Bean
    public Binding backupBinding(@Qualifier("backQueue") Queue queue,
                                 @Qualifier("backupExchange") FanoutExchange backupExchange){
        return BindingBuilder.bind(queue).to(backupExchange);
    }
}

3. Queue Sotrage

当消息到达队列后,进行存放等待消费者获取,但我们都知道,RabbitMQ的实现是基于内存的,那这就会有个问题,如果RabbitMQ挂了,那重启后数据就丢失了,所以相关的数据应该持久化到磁盘中,这样就算RabbitMQ重启后也可以到硬盘中取数据恢复。那如何持久化呢?

message消息到达RabbitMQ后先是到exchange交换机中,然后路由给queue队列,最后发送给消费端。就需要给exchange、queue和message都进行持久化:
在这里插入图片描述

exchange持久化(exchange非必要情况可以选择不作持久化)

//第三个参数true表示这个exchange持久化
channel.exchangeDeclare(EXCHANGE_NAME, "direct", true);

queue持久化:

//第二个参数true表示这个queue持久化
channel.queueDeclare(QUEUE_NAME, true, false, false, null);

message持久化:

//第三个参数MessageProperties.PERSISTENT_TEXT_PLAIN表示这条消息持久化
channel.basicPublish(EXCHANGE_NAME, ROUTING_KEY, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes(StandardCharsets.UTF_8));

这样,如果RabbitMQ收到消息后挂了,重启后会自行恢复消息。但是却无法避免单机故障且无法修复(比如磁盘损毁)而引起的消息丢失,这里就需要引入镜像队列。镜像队列相当于配置了副本,绝大多数分布式的东西都有多副本的概念来确保HA。在镜像队列中,如果主节点(master)在此特殊时间内挂掉,可以自动切换到从节点(slave),这样有效的保证了高可用性,除非整个集群都挂掉。虽然这样也不能完全的保证RabbitMQ消息不丢失(比如机房被炸。。。),但是配置了镜像队列要比没有配置镜像队列的可靠性要高很多,在实际生产环境中的关键业务队列一般都会设置镜像队列。

参考文章👉Docker搭建RabbitMQ集群

4. Queue-to-Consumer

为了保证消息从队列可靠地达到消费者,RabbitMQ提供了消息确认机制(message acknowledgement)。当autoAck等于false时,RabbitMQ会等待消费者显式地回复确认信号后才从内存(或者磁盘)中移去消息(实质上是先打上删除标记,之后再删除)。当autoAck等于true时,RabbitMQ会自动把发送出去的消息置为确认,然后从内存(或者磁盘)中删除,而不管消费者是否真正的消费到了这些消息。

采用消息确认机制后,只要设置autoAck参数为false,消费者就有足够的时间处理消息(任务),不用担心处理消息过程中消费者进程挂掉后消息丢失的问题,因为RabbitMQ会一直等待持有消息直到消费者显式调用Basic.Ack命令为止。

  • 如果RabbitMQ一直没有收到消费者的确认信号,并且消费此消息的消费者已经断开连接,则RabbitMQ会安排该消息重新进入队列,等待投递给下一个消费者,当然也有可能还是原来的那个消费者。
  • RabbitMQ不会为未确认的消息设置过期时间,它判断此消息是否需要重新投递给消费者的唯一依据是消费该消息的消费者连接是否已经断开

如果消息消费失败,也可以调用Basic.Reject或者Basic.Nack来拒绝当前消息而不是确认,如果只是简单的拒绝那么消息会丢失,需要将相应的requeue参数设置为true,那么RabbitMQ会重新将这条消息存入队列,以便可以发送给下一个订阅的消费者。如果requeue参数设置为false的话,RabbitMQ会丢弃改消息。

还有一种情况需要考虑:requeue的消息是存入队列头部的,如果此时消费者又不能正确的消费而又requeue的话就会进入一个无尽的循环之中。对于这种情况,建议在出现无法正确消费的消息时不要采用requeue的方式来确保消息可靠性,而是重新投递到新的队列中,比如设定的死信队列中,以此可以避免前面所说的死循环而又可以确保相应的消息不丢失。对于死信队列中的消息可以用另外的方式来消费分析,以便找出问题的根本。

5. Others

RabbitMQ提供的几种机制都介绍完了,但这样还不足以保证消息可靠性投递RabbitMQ中,上面我也提到了会有极端情况,比如RabbitMQ收到消息还没来得及将消息持久化到硬盘时,RabbitMQ挂了,这样消息还是丢失了,或者RabbitMQ在发送确认消息给生产端的过程中,由于网络故障而导致生产端没有收到确认消息,这样生产端就不知道RabbitMQ到底有没有收到消息,就不好做接下来的处理。(思考:rabbitMQ中有很多回调接口是否有超时限制,否则可能由于网络回调迟迟未能触发???)
在这里插入图片描述
所以除了RabbitMQ提供的一些机制外,我们自己也要做一些消息补偿机制,以应对一些极端情况。接下来我就介绍其中的一种解决方案——消息入库。

消息入库

消息入库,顾名思义就是将要发送的消息保存到数据库中。

首先发送消息前先将消息保存到数据库中,有一个状态字段status=0,表示生产端将消息发送给了RabbitMQ但还没收到确认;在生产端收到确认后将status设为1,表示RabbitMQ已收到消息。这里有可能会出现上面说的两种情况,所以生产端这边开一个定时器,定时检索消息表,将status=0并且超过固定时间后(可能消息刚发出去还没来得及确认这边定时器刚好检索到这条status=0的消息,所以给个时间)还没收到确认的消息取出重发(第二种情况下这里会造成消息重复,消费者端要做幂等性),可能重发还会失败,所以可以做一个最大重发次数,超过就做另外的处理。
在这里插入图片描述
这样消息就可以可靠性投递到RabbitMQ中了,而生产端也可以感知到了。

消息提前持久化 + 定时任务

其实本质的原因是无法确定是否持久化?那我们是不是可以自己让消息持久化呢?答案是可以的,我们的方案再一步的演化。
在这里插入图片描述
上图流程:

(1)订单服务生产者再投递消息之前,先把消息持久化到Redis或DB中,建议Redis,高性能。消息的状态为发送中。

(2)confirm机制监听消息是否发送成功?如ack成功消息,删除Redis中此消息。

(3)如果nack不成功的消息,这个可以根据自身的业务选择是否重发此消息。也可以删除此消息,由自己的业务决定。

(4)这边加了个定时任务,来拉取隔一定时间了,消息状态还是为发送中的,这个状态就表明,订单服务是没有收到ack成功消息。

(5)定时任务会作补偿性的投递消息。这个时候如果MQ回调ack成功接收了,再把Redis中此消息删除。

这样的机制其实就是一个补偿机制,我不管MQ有没有真正的接收到,只要我的Redis中的消息状态也是为【发送中】,就表示此消息没有正确成功投递。再启动定时任务去监控,发起补偿投递。

当然定时任务那边我们还可以加上一个补偿的次数,如果大于3次,还是没有收到ack消息,那就直接把消息的状态设置为【失败】,由人工去排查到底是为什么?

这样的话方案就比较完美了,保障了100%的消息不丢失(当然不包含磁盘也坏了,可以做主从方案)。

不过这样的方案,就会有可能发送多次相同的消息,很有可能MQ已经收到了消息,就是ack消息回调时出现网络故障,没有让生产者收到。

那就要要求消费者一定在消费的时候保障幂等性!

幂等性设计

我们先了解一下什么叫幂等?在分布式应用中,幂等是非常重要的,也就是相同条件下对一个业务的操作,不管操作多少次,结果都是一样。

1)乐观锁

借鉴数据库的乐观锁机制,如:

在这里插入图片描述
根据version版本,也就是在操作库存前先获取当前商品的version版本号,然后操作的时候带上此version号。我们梳理下,我们第一次操作库存时,得到version为1,调用库存服务version变成了2;但返回给订单服务出现了问题,订单服务又一次发起调用库存服务,当订单服务传如的version还是1,再执行上面的sql语句时,就不会执行;因为version已经变为2了,where条件就不成立。这样就保证了不管调用几次,只会真正的处理一次。

2)唯一ID + 指纹码

原理就是利用数据库主键去重,业务完成后插入主键标识
在这里插入图片描述

  • 唯一ID就是业务表的唯一的主键,如商品ID
  • 指纹码就是为了区别每次正常操作的码,每次操作时生成指纹码;可以用时间戳+业务编号的方式。(这根据业务来定)

上面的sql语句:

  • 返回如果为0 表示没有操作过,那业务操作后就可以insert into t_check(唯一ID+指纹码)
  • 返回如果大于0 表示操作过,就直接返回

好处:实现简单

坏处:高并发下数据库瓶颈

解决方案:根据ID进行分库分表进行算法路由

3)Redis原子操作

利用redis的原子操作(如setnx 命令,天然具有幂等性),做个操作完成的标记。这个性能就比较好。但会遇到一些问题。

第一:我们是否需要把业务结果进行数据落库,如果落库,关键解决的问题时数据库和redis操作如何做到原子性?

思考:这个意思就是库存减1了,但redis进行操作完成标记时,失败了怎么办?也就是一定要保证落库和redis 要么一起成功,要么一起失败?

第二:如果不进行落库,那么都存储到缓存中,如何设置定时同步策略?

思考:这个意思就是库存减1,不落库,直接先操作redis操作完成标记,然后由另外的同步服务进行库存落库,这个就是增加了系统复杂性,而且同步策略如何设置。

好了,到此从生产端到RabbitMQ再到消费端的全链路,就可以极大地保证数据不丢失。

参考博文:

RabbitMQ消息可靠性分析

RabbitMQ消息可靠性投递解决方案 - 基于SpringBoot实现

消息中间件如何确保消息100%投递成功及消息的幂等性设计

如何保证RabbitMQ全链路数据100%不丢失

;