消息队列--如何保证消息的可靠性

208 阅读12分钟

一、什么是消息可靠性

消息可靠性是指:在消息传递的过程中,确保消息不丢失,不重复、且按顺序到达对方。 这里我们简单画一下,消息的可靠性问题的场景

image.png

二、消息丢失

1、消息丢失的场景

image.png

消息队列完整的消息处理场景是:先由生产者生产消息,发送MQ服务器。消息先发送的MQ服务器的Exchange(交换机)。交换机再根据我们设置的路由规则发送到对应的队列。最后消费者从队列中取出消息来进行消费

上述的三个节点,都有可能存在消息丢失的情况

  • 生产者丢失消息:生产者生产消息发送到MQ服务器,由于网络问题或者服务器问题,可能会导致消息没有到达服务器,造成消息发送失败,从而导致消息丢失
  • MQ丢失消息:消息存放在MQ服务器的队列当中,如果MQ服务器故障崩溃或者MQ服务器重启,可能会导致消息队列中的数据丢失。
  • 消费者丢失消息:消费者从队列中取出消息消费,可能会由于程序问题导致消费失败,但此时MQ服务器中已经没有这个消息了,也会导致消息丢失

2、消息丢失的处理方案

消息丢失的节点可能有三处,因此我们针对不同节点,都需要进行处理,从而防止消息丢失

2.1 生产者消息丢失的解决方案

对于生产者消息丢失问题,一般有两个方案

  • 开启消息发送事务功能;
  • 开启 Confirm 消息确认机制。
2.1.1 开启消息发送事务功能

我们可以选择使用 RabbitMQ 提供的事务功能:生产者在发送数据之前开启事物,然后再发送消息。

如果消息没有成功被 RabbitMQ 接收到,那么生产者会受到异常报错,这时就可以回滚事务,然后尝试重新发送;如果收到了消息,那么就可以提交事务。

伪代码如下:

channel.txSelect();
try{
    //发送消息到MQ服务器
}catch(Exception e){
    //消息发送失败
    channel.txRollback();  //回滚事务
    //重试发送
}

这种方案有个比较大的缺点:RabbitMQ 事务一旦开启,就会变为同步阻塞操作,生产者会阻塞等待是否发送成功,由于比较耗性能而会造成吞吐量的下降。所以并不推荐这种方案。

2.1.2 开启Confirm消息确认机制

在生产者中开启了Confirm 模式,为每次写的消息分配一个唯一的 ID,然后再发送给 RabbitMQ 服务

如果成功写入到了 RabbitMQ 之中,RabbitMQ 会给你回传一个 ACK 消息,告诉你这个消息发送 OK 了;

如果 RabbitMQ 没能处理这个消息,就会回调你一个 NACK 接口,告诉你这个消息失败了,你可以进行重试。

同时也可以结合这个机制知道自己在内存里维护每个消息的 ID,如果超过一定时间还没接收到这个消息的回调,那么可以尝试进行重发。

伪代码如下:

//开启confirm
channel.confirm();

//发送成功回调
public void ack(String messageId){
}

// 发送失败回调
public void nack(String messageId){
    //重发该消息
}

由于事务机制是同步阻塞的,而 Confirm 机制是异步的,在发送消息之后可以接着发送下一个消息,最后通过 RabbitMQ 的回调告知成功与否,所以,生产者消息丢失方案一般都是采用 Confirm 确认机制。

这里发送失败后,所说的重新发送该消息,其实指的是一个重试机制 不用立马就重新发送一次,你可以现将错误的消息记录到数据库里面。然后可以定期扫描然后重新发送

2.1.3 代码实践

首先有一个前提,你的项目是一个SpringBoot项目,并且简单集成了RabbitMQ

1、首先先修改我们的生产者服务的配置。在application.yml中添加下面内容

spring:
  rabbitmq:
    publisher-confirm-type: correlated
    publisher-returns: true
    template:
      mandatory: true

说明:

  • publish-confirm-type:开启publisher-confirm,这里支持两种类型:
  • simple:同步等待confirm结果,直到超时
  • correlated:异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
  • publish-returns:开启publish-return功能,同样是基于callback机制,不过是定义ReturnCallback
  • template.mandatory:定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息

2、定义ReturnCallback

每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目加载时配置

import lombok.extern.slf4j.Slf4j;
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;

@Slf4j
@Configuration
public class CommonConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        // 获取RabbitTemplate
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        // 设置ReturnCallback
        rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
            // 投递失败,记录日志
            log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
                     replyCode, replyText, exchange, routingKey, message.toString());
            // 如果有业务需要,可以重发消息
        });
    }
}

3、定义ConfirmCallback

ConfirmCallback可以在发送消息时指定,因为每个业务处理confirm成功或失败的逻辑不一定相同。

public void testSendMessage2SimpleQueue() throws InterruptedException {
    // 1.消息体
    String message = "hello, spring amqp!";
    // 2.全局唯一的消息ID,需要封装到CorrelationData中
    CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
    // 3.添加callback
    correlationData.getFuture().addCallback(
        result -> {
            if(result.isAck()){
                // 3.1.ack,消息成功
                log.debug("消息发送成功, ID:{}", correlationData.getId());
            }else{
                // 3.2.nack,消息失败
                log.error("消息发送失败, ID:{}, 原因{}",correlationData.getId(), result.getReason());
            }
        },
        ex -> log.error("消息发送异常, ID:{}, 原因{}",correlationData.getId(),ex.getMessage())
    );
    // 4.发送消息
    rabbitTemplate.convertAndSend("task.direct", "task", message, correlationData);

    // 休眠一会儿,等待ack回执
    Thread.sleep(2000);
}

2.2 MQ队列丢失消息的解决方案

对于 MQ 队列丢失消息的问题,我们可以开启消息的持久化,当然队列(交换机)本身也要开启持久化,毕竟队列(交换机)如果不存在了,哪怕消息持久化也没有用。

开启了消息队列的持久化后,可以将消息的持久化和生产者的 Confirm 机制配合起来,只有消息持久化到了磁盘,才会给生产者发送 ACK,这样就算是在持久化之前 RabbitMQ 挂了,数据丢了,生产者收不到 ACK 回调也会进行消息重发。

持久化有个关键的问题需要注意:

消息在正确存入 RabbitMQ 之后,还需要有一段时间(这个时间很短,但不可忽视)才能存入磁盘之中。因为 RabbitMQ 并不是为每条消息都做刷新磁盘的处理,可能仅仅保存到 缓存 中而不是物理磁盘上,后续统一存储到磁盘当中。那么在这段时间内 RabbitMQ 的 broker 发生宕机,消息保存到 缓存中 但是还没来得及落盘,那么这些消息将会丢失。

解决这个问题的方案是 RabbitMQ 开启镜像队列,镜像队列相当于配置了副本,当 master 在此特殊时间内 宕机,可以自动切换到 slave,这样有效地保障了数据的丢失。

2.2.1 代码实践

1、交换机持久化

RabbitMQ中交换机默认是非持久化的,mq重启后就丢失。 SpringAMQP中可以通过代码指定交换机持久化:

@Bean
public DirectExchange simpleExchange(){
    // 三个参数:交换机名称、是否持久化、当没有queue与其绑定时是否自动删除
    return new DirectExchange("simple.direct", true, false);
}

2、队列持久化

RabbitMQ中队列默认是非持久化的,mq重启后就丢失。 SpringAMQP中可以通过代码指定交换机持久化:

@Bean
public Queue simpleQueue(){
    // 使用QueueBuilder构建队列,durable就是持久化的
    return QueueBuilder.durable("simple.queue").build();
}

3、消息持久化

public void testDurableMessage(){
    //创建消息
    Message message = MessageBuilder
        .withBody("hello,ttl queue".getBytes(StandardCharsets.UTF_8))
        .setDeleverMode(MessageDeliveryMode.PERSISTENT)
        .build();
        
        //消息ID
        CorrelationData correlationdata = new CorrelationData(UUID.randomUUID()).toString());
        //发送消息
        rabbitTemplate.convertAndSent("simple.queue",message,correlationdata);
        //记录日志
        log.debug("发送消息成功");
}

2.3 消费者丢失消息的解决方案

针对消费者丢失消息问题,我们可以使用 RabbitMQ 提供的 ACK 应答机制,首先需要将 自动应答标志位 autoAck 设置为 false 来关闭 RabbitMQ 的自动ack,这是为了防止 Consumer 收到消息后,还没来得及处理完成就宕机掉了。所以我们采用手动应答的方式:

String basicConsume(String queue,boolean autoAck,Consumer callback) thrwos IOException;

然后在消费者执行完毕之后手动应答: chanel.basicAck

而SpringAMQP则允许配置三种确认模式:

  • manual:手动ack,需要在业务代码结束后,调用api发送ack。
  • auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
  • none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除

3、消费失败重试机制

3.1 本地重试

我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。 修改consumer服务的application.yml文件,添加内容:

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

3.2 失败策略

在之前的测试中,达到最大重试次数后,消息会被丢弃,这是由Spring内部机制决定的。 在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecovery接口来处理,它包含三种不同的实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.retry.MessageRecoverer;
import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer;
import org.springframework.context.annotation.Bean;

@Configuration
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){
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
}

三、消息重复消费

1、消息重复消费的场景

1.1 生产者发送多次消息给MQ

生产者发送消息给MQ,在MQ确认的时候出现了网络波动,生产者没有收到确认,这时候生产者就会重新发送这条消息,导致MQ会接收到重复消息。

1.2 MQ的一条消息被消费了多次

消费者消费成功后,给MQ确认的时候出现了网络波动,MQ没有接收到确认,为了保证消息不丢失,MQ就会继续给消费者投递之前的消息。这时候消费者就接收到了两条一样的消息。由于重复消息是由于网络原因造成的,无法避免。

2、消息重复消费的处理方案

首先得明白,消息的重复这种事不可避免的。当网络出现问题的时候,是会存在消息的重复发送或者重复消费的

1、发送消息时,让每个消息携带一个全局唯一的ID

2、在消费消息时先判断消息是否已经被消费过,保证消息消费逻辑的幂等性。

四、消息积压

1、消息积压的场景

出现了消息挤压,一般直接的原因是系统某个地方出现了性能问题,导致消费者来不及处理生产者生产的消息。这要是以下几点:

• 消费者宕机积压

• 消费者消费能力不足积压

• 发送者发送流量太大

2、消息积压的处理方案

• 上线更多的消费者,进行正常消费

• 上线专门的队列消费服务,将消息先批量取出来,记录数据库,离线慢慢处理

五、消息顺序性

首先,我们需要了解一点。MQ服务器是通过队列来存储消息的。由于队列的这个天然的数据结构是具有先进先出的特点的。所以消息在MQ保存的时候天然具有顺序性 那么我们这里考虑的顺序性主要有两点,一个是消息发送的顺序性,另外一个是消息消费的顺序性

1、消息发送顺序性

正常来说,我们发送消息的时候都是按照既定的业务顺序发送的,这点是无疑的。所以发送有序本来不是啥大事,问题在于,有的时候我们的项目是集群化部署,同一个项目有多个实例,当多个不同的实例分布于不同的服务器上运行的时候,都向 MQ 发消息,此时就无法确保消息的有序了。 那么对于这种情况,我们可以考虑使用 Redis 分布式锁来实现,发送消息之前,先去 Redis 上获取到锁,能拿到锁,然后再去发送消息,避免并发发送。

2、消息消费顺序性

消息消费的顺序性问题,主要是同一个队列绑定了多个消费者。queue(队列)中的消息只能被一个消费者所消费,然后多个消费者在消费消息的过程中是无序的,多个消费者消费同一个queue,顺序错乱就会导致数据的不一致。这和我们预期的结果不符,如果这样的情况很多,那么就造成了数据库中的数据完成不对,同步工作也是白费了。 你可能会有疑问,即使是同一个队列绑定了多个消费者。那也是按顺序从队列中取的呀,为啥会顺序错乱。是这样的。我们的消息在被消费的时候,不同的消息处理时间可能是不一样的。可能A消息5秒处理完, B消息1秒就能处理完。这样是不能保证顺序的