RabbitMQ用法和面试题

文章目录

  • 1.初识MQ
    • [1.1 同步通讯和异步通讯的区别](#1.1 同步通讯和异步通讯的区别)
    • [1.2 同步调用](#1.2 同步调用)
    • [1.3 异步调用](#1.3 异步调用)
    • [1.4 几种常见MQ的对比](#1.4 几种常见MQ的对比)
  • 2.RabbitMQ
    • [2.1 安装](#2.1 安装)
    • [2.2 收发消息](#2.2 收发消息)
      • [2.2.1 队列](#2.2.1 队列)
      • [2.2.2 交换机](#2.2.2 交换机)
      • [2.2.3 发送消息](#2.2.3 发送消息)
    • [2.3 数据隔离](#2.3 数据隔离)
  • 3.SpringAMQP
    • [3.1 SpringAMQP的使用](#3.1 SpringAMQP的使用)
      • [3.1.1 发送消息](#3.1.1 发送消息)
      • [3.1.2 接收消息](#3.1.2 接收消息)
    • [3.2 Work Queue 工作队列](#3.2 Work Queue 工作队列)
    • [3.3 交换机](#3.3 交换机)
      • [3.3.1 Fanout交换机](#3.3.1 Fanout交换机)
      • [3.3.2 Direct交换机](#3.3.2 Direct交换机)
      • [3.3.3 Topic交换机](#3.3.3 Topic交换机)
    • [3.4 声明队列和交换机](#3.4 声明队列和交换机)
      • [3.4.1 基于@Bean的方式声明队列和交换机](#3.4.1 基于@Bean的方式声明队列和交换机)
      • [3.4.2 基于注解方式来声明](#3.4.2 基于注解方式来声明)
    • [3.5 消息转换器](#3.5 消息转换器)
  • 4.确保MQ消息的可靠性
    • 4.1消息丢失
    • [4.1 发送者的可靠性](#4.1 发送者的可靠性)
      • [4.1.1 生产者重试机制](#4.1.1 生产者重试机制)
      • [4.1.2 生产者确认机制](#4.1.2 生产者确认机制)
      • [4.1.2.1 实现生产者确认](#4.1.2.1 实现生产者确认)
          • [4.1.2.1 开启生产者确认](#4.1.2.1 开启生产者确认)
        • [4.2.1.2 定义ReturnCallback](#4.2.1.2 定义ReturnCallback)
        • [4.2.1.3 定义ConfirmCallback](#4.2.1.3 定义ConfirmCallback)
    • [4.3 MQ的可靠性](#4.3 MQ的可靠性)
      • [4.3.1 数据持久化](#4.3.1 数据持久化)
    • [7.2 LazyQueue](#7.2 LazyQueue)
      • [7.2.1 配置Lazy模式](#7.2.1 配置Lazy模式)
    • [4.4 消费者的可靠性](#4.4 消费者的可靠性)
    • [8.1 消费者确认机制](#8.1 消费者确认机制)
    • [8.2 失败重试机制](#8.2 失败重试机制)
      • [8.2.1 失败处理策略](#8.2.1 失败处理策略)
    • [8.3 业务幂等性](#8.3 业务幂等性)
      • [8.3.1 解决非幂等性问题](#8.3.1 解决非幂等性问题)
    • [8.4 兜底方案](#8.4 兜底方案)

1.初识MQ

1.1 同步通讯和异步通讯的区别

(1)同步通讯就好比你和一个人打电话,一次只能和一个人打电话

(2)异步通讯就好比你和别人发微信,一次可以和多个人发微信

1.2 同步调用

以下图为例:我们完成了支付服务需要50ms,还要等待订单服务完成150ms,仓储服务完成150ms,短信

服务150ms,总共500ms

(1)同步调用的优点:

时效性强,可以立即得到结果

(2)同步调用的缺点:

①耦合度高:每次加入新的需求,都要修改原来的代码

②性能下降:调用者需要等待服务提供者的响应,如果调用链过长则响应时间等于每次调用的时间

之和

③资源浪费:调用链中的每个服务在等待响应过程中,不能释放请求占用的资源,高并发场景下会

极度浪费系统资源

④级联失败:如果服务提供者出现问题,所有调用者都会跟着出问题。

1.3 异步调用

异步调用常见的实现就是事件驱动模式。以下图为例,使用异步调用。支付业务需要50ms,发布订单成功事件需要10ms,后面的订单服务,仓储服务,短信服务不会影响我支付服务的成功

(1)事件驱动(异步调用)的优势:

①服务解耦

②性能提升,吞吐量提高

③服务没有强依赖,不担心级联失败问题

④流量削峰(Broker可以起到缓冲的作用)

(2)异步调用的缺点:

①依赖于Broker的可靠性、安全性、吞吐能力

②架构复杂了,业务没有明显的流程线,不好追踪管理

1.4 几种常见MQ的对比

追求可用性:Kafka、 RocketMQ 、RabbitMQ

追求可靠性:RabbitMQ、RocketMQ

追求吞吐能力:RocketMQ、Kafka

追求消息低延迟:RabbitMQ、Kafka

据统计,目前国内消息队列使用最多的还是RabbitMQ

2.RabbitMQ

2.1 安装

(1)官网安装地址:https://www.rabbitmq.com/docs/download

(2)命令

复制代码
docker run -d \
  --name rabbitmq \
  -p 5672:5672 \
  -p 15672:15672 \
  -e RABBITMQ_DEFAULT_USER=admin \
  -e RABBITMQ_DEFAULT_PASS=123 \
  rabbitmq:4-management

①-p 5672:5672 - AMQP 协议端口

②-p 15672:15672 - 管理界面端口

(3)安装完成后,我们访问 http://IP地址:15672即可看到管理控制台。首次访问需要登录,默认的用户名和密码在配置文件中已经指定了。登录后即可看到管理控制台总览页面。

(4)RabbitMQ对应的架构如图:

  • publisher:生产者,也就是发送消息的一方
  • consumer:消费者,也就是消费消息的一方
  • queue:队列,存储消息。生产者投递的消息会暂存在消息队列中,等待消费者处理
  • exchange:交换机,负责消息路由。生产者发送的消息由交换机决定投递到哪个队列。
  • virtual host:虚拟主机,起到数据隔离的作用。每个虚拟主机相互独立,有各自的exchange、queue

2.2 收发消息

2.2.1 队列

2.2.2 交换机

(1)打开Exchanges选项卡,可以看到已经存在很多交换机

(2)绑定队列

2.2.3 发送消息

(1)在Exchanges中找到对于的进入详情中,发送消息

(2)回到Queues页面,可以发现队列中已经有一条消息了:点击队列名称,进入详情页,查看队列详情,这次我们点击get message就可以看到消息体的内容了。

2.3 数据隔离

2.3.1.用户管理

(1)点击Admin选项卡,首先会看到RabbitMQ控制台的用户管理界面。这里的用户都是RabbitMQ的管理或运维人员。目前只有安装RabbitMQ时添加的itheima这个用户。仔细观察用户表格中的字段,如下:

Nameadmin,也就是用户名

Tagsadministrator,说明admin用户是超级管理员,拥有所有权限

Can access virtual host/,可以访问的virtual host,这里的/是默认的virtual host

(2)对于小型企业而言,出于成本考虑,我们通常只会搭建一套MQ集群,公司内的多个不同项目同时使用。这个时候为了避免互相干扰, 我们会利用virtual host的隔离特性,将不同项目隔离。一般会做两件事情:

①给每个项目创建独立的运维账号,将管理权限分离。

②给每个项目创建不同的virtual host,将每个项目的数据隔离。

2.3.2 virtual host

(1)点击Virtual Hosts菜单,进入virtual host管理页,新增virtual host

(2)切换到刚才创建的virtual host,就看不到之前创建的queue了

3.SpringAMQP

将来我们开发业务功能的时候,肯定不会在控制台收发消息,而是应该基于编程的方式。由于RabbitMQ采用了AMQP协议,因此它具备跨语言的特性。任何语言只要遵循AMQP协议收发消息,都可以与RabbitMQ交互。并且RabbitMQ官方也提供了各种不同语言的客户端。

但是,RabbitMQ官方提供的Java客户端编码相对复杂,一般生产环境下我们更多会结合Spring来使用。而Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP。并且还基于SpringBoot对其实现了自动装配,使用起来非常方便。

3.1 SpringAMQP的使用

(1)创建publisher和consumer两个服务,用于发布和接收消息。在父工程中引入SpringAMQP依赖,这样两个都可以使用依赖了。

(2)在每个微服务中引入MQ服务端消息,这样微服务才能连接到RabbitMQ

yaml 复制代码
spring:
  rabbitmq:
    host: XXX.XXX.XXX.XXX # 主机名
    port: 5672 # 端口
    virtual-host: / # 虚拟主机
    username: XXX # 用户名
    password: XXX # 密码

3.1.1 发送消息

SpringAMQP提供了RabbitTemplate工具类,方便我们发送消息,在publisher服务中利用rabbitTemplate编写发送消息代码如下:

java 复制代码
@Autowired
private RabbitTemplate rabbitTemplate;
@Test
void testSend(){
	String queueName = "simple.queue"; // 队列名称
	String msg = "hello,mq"; // 消息体
	rabbitTemplate.convertAndSend(queueName,msg); // 发送消息
}

3.1.2 接收消息

SpringAMQP提供声明式的消息监听,我们只需要通过注解RabbitListener在方法上声明要监听的队列名称,将来SpringAMQP就会把消息传递给当前方法。在consumer服务中利用RabbitListener编写代码如下:

java 复制代码
@Component
public class MyListener {
    @RabbitListener(queues = "hello.queue1")// 队列名称
    public void listenSimpleQueue(String msg) {
        System.out.println("msg:" + msg);//接收消息
    }
}

3.2 Work Queue 工作队列

(1)Work queue,工作队列,可以提高消息处理速度,避免队列消息堆积

(2)默认情况下,RabbitMQ会将消息依次轮询投递给绑定在队列上的每一个消费者。但这并没有考虑到消费者是否已经处理完消息,可能会出现消息堆积。

(3)因此我们需要修改消费者的application.yml,设置preFetch的值为1,确保同一时刻最多投递给消费者1条消息,能者多劳,消费者处理完消息当前消息再发给它,没处理完就发送给其他消费者

java 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息

3.3 交换机

真正生产环境都会经过exchange来发送消息,而不是直接发送到队列,交换机的类型有以下三种:

(1)Fanout:广播(3)Direct:定向(3)Topic:话题(4)Headers:头匹配,基于MQ的消息头匹配,用的较少

3.3.1 Fanout交换机

Fanout Exchange会将接收到的消息广播到每一个跟其绑定的queue,所以也叫广播模式

(1)在RabbitMQ控制台中,声明队列,例如:fanout.queue1和fanout.queue2

(2)在RabbitMQ控制台中,声明交换机,例如:hmall.fanout,将两个队列与其绑定


(3)在consumer服务中,编写两个消费者方法,分别监听fanout.queue1和fanout.queue2

java 复制代码
@Component
public class MyListener {
    @RabbitListener(queues = "fanout.queue1")
    public void listenFanoutQueue1(String msg) {
        System.out.println("消费者1收到了fanout.queue的消息:" + msg);
    }
    @RabbitListener(queues = "fanout.queue2")
    public void listenFanoutQueue2(String msg) {
        System.out.println("消费者2收到了fanout.queue的消息:" + msg);
    }
}

(4)在publisher中编写测试方法,向交换机hmall.fanout中发送消息。此时,队列fanout.queue1和fanout.queue2都会收到消息

java 复制代码
  @Test
    void testSendFanout(){
        String exchangeName = "hmall.fanout";
        String msg = "hello,fanout";
        rabbitTemplate.convertAndSend(exchangeName,null,msg);
    }

3.3.2 Direct交换机

Direct Exchange会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由

(1)每一个Queue都与Exchange设置一个Bindingkey

(2)发布者发送消息时,指定消息的RountingKey

(3)Exchange将消息路由到BindingKey与消息RountingKey一致的队列

(4)示例

①在RabbitMQ控制台中,声明队列direct.queue1和direct.queue2

②在RabbitMQ控制台中,声明交换机hmall.direct,将两个队列与其绑定



③在consumer服务中,编写两个消费者方法,分别监听direct.queue1和direct.queue2

java 复制代码
 @RabbitListener(queues = "direct.queue1")
    public void listenDirectQueue1(String msg) {
        System.out.println("消费者1收到了direct.queue的消息:" + msg);
    }
    @RabbitListener(queues = "direct.queue2")
    public void listenDirectQueue2(String msg) {
        System.out.println("消费者2收到了direct.queue的消息:" + msg);
    }

④在publisher中编写测试方法,利用不同的RountingKey向hmall.direct发送消息

java 复制代码
 @Test
    void testSendDirect(){
        String exchangeName = "hmall.direct";
        String msg = "hello,direct";
        rabbitTemplate.convertAndSend(exchangeName,"red",msg);
    }

3.3.3 Topic交换机

(1)TopicExchange与DirectExchange类似,区别在于routingkey可以是多个单词的列表,并且以.分割。Queue与Exchange指定BindingKey时可以使用通配符:

①#:代表0个或多个单词

②*:代指一个单词

(2)假如此时publisher发送的消息使用的RoutingKey共有四种:

  • china.news 代表有中国的新闻消息;
  • china.weather 代表中国的天气消息;
  • japan.news 则代表日本新闻
  • japan.weather 代表日本的天气消息;

解释:

  • topic.queue1:绑定的是china.# ,凡是以 china.开头的routing key 都会被匹配到,包括:
    • china.news
    • china.weather
  • topic.queue2:绑定的是#.news ,凡是以 .news结尾的 routing key 都会被匹配。包括:
    • china.news
    • japan.news

3.4 声明队列和交换机

(1)在之前我们都是基于RabbitMQ控制台来创建队列、交换机。但是在实际开发时,队列和交换机是程序员定义的,将来项目上线,又要交给运维去创建。那么程序员就需要把程序中运行的所有队列和交换机都写下来,交给运维。在这个过程中是很容易出现错误的。因此推荐的做法是由程序启动时检查队列和交换机是否存在,如果不存在自动创建。

(2)SpringAMQP提供了几个类,用来声明队列、交换机及其绑定关系

①Queue:用来声明队列,可以用工厂类QueueBuilder构建

②Exchange:用来声明交换机,可以用工厂类ExchangeBuilder构建

③Binding:用来声明队列和交换机的绑定关系,可以用工厂类BindingBuilder构建

(3)因为消费者必须知道他们的队列和哪个交换器绑定,在consumer中创建一个类,声明队列和交换机。一般是在消费者服务中去写配置

3.4.1 基于@Bean的方式声明队列和交换机

java 复制代码
@Configuration
public class FanoutConfig {
    /**
     * 声明交换机
     * @return Fanout类型交换机
     */
    @Bean
    public FanoutExchange fanoutExchange(){
        return new FanoutExchange("hmall.fanout");
    }

    /**
     * 第1个队列
     */
    @Bean
    public Queue fanoutQueue1(){
        return new Queue("fanout.queue1");
    }

    /**
     * 绑定队列和交换机
     */
    @Bean
    public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }

    /**
     * 第2个队列
     */
    @Bean
    public Queue fanoutQueue2(){
        return new Queue("fanout.queue2");
    }

    /**
     * 绑定队列和交换机
     */
    @Bean
    public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){
        return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
    }
}

3.4.2 基于注解方式来声明

基于@Bean的方式声明队列和交换机比较麻烦,Spring还提供了基于注解方式来声明。

java 复制代码
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue1"),
    exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "blue"}
))
public void listenDirectQueue1(String msg){
    System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "direct.queue2"),
    exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
    key = {"red", "yellow"}
))
public void listenDirectQueue2(String msg){
    System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}

3.5 消息转换器

(1)Spring对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter来处理的。而默认实现是SimpleMessageConverter,基于JDK的ObjectOutputStream完成序列化。会存在下列问题:

①JDK的序列化有安全风险

②JDK序列化的消息太大

③JDK序列化的可读性差

(2)显然,JDK序列化方式并不合适。我们希望消息体的体积更小、可读性更高,因此可以使用JSON方式来做序列化和反序列化。

①在publisherconsumer两个服务中都引入jackson依赖:

java 复制代码
<dependency>
    <groupId>com.fasterxml.jackson.dataformat</groupId>
    <artifactId>jackson-dataformat-xml</artifactId>
    <version>2.9.10</version>
</dependency>

注意,如果项目中引入了spring-boot-starter-web依赖,则无需再次引入Jackson依赖。

②在publisherconsumer两个服务中都要配置MessageConverter

java 复制代码
@Bean
public MessageConverter messageConverter(){
	return new jackson2JsonMessageConverter();
}

4.确保MQ消息的可靠性

4.1消息丢失

(1)首先,我们分析一下消息丢失的可能性有哪些。消息从发送者发送消息,到消费者处理消息,需要经过的流程是这样的:

(2)消息从生产者到消费者的每一步都可能导致消息丢失:

  • 发送消息时丢失:
    • 生产者发送消息时连接MQ失败
    • 生产者发送消息到达MQ后未找到Exchange
    • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue
    • 消息到达MQ后,处理消息的进程发生异常
  • MQ导致消息丢失:
    • 消息到达MQ,保存到队列后,尚未消费就突然宕机
  • 消费者处理消息时:
    • 消息接收后尚未处理突然宕机
    • 消息接收后处理过程中抛出异常

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

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

4.1 发送者的可靠性

4.1.1 生产者重试机制

(1)首先第一种情况,就是生产者发送消息时,出现了网络故障,导致与MQ的连接中断。

(2)为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。即:当RabbitTemplate与MQ连接超时后,多次重试。

(3)修改publisher模块的application.yaml文件,添加下面的内容:

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

(4)注意:当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式 的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。

如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。

4.1.2 生产者确认机制

(1)在少数情况下,也会出现消息发送到MQ之后丢失的现象,比如:

  • MQ内部处理消息的进程发生了异常
  • 生产者发送消息到达MQ后未找到Exchange
  • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由

(2)针对上述情况,RabbitMQ提供了Publisher ConfirmPublisher Return两种确认机制。在开启确认机制后,在MQ成功收到消息后会返回确认消息给生产者,返回的结果有以下几种情况:

①当消息投递到MQ,但是路由失败时,通过Publisher Return 返回异常原因,同时返回ack的确认信息,代表投递成功

②临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功

③持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功

④其它情况都会返回NACK,告知投递失败

(3)其中acknack属于Publisher Confirm 机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。默认两种机制都是关闭状态,需要通过配置文件来开启。

4.1.2.1 实现生产者确认

4.1.2.1 开启生产者确认

(1)在publisher模块的application.yaml中添加配置:

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

(2)这里publisher-confirm-type有三种模式可选:

  • none:关闭confirm机制
  • simple:同步阻塞等待MQ的回执消息
  • correlated:MQ异步回调方式返回回执消息(一般我们推荐这种)
4.2.1.2 定义ReturnCallback

(1)每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目启动过程中配置。我们在publisher模块定义一个配置类:

java 复制代码
@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(new RabbitTemplate.ReturnCallback() {
            @Override
            public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
                log.info("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}", replyCode, replyText, exchange, routingKey, message);
            }
        });
    }
}
4.2.1.3 定义ConfirmCallback

(1)由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义。具体来说,是在调用RabbitTemplate中的convertAndSend方法时,多传递一个参数:

(2)这里的CorrelationData中包含两个核心的东西:

  • id:消息的唯一标示,MQ对不同的消息的回执以此做判断,避免混淆
  • SettableListenableFuture:回执结果的Future对象

将来MQ的回执就会通过这个Future来返回,我们可以提前给CorrelationData中的Future添加回调函数来处理消息回执:

(3)例子:

java 复制代码
@Test
void testPublisherConfirm() {
    // 1.创建CorrelationData
    CorrelationData cd = new CorrelationData();
    // 2.给Future添加ConfirmCallback
    cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
        @Override
        public void onFailure(Throwable ex) {
            // 2.1.Future发生异常时的处理逻辑,基本不会触发
            log.error("send message fail", ex);
        }
        @Override
        public void onSuccess(CorrelationData.Confirm result) {
            // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
            if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
                log.debug("发送消息成功,收到 ack!");
            }else{ // result.getReason(),String类型,返回nack时的异常描述
                log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
            }
        }
    });
    // 3.发送消息
    rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}

(4)注意

开启生产者确认比较消耗MQ性能,一般不建议开启。而且大家思考一下触发确认的几种情况:

  • 路由失败:一般是因为RoutingKey错误导致,往往是编程导致
  • 交换机名称错误:同样是编程错误导致
  • MQ内部故障:这种需要处理,但概率往往较低。因此只有对消息可靠性要求非常高的业务才需要开启,而且仅仅需要开启ConfirmCallback处理nack就可以了。

4.3 MQ的可靠性

消息到达MQ以后,如果MQ不能及时保存,也会导致消息丢失,所以MQ的可靠性也非常重要。

4.3.1 数据持久化

为了提升性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:交换机持久化、队列持久化、消息持久化。我们以控制台界面为例来说明:

(1)交换机持久化

在控制台的Exchanges页面,添加交换机时可以配置交换机的Durability参数:

设置为Durable就是持久化模式,Transient就是临时模式。

(2)队列持久化

在控制台的Queues页面,添加队列时,同样可以配置队列的Durability参数:

(3)消息持久化

在控制台发送消息的时候,可以添加很多参数,而消息的持久化是要配置一个properties

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

不过出于性能考虑,为了减少IO次数,发送到MQ的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在100毫秒左右,这就会导致ACK有一定的延迟,因此建议生产者确认全部采用异步方式。

7.2 LazyQueue

(1)在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:

①消费者宕机或出现网络故障

②消息发送量激增,超过了消费者处理速度

③消费者处理业务发生阻塞

(2)一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为称为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。

(3)为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

①接收到消息后直接存入磁盘而非内存

②消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)

③支持数百万条的消息存储

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

7.2.1 配置Lazy模式

(1)控制台配置Lazy模式

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

(2)代码配置Lazy模式

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

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

②这里是通过QueueBuilderlazy()函数配置Lazy模式,底层源码如下:

(2)当然,我们也可以基于注解来声明队列并设置为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);
}

4.4 消费者的可靠性

(1)当RabbitMQ向消费者投递消息以后,需要知道消费者的处理状态如何。因为消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:

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

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

8.1 消费者确认机制

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

①ack:成功处理消息,RabbitMQ从队列中删除该消息

②nack:消息处理失败,RabbitMQ需要再次投递消息

③reject:消息处理失败并拒绝该消息(比如消息格式有问题),RabbitMQ从队列中删除该消息

(2)一般reject方式用的较少,除非是消息格式有问题,那就是开发问题了。因此大多数情况下我们需要将消息处理的代码通过try catch机制捕获,消息处理成功时返回ack,处理失败时返回nack.

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

①none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用

②manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活

③auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:

  • 如果是业务异常 ,会自动返回nack
  • 如果是消息处理或校验异常 ,自动返回reject;

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

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: none # 不做处理

8.2 失败重试机制

(1)当消费者出现异常后,消息会不断requeue(重新入队)到队列,再重新发送给消费者,然后再次异常,再次requeue到队列,无限循环,导致mq的消息处理飙升,带来不必要的压力:

(2)为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。

(3)修改consumer服务的application.yml文件,添加内容:

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

(3)开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试;重试达到最大次数后,Spring会返回reject,消息会被丢弃

8.2.1 失败处理策略

(1)Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

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

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

(2)在consumer服务中定义处理失败消息的交换机和队列,定义一个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){
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
}

8.3 业务幂等性

(1)幂等 是一个数学概念,用函数表达式来描述是这样的:f(x) = f(f(x)),例如求绝对值函数。

在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。

①幂等业务:

  • 根据id删除数据
  • 查询数据
  • 新增数据

②非幂等业务:

  • 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况
  • 退款业务。重复退款对商家而言会有经济损失。

8.3.1 解决非幂等性问题

(1)方案一:给每个消息都设置一个唯一id,利用id区分是否重复消息

①每一条消息都生成一个唯一的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;
}

(2)业务判断

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。例如我们当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以在执行业务时判断订单状态是否是未支付,如果不是则证明订单已经被处理过,无需重复处理。

相比较而言,消息ID的方案需要改造原有的数据库,所以我更推荐使用业务判断的方案。

8.4 兜底方案

(1)虽然我们利用各种机制尽可能增加了消息的可靠性,但也不好说能保证消息100%的可靠。万一真的MQ通知失败该怎么办呢?有没有其它兜底方案,能够确保订单的支付状态一致呢?

(2)其实思想很简单:既然MQ通知不一定发送到交易服务,那么交易服务就必须自己主动去查询支付状态。这样即便支付服务的MQ通知失败,我们依然能通过主动查询来保证订单状态的一致。

相关推荐
Beginner x_u3 小时前
JavaScript 核心知识索引(面试向)
开发语言·javascript·面试·八股
roman_日积跬步-终至千里3 小时前
【Java并发】Tomcat 与 Spring:后端项目中的线程与资源管理
java·spring·tomcat
独自破碎E3 小时前
IDEA 提示“未配置SpringBoot配置注解处理器“的解决方案
java·spring boot·intellij-idea
2601_949809593 小时前
flutter_for_openharmony家庭相册app实战+照片详情实现
android·java·flutter
4311媒体网3 小时前
Libvio.link 页面布局与数据分布
java·php
白日梦想家6813 小时前
JavaScript性能优化实战系列(三篇完整版)
开发语言·javascript·性能优化
_张一凡3 小时前
【AIGC面试面经第七期】旋转位置编码RoPE:从 2D 到 nD 的完美扩展之旅
人工智能·面试·aigc
请注意这个女生叫小美3 小时前
C语言 实例20 25
c语言·开发语言·算法
fundroid3 小时前
Kotlin 泛型进阶:in、out 与 reified 实战
android·开发语言·kotlin