微服务—RabbitMQ高级(业务在各方面的可靠性)

本博客为个人学习笔记,学习网站:2023黑马程序员RabbitMQ入门到实战教程 高级篇章节

目录

生产者可靠性

生产者重连机制

生产者确认机制

介绍

实现

总结与建议

MQ可靠性

数据持久化

LazyQueue

消费者可靠性

消费者确认机制

失败重试机制

失败重试策略

业务幂等性

何为幂等

唯一id

业务判断

兜底方案

总结


生产者可靠性

生产者重连机制

**注意:**生产者重连机制只有在连接MQ失败的时候进行重连,消息发送失败时并不会重连

配置代码如下:

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

测试:

利用docker命令停掉RabbitMQ服务,再进行消息发送测试。结果如下图所示,总共重试了3次,证明消息发送的超时重试机制配置成功。


生产者确认机制

介绍

其中ack和nack属于Publisher Confirm机制,ack是投递成功;nack是投递失败。而return(消息到达交换机,但是路由失败,如:匹配不到routingKey)则属于Publisher Return机制。

其中,Publisher Confirm又分为两种类型,一种是同步阻塞等待MQ的回执,另一种是MQ异步回调返回回执,即同步与异步两种。

默认两种机制都是关闭状态,需要通过配置文件来开启。


实现

实现生产者确认机制:

步骤1: 在publisher模块的application.yaml中添加配置

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

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

none:关闭confirm机制

simple:同步阻塞等待MQ的回执

correlated:MQ异步回调返回回执

一般我们推荐使用correlated,回调机制。

步骤2:编写回调函数,定义ReturnCallback

每个RabbitTemplate只能配置一个ReturnCallback,因此我们可以在配置类中统一设置。我们在publisher模块定义一个配置类:

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

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;

@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init(){
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.error("触发return callback,");
                log.debug("exchange: {}", returned.getExchange());
                log.debug("routingKey: {}", returned.getRoutingKey());
                log.debug("message: {}", returned.getMessage());
                log.debug("replyCode: {}", returned.getReplyCode());
                log.debug("replyText: {}", returned.getReplyText());
            }
        });
    }
}

步骤三:定义ConfirmCallback

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

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

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

SettableListenableFuture:回执结果的Future对象

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

我们新建一个测试,向系统自带的交换机发送消息,并且添加ConfirmCallback:

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);
}

成功接收消息结果:

把routingKey故意写错,路由失败结果:

可以看到,由于传递的RoutingKey是错误的,路由失败后,触发了return callback,同时也收到了ack。 当我们修改为正确的RoutingKey以后,就不会触发return callback了,只收到ack。 而如果连交换机都是错误的,则只会收到nack。

交换机名填错结果:


总结与建议

总结

开启生产者确认比较消耗MQ性能,一般不建议开启

而且大家思考一下触发确认的几种情况:

  1. 路由失败:一般是因为RoutingKey错误导致,往往是编程导致

  2. 交换机名称错误:同样是编程错误导致

  3. MQ内部故障:这种需要处理,但概率往往较低。因此只有对消息可靠性要求非常高的业务才需要开启,而且仅仅需要开启ConfirmCallback处理nack就可以了

建议

  1. 生产者确认需要额外的网络和系统资源开销,尽量不要使用

  2. 如果一定要使用,无需开启Publisher-Return机制,因为一般路由失败是自己业务问题

  3. 对于nack消息可以有限次数重试,依然失败则记录异常消息


MQ可靠性

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

数据持久化

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

spring在声明创建交换机和队列的时候,会默认设置为持久化模式,在发送消息时,也会默认将消息持久化。

在MQ管理平台新增交换机或队列的时候可以选择持久化模式,如果在MQ选择发送一条持久化消息时,需要配置properties值为2。

注意

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

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


LazyQueue

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

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

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

  3. 消费者处理业务发生阻塞

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

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

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

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

  3. 支持数百万条的消息存储

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

下面介绍一下LazyQueue模式 的设置:

在MQ管理平台新增队列时,设置为LazQueue模式:

代码配置队列为LazQueue模式:

  1. 基于Bean声明队列的设置方式
java 复制代码
@Bean
public Queue lazyQueue(){
    return QueueBuilder
            .durable("lazy.queue")
            .lazy() // 开启Lazy模式
            .build();
}
  1. 基于注解声明队列的设置方式
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);
}
  1. 对于已经存在的队列,也可以配置为lazy模式,但是要通过设置policy实现。 可以基于命令行设置policy
java 复制代码
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues  

消费者可靠性

消费者确认机制

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

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

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

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

auto:自动模式。

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

如果是业务异常,会自动返回nack;

如果是消息处理或校验异常,自动返回reject;

返回Reject的常见异常有:

Starting with version 1.3.2, the default ErrorHandler is now a ConditionalRejectingErrorHandler that rejects (and does not requeue) messages that fail with an irrecoverable error. Specifically, it rejects messages that fail with the following errors:

  • o.s.amqp...MessageConversionException: Can be thrown when converting the incoming message payload using a MessageConverter.

  • o.s.messaging...MessageConversionException: Can be thrown by the conversion service if additional conversion is required when mapping to a @RabbitListener method.

  • o.s.messaging...MethodArgumentNotValidException: Can be thrown if validation (for example, @Valid) is used in the listener and the validation fails.

  • o.s.messaging...MethodArgumentTypeMismatchException: Can be thrown if the inbound message was converted to a type that is not correct for the target method. For example, the parameter is declared as Message<Foo> but Message<Bar> is received.

  • java.lang.NoSuchMethodException: Added in version 1.6.3.

  • java.lang.ClassCastException: Added in version 1.6.3.

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

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

测试

修改consumer服务的SpringRabbitListener类中的方法,模拟一个消息处理的异常:

java 复制代码
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException {
    log.info("spring 消费者接收到消息:【" + msg + "】");
    if (true) {
        throw new MessageConversionException("故意的");
        //throw new RuntimeException("故意的");
    }
    log.info("消息处理完成");
}

测试可以发现:当消息处理发生异常时,消息依然被RabbitMQ删除了。

我们把确认机制修改为auto,放行以后,由于抛出的是消息转换异常,因此Spring会自动返回reject,所以消息依然会被删除。

我们重新将抛出异常改为RuntimeException类型,然后再次发送消息测试, 由于抛出的是业务异常,所以Spring返回ack,最终消息恢复至Ready状态,并且没有被RabbitMQ删除。

总结:

当我们把配置改为auto时,

消息处理成功:

自动返回ack,并将消息删除。

消息处理失败:

如果是业务异常,会自动返回nack,并将消息重新投递到消费者;

如果是消息处理或校验异常,自动返回reject,并将消息删除。


失败重试机制

配置代码如下

bash 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        retry:
          enabled: true # 开启消费者失败重试
          initial-interval: 1000ms # 初识的失败等待时长为1秒
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
          max-attempts: 3 # 最大重试次数
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false

重启consumer服务,重复之前的测试。可以发现:

消费者在失败后消息没有重新回到MQ无限重新投递,而是在本地重试了3次,本地重试3次以后,抛出了AmqpRejectAndDontRequeueException异常。查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是reject。

结论:

开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试。

重试达到最大次数后,Spring会返回reject,消息会被丢弃 。


失败重试策略

在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。 因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

  1. RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式

  2. ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队

  3. RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

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

步骤: 在consumer服务配置类中定义处理失败消息的交换机和队列,并定义一个RepublishMessageRecoverer,关联队列和交换机

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

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
//设置当满足某个条件(失败重试策略被开启)时配置生效↓
@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");
    }
}

业务幂等性

何为幂等

幂等是一个数学概念,用函数表达式来描述是这样的:f(x) = f(f(x)),例如求绝对值函数。 在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。

例如:根据id删除数据和查询数据操作,执行一次或多次对业务状态的影响是一致的。

但数据的更新往往不是幂等的,如果重复执行可能造成不一样的后果。

比如:取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况。

退款业务,重复退款对商家而言会有经济损失。

所以,我们要尽可能避免业务被重复执行。然而在实际业务场景中,由于意外经常会出现业务被重复执行的情况,因此,我们必须想办法保证消息处理的幂等性。

这里给出两种方案:唯一消息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;
}

业务判断

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

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

以支付修改订单的业务为例,我们需要修改OrderServiceImpl中的markOrderPaySuccess方法:

java 复制代码
    @Override
    public void markOrderPaySuccess(Long orderId) {
        // 1.查询订单
        Order old = getById(orderId);
        // 2.判断订单状态
        if (old == null || old.getStatus() != 1) {
            // 订单不存在或者订单状态不是1,放弃处理
            return;
        }
        // 3.尝试更新订单
        Order order = new Order();
        order.setId(orderId);
        order.setStatus(2);
        order.setPayTime(LocalDateTime.now());
        updateById(order);
    }

上述代码逻辑上符合了幂等判断的需求,但是由于判断和更新是两步动作,因此在极小概率下可能存在线程安全问题。

我们可以合并上述操作为这样:

java 复制代码
@Override
public void markOrderPaySuccess(Long orderId) {
    // 代码等同于如下sql语句
    // UPDATE `order` SET status = ? , pay_time = ? WHERE id = ? AND status = 1
    lambdaUpdate()
            .set(Order::getStatus, 2)
            .set(Order::getPayTime, LocalDateTime.now())
            .eq(Order::getId, orderId)
            .eq(Order::getStatus, 1)
            .update();
}

我们在where条件中除了判断id以外,还加上了status必须为1的条件。如果条件不符(说明订单已支付),则SQL匹配不到数据,根本不会执行。


兜底方案

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

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

图中黄色线圈起来的部分就是MQ通知失败后的兜底处理方案,由交易服务自己主动去查询支付状态。

不过需要注意的是,交易服务并不知道用户会在什么时候支付,如果查询的时机不正确(比如查询的时候用户正在支付中),可能查询到的支付状态也不正确。 那么问题来了,我们到底该在什么时间主动查询支付状态呢?

这个时间是无法确定的,因此,通常我们采取的措施就是利用定时任务定期查询,例如每隔20秒就查询一次,并判断支付状态。如果发现订单已经支付,则立刻更新订单状态为已支付即可。


总结

综上,支付服务与交易服务之间的订单状态一致性是如何保证的?

首先,支付服务会正在用户支付成功以后利用MQ消息通知交易服务,完成订单状态同步。

其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递的可靠性

最后,我们还在交易服务设置了定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。

相关推荐
小扳2 小时前
微服务篇-深入了解 MinIO 文件服务器(你还在使用阿里云 0SS 对象存储图片服务?教你使用 MinIO 文件服务器:实现从部署到具体使用)
java·服务器·分布式·微服务·云原生·架构
DT辰白14 小时前
如何解决基于 Redis 的网关鉴权导致的 RESTful API 拦截问题?
后端·微服务·架构
老猿讲编程16 小时前
技术发展历程:从 CORBA 到微服务
微服务·云原生·架构
浩哲Zhe19 小时前
RabbitMQ
java·分布式·rabbitmq
Allen Bright20 小时前
RabbitMQ中的Topic模式
分布式·rabbitmq
time_silence21 小时前
微服务——服务通信与接口设计
微服务
Allen Bright1 天前
Spring Boot 整合 RabbitMQ:手动 ACK 与 QoS 配置详解
spring boot·rabbitmq·java-rabbitmq
一路狂飙的猪1 天前
RabbitMQ的工作模型
分布式·rabbitmq
Java程序之猿1 天前
微服务分布式(一、项目初始化)
分布式·微服务·架构