SpringBoot调用RabbitMQ入门(超详细)

MQ

初识

同步调用

以支付为例,支付的主要功能就是用户扣款,另一个是修改支付状态。其余的比如修改订单状态、发送短信提醒、增加积分等等服务都是次要的。如果同步调用的化也就是说在支付这块,我们就需要执行扣款——修改支付状态——修改订单状态...... 那么这一系列操作就是同步调用的。

同步优点:

时效性强:等到结果后才返回,在需要返回结果的场景下,是必须使用同步的。

同步缺点:

拓展性差:每一次添加业务都需要在源代码上修改,不符合代码开闭原则:面向修改关闭,面向拓展开放。 性能下降:如果是用openfeign的话,那么在调用其他服务的时候,支付服务是在等待的。 级联失败:如果其中某个服务失败了,会导致整个支付服务失败回滚,从而占用大量资源。

异步调用

支付服务需要得到的只是扣款与修改订单的返回值,以此判断服务是否向下调用,其余的服务是否成功、返回值等支付服务并不关心。 所以这里就可以使用异步调用,基于消息通知的方式。一般包含三个角色:

消息的发送者: 投递消息的人,就是原来的调用方。此时不再直接调用服务,而是发送一条消息给消息代理。

消息的代理者: 管理、暂存、转发消息,可以理解为微信服务器。它最终会将消息转发给消息接收者。

消息的接收者: 接收和处理消息的人,就是原来的服务提供方

那么以上的同步调用就可以优化为以下流程:

用户服务扣减余额(支付服务调用)——支付服务更新支付状态——发送消息通知给消息代理

那么这里的支付服务就结束了,接下来的更新订单状态......自己去消息代理去获取消息,执行对应操作。 这里的消息不只是说订单完成,其中会包含订单号等消息,那么其余服务就可以通过这个信息去完成服务。

异步优点:

解除耦合,拓展性强:可以通过消息队列处理剩余信息,每个拓展出来的业务只需从消息代理获取消息即可

无需等待,性能好:主服务只需要执行完后将消息通知至消息代理,剩余的操作由消息代理去通知其他服务

故障隔离:如果某服务出现故障,只要是消息代理发送的,就可以重启服务,然后继续从消息代理中获取消息处理

缓存消息,流量削峰填谷:可以将大量消息存入消息代理,不急的事务可以慢慢处理

异步缺点:

不能立即得到调用结果,时效性差

不确定下游业务执行是否成功

业务安全依赖于消息代理(Broker)的可靠性

技术选型

消息代理(Broker)的常见实现就是MQ,消息队列。

端口介绍

我们这里选择RabbitMQ进行学习。 15672提供的是一个控制台端口,图形化界面。 5672是消息通信的端口,进行发送和接收消息。

基本介绍

RabbitMQ的整体架构及核心概念: publisher: 消息发送者 consumer: 消息消费者 queue: 队列,存储消息 exchange: 交换机,负责路由消息

而RabbitMQ的作用就是中间这一块,是一个消息代理

而RabbitMQ内也是与MySQL类似,是存在数据隔离的,类似一个数据库中有多个database,在RabbitMQ中叫做virtual host(虚拟主机)。

网页MQ使用

安装后,我们可以访问其ip:15672去查看控制台。

Virtual host(虚拟主机)

默认情况下的ALL是展示所有的虚拟主机。 在初始情况下会有一个虚拟主机叫"/"。

Overview(总览)
Node

结点信息,如果是集群的话可以看到很多的结点信息。 可以看到结点的内存、磁盘、端口等信息。

Connection(连接)

消息的发送者和接收者都需要与MQ进行连接。连接之后,就会在这里展示出来,MQ与谁连接了。

Channels(频道)

要与MQ实现通讯,需要一个频道,在这个频道内去接收与发送消息。

Exchanges(交换机)

交换机接收到消息后,会将消息转发给队列,负责路由,本身没有存储消息的作用。要实现消息的转发就需要给交换机加上对应的关系,使其可以将消息发送到对应的队列。

可以在交换机中点击一个交换机名字,进入交换机。 在交换机内部可以发送消息,实现在控制台无代码测试。

Overview(总览)

总览中是对当前交换机的一个数据统计,可以统计发送过来的数据量以及发送出去的数据量。

publish message(控制台发送消息)

这个就是发送消息。 其中的payload就是负载,也就是发送的消息的内容。

Bindings(绑定队列)

可以添加一个绑定到当前交换机。 前面的To queue是可以绑定一个队列,点击Bind后就实现了绑定。 同样的,它也可以绑定一个交换机。

在绑定之后可以在对应的队列或者交换机中查看他们的Bind,这也是一一对应的,ABind了B,那么B也就Bind了A。

Fanout交换机(广播)

交换机会将接收到的消息广播到每一个与其绑定的queue,所以也叫广播模式。

Direct交换机(定向)

有时候我们并不想把消息发送给所有队列,而是有选择性地发送给某些队列,实现消息的按需投放。 比如支付成功了,那么久将消息发送给所有的子服务。但是如果支付失败了,那么消息就只发送给订单服务,改变订单状态为取消或者是支付失败。 相当于是将支付服务的结果带上了,结果交换机的时候判断是否要发送消息给队列,这里的判断就是由routingkey实现的。

Direct Exchange会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由。 ● 每一个Queue都与Exchange设置一个BindingKey ● 发布者发送消息时,指定消息的RoutingKey ● Exchange:将消息路由到BindingKey与消息RoutingKey一致的队列

在绑定队列时,RoutingKey就是BindingKey,可以认为是在挂一个标签。等对应的RoutingKey到来时,根据标签发送消息。 默认交换机就是Direct交换机,其绑定了每一个队列,对应的BindingKey就是队列名。 而一个Direct交换机可以同时绑定多个BindingKey,只是一次绑定操作只能绑定一个对应的BindingKey,所有就需要多绑定几次。 因此在给队列发送消息的时候不需要指定交换机,默认的就是默认交换器。 如果发送的交换器不存在,也会发送到默认交换器中。

Topic交换机(话题)

与Direct交换机类似,可以将消息转发至特定的队列,其区别是Topic交换机的RoutingKey可以是多个单词的列表,以.分割。 比如发送消息时的RoutingKey china.news china.weather japan.news japan.weather

而交换机对应绑定的RoutingKey是可以使用通配符的。 #代指0个或多个单词 *代指一个单词

那么在绑定时就可以是 #.news 代表以news结尾的 china.* 代表以china开头,且长度为2的 等等,这些都是有效的BindingKey

那么对应的什么.news就会发送到#.news

Queues(队列)

接收发来的消息,储存消息,供给消费者

Add a new queue(添加队列)

图形化界面中,点击Add a new queue,可以添加新的队列 在这个里面可以选择队列的类型,名字等,我们只需要修改名字就可以直接生成一个队列。

Get messages(获取消息)

点击按钮之后就可以查看对应条数的消息,但是消息还是保留着的。

Purge(清理队列所有消息)

WorkQueue

假设一共有50条数据,那么就是A和B一起一共消费了50条,而不是各消费50条。

此时如果有多个性能相同的监听器,同时监听一个队列,那么就相当于轮询。 也就是A消费一条B消费一条。

如果性能不同,他依旧还是轮询的,A消费25条,结束后A不再处理。B消费25条。哪怕A消费完了,B还在处理消息,A也不会去帮B处理消息。 他们之间更像是各自有一个队列,消息队列平均把消息分配到队列后,各自处理各自的。

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

prefetch叫预处理,也就是先获取消息,再去处理消息。 默认情况下是可以一直预获取,然后再自己一一处理,这种是不合理的,应该是对于资源多的,处理能力强的多处理一些数据,从而增快数据处理的速度。

因此就可以通过这个预处理为1的方式去改变消息的分配。这种方法可以通过增加到多个消费者从而加快消息处理速度,降低消息堆积的可能。

Admin(管理)

管理页面能管理以下东西:

用户
All users

这里可以查看所有的用户

Add a user

创建新用户 Username为用户名 接下来的两行为密码与确认密码 Tag就是标签,会对应不同的权限,点击?后可以查看各个标签对应的权限

创建后是没有可访问的虚拟主机的。 也就是说,此刻用新创建的账号是无法操作队列等的东西的。 能查看是因为用户权限可以看到对应的虚拟主机,可以在右上角切换虚拟主机。

虚拟主机

在显示的所有的虚拟主机中,总览框内的User是显示该虚拟主机的所属用户。

All virtual host

可以看到所有的虚拟主机

Add a new virtual host

添加一个虚拟主机,Name是任意的,可以自己编,但是不能重复。 一般我们是/newName。 Description是描述,可以随便加,类似备注。 Tags可以不填。

创建虚拟主机后会自动创建对应的交换机等。前面的Virtual host就是该交换机对应的虚拟主机。 这些交换机的名字是完全相同的。 创建的虚拟主机默认可操作用户就是创建时的用户。

SpringAMQP介绍

Advanced Message Queuing Protocol(AMQP)高级消息队列协议,是用于在应用程序之间传递业务消息的开放标准。该协议与语言和平台无关,更符合微服务中独立性的要求。

Spring AMQP是基于AMQP协议定义的一套API规范,提供了模板来发送和接收消息。包含两部分,其中spring-amqp是基础抽象,spring-rabbit是底层的默认实现。

SpringAmqp的官方地址:https:/spring.io/projects/spring-amqp

项目载入

依赖

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-amqp</artifactId>
</dependency>

配置

spring:
    rabbitmq:
      host: localhost
      port: 5672
      virtual-host: /HL
      username: HL
      password: 123

SpringAMQP使用

SpringAMQP提供了RabbitTemplate工具类

与队列的通讯

可以通过这个类来实现接收与发送消息。

public void test() {
        rabbitTemplate.convertAndSend("simple.queue", "hello rabbitMQ");
       
        byte[] msg = rabbitTemplate.receive("simple.queue").getBody();
}

发送消息有两个参数,第一个是要发送到的队列名,第二个是要发送的消息。这里也可以是User等等,会对这个对象进行序列化。 接收消息可以使用RabbitTemplate工具类也可以使用注解,生成一个监听器去接收消息。

生成的监听器会在运行期间持续地消费消息队列里的消息,消息被消费后就会消失,适合异步操作。

给交换机发送消息

其中routingKey是路由键,当没有交换机信息时,路由键就是指代队列名,这样就可以直接给队列发送消息了。 如果要给交换机发送消息,则在前面再加入一个元素,填写交换机名,就可以实现交换机广播。

public void test() {
    rabbitTemplate.convertAndSend("my.fanout", "china.weather", "hello rabbitMQ");
}

对于第二个元素路由键,广播的交换机是不会看这个值的,它一般更多使用于其他的交换机类。 对于Direct交换机,这里的RoutingKey就会作为其发送消息的依旧,在绑定时注册的RoutingKey要是与rabbitTemplate发送的RoutingKey相同,那么交换机就会将消息发送给此队列。

声明队列与交换机

方法一:Config类中声明

SpringAMQP提供了几个类,可以在java代码中声明队列、交换机及其绑定关系:

· Queue: 用于声明队列,可以用工厂类QueueBuilder构建 · Exchange: 用于声明交换机,可以用工厂类ExchangeBuilder构建 · Binding: 用于声明队列和交换机的绑定关系,可以用工厂类BindingBuilder构建

由于队列只有一种类型,而交换机由多种类型,所以交换机实际上是一个接口,由多个类实现,因此在新建交换机的时候可以指定具体的某一个交换机去new。

以FanoutExchange为例

@Configuration
public class FanoutConfig {
​
    //声明FanoutExchange交换机
    @Bean
    public FanoutExchange  fanoutExchange() {
        //return ExchangeBuilder.fanoutExchange("my.fanout").build();
        //return ExchangeBuilder.directExchange("my.direct").build();
        //return ExchangeBuilder.topicExchange("my.topic").build();
        //return ExchangeBuilder.headersExchange("my.headers").build();
        return new FanoutExchange("my.fanout");
    }
​
    //声明第一个队列
    @Bean
    public Queue fanoutQueue1() {
        //return QueueBuilder.durable("queueu").build();
        //return QueueBuilder.nonDurable("queue").build();
        return new Queue("fanout.queue1");
    }
    
    //绑定队列1和交换机
    @Bean
    public Binding  fanoutBinding1(FanoutExchange fanoutExchange, Queue fanoutQueue1) {
        return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
    }
    //.....
}

一般我们会将这种配置代码写在消费者中,因为一般生产者只关系交换机,与交换机交互,所以队列及其绑定关系都是在消费者中声明的。

如果是Direct交换机绑定的话,需要带上BindingKey,代码如下

@Bean
public Binding  directBinding1(DirectExchange directExchange, Queue fanoutQueue1) {
    return BindingBuilder.bind(fanoutQueue1).to(directExchange).with("red");
}

方法二:注解声明
@RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "simple.queue", durable = "true"),
        exchange = @Exchange(name = "simple.exchange", type = ExchangeTypes.DIRECT),
        key = {"red", "blue"}
))
public void receive_1(String message) {
    log.error("receive_1: {}", message);
}

可以直接在监听器上用嵌套注解的形式实现声明并且监听。

嵌套注解如果记不住可以用Ctrl+P进行提示。

消息转换器

如果直接将对象发送到消息队列中,会用Java自带的序列化工具,将对象的值转为一堆,增大了数据量,占用了消息队列空间,所以我们这里就需要改写使用其他的消息转换器。

java自带的序列化工具会把对象一个一个转成字节,不利于存储、不好读取且有安全漏洞。 我们可以采用JSON序列化替代默认的JDK序列化。

所以可以导入jackson依赖,然后再在代码中用@Bean去注入,这样就会调用对应的Bean。

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
</dependency>
@Bean
public MessageConverter jacksonMessageConverter() {
    return new Jackson2JsonMessageConverter();
}

这样就实现了序列化工具的切换。

消息可靠性

从发送消息到消息消费失败,有以下三种可能:

1.发送者在向消息代理发送消息时,发送失败 2.消息代理宕机,消息丢失 3.消息代理把消息发出去了,但是消费者抛出异常

所以这里要解决的是三种可靠性问题:

1.发送者的可靠性 2.MQ的可靠性 3.消费者的可靠性

发送者可靠性

主要有两种解决方式,生产者重连与生产者确认

生产者重连

有的时候由于网络波动,可能会出现客户端连接MQ失败的情况。通过配置我们可以开启连接失败后的重连机制:

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

此处仅是连接失败的重试,如果连接成功但是发送失败是不会重试的。 这是一个阻塞式的重试,也就是多次重试这个过程中,当前线程是被阻塞的,会影响业务性能。

生产者确认

相比与生产者重连,它会更加侧重于是消息代理内部出现失败的情况。

RabbitMQ有Publisher Confirm和Publisher Return两种确认机制。开启确机制认后,在MQ成功收到消息后会返回确认消息给生产者。返回的结果有以下几种情况:

· 消息投递到了MQ,但是路由失败。此时会通过Publisher Return:返回路由异常原因,然后返回ACK,告知投递成功 · 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功 · 持久消息投递到了MQ,并且入队完成持久化,返回ACK,告知投递成功 · 其它情况都会返回NACK,告知投递失败

对于MQ的返回结果,它有两种方式接收,一种的同步等待,另一种的异步等待。 同步等待线程就会卡在那,一直等待MQ给返回的消息。 而异步等待则是发送消息后直接结束,对于MQ返回的回执消息,接收到后再二外进行处理。这种效率会更高一些。

rabbitmq:
  publisher-confirm-type: correlated    #开启publisher confirm机制,并设置comfirm类型
  publisher-returns: true     #开启publisher returns机制,这种只有在路由失败的时候会有作用,一般可以不用开启

以上为需要新增的配置,新增了这两条配置后,发送消息的速率会下降,因为会多出一步回调操作。

· 这里publisher-confirm-type有三种模式可选: · none:关闭confirm机制 · simple:同步阻塞等待MQ的回执消息 · correlated:MQ异步回调方式返回回执消息

异步等待在配置完之后需要编写回调函数,以处理MQ返回的回执信息。 SpringBoot2中每个RabbitTemplate只能配置一个Return Callback,而Confirm Callback则是在每一个消息发送时单独指定。 SpringBoot3中则是每个RabbitTemplate只能配置一个ReturnCallback与一个ComfirmCallback。

以下为回调函数示例:

Return CallBack(旧版):

@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.fo("消息发送失败,应答码{},原因{},交换机{},路由键{},消息{}",
            replyCode,replyText,exchange,routingKey,message.toString());
        });
    }
}

Return Callback(新版):

@Slf4j
@Configuration
public class MqConfirmConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
​
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returnedMessage) {
                log.info("消息发送失败, Message:{}, Exchange:{}, ReplyCode:{}, ReplyTest:{}, RoutingKey:{}",
                        returnedMessage.getMessage(), returnedMessage.getExchange(), returnedMessage.getReplyCode(),
                        returnedMessage.getReplyText(), returnedMessage.getRoutingKey());
            }
        });
    }
}

Confirm Callback(SpringBoot2):

@Test
void testPublisherConfirm()throws InterruptedException{
    //1.创建℃orrelationData
    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("handle message ack 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","red1","hello",cd);
}

Confirm Callback(SpringBoot3): SpringBoot3版本的比较特殊

@Slf4j
@Configuration
public class MqConfirmConfig implements ApplicationContextAware {
    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
​
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returnedMessage) {
                log.info("消息发送失败, Message:{}, Exchange:{}, ReplyCode:{}, ReplyTest:{}, RoutingKey:{}",
                        returnedMessage.getMessage(), returnedMessage.getExchange(), returnedMessage.getReplyCode(),
                        returnedMessage.getReplyText(), returnedMessage.getRoutingKey());
            }
        });
​
        rabbitTemplate.setConfirmCallback((correlationData, ack, cause) -> {
            if(ack) {
                log.info("消息发送成功, MessageId:{}", correlationData.getId());
            } else {
                log.info("消息发送失败, MessageId:{}, Cause:{}", correlationData.getId(), cause);
            }
        });
    }
}
public void send(String queueName, String message) {
    CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
    rabbitTemplate.convertAndSend(queueName, (Object) message, cd);
}

具体的功能中,CorrelationData的功能是一样的,都是给消息一个ID,在回调时执行,去接收对应消息的执行结果。

SpringBoot3相当于将cd的callback转移到rabbitTemplate中。

MQ可靠性

在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。这样会导致两个问题: · 一旦MQ宕机,内存中的消息会丢失 · 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞

如果内存满了,那么MQ就会自己清除一些老的(可能是还未消费的)消息,从而导致数据的丢失,这个过程叫Page Out。 消息量于Page Out的次数可以再Queues的Details中查看。 在Page Out的过程中,MQ会暂停接收消息,等PageOut过程结束后再重新开始接收消息。

在MQ3.6之前,我们采用数据持久化解决此类问题。 在3.6之后又推出了一个Lazy Queue,同样可以解决此问题。

数据持久化

数据持久化包括三个方面

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

队列和交换机在web控制台中,可以在添加的时候选择Durable来确定它为持久化的交换机或者队列。 在Java中用Durable声明的就是持久化的。

对于消息的持久化,在web控制台发送的化需要在properties中,delivery mode = 2 这样的消息可以在MQ重启后依旧存在,并且就不会出现大量的Page Out操作,导致MQ堵塞。

在SpringBoot中发送的消息默认都是持久化的,如果要得到非持久化的,就可以手动创建一个对象,Message,再将这个对象作为消息传入。

void testPageOut(){
    Message message = MessageBuilder
        .withBody("hello".getBytes(StandardCharsets.uTF_8))
        .SetDeliveryMode(MessageDeliveryMode.NON_PERSISTENT).build();
    rabbitTemplate.convertAndSend("simple.queue",message);
}

当消息持久化后,内存中存储的消息在达到一定量后会被清理掉。这个过程不是PageOut操作,所以在接收消息时性能会下降一点,但是不会像Page Out一样直接掉到0

最终在硬盘中持久化数据1,000,000后,内存中存放的为119,400条。

Lazy Queue(3.12版本为默认,不可更改)

从RabbitMQ的3.6版本开始,就增加了Lazy Queue的概念,也就是惰性队列。

惰性队列的特征如下: · 接收到消息后直接存入磁盘而非内存(内存中只保留最近的消息,默认2048条) · 消费者要消费消息时才会从磁盘中读取并加载到内存 · 支持数百万条的消息存储 在3.12版本后,所有队列都是Lazy Queue模式,无法更改。

在3.12版本以下,可以在创建的时候手动选择队列类型为Lazy mode。 Java代码实现时可以在QueueBuilder中.lazy(),开启Lazy模式。 如果是基于注解的,可以在@Queue中加入arguments = @Argument(name = "x-queue-mode", value = "lazy") 类似key-value的指定,设定额外的配置。

对于Lazy Queue的,会直接将数据写入磁盘,不经过内存,无论消息是否是持久化的。 在开启了消息持久化与生产者确认后,RabbitMQ只有在消息持久化完成后才会给生产者返回ACK回执

消费者可靠性

分为消费者确认机制、消费失败处理、业务幂等性

消费者确认机制

为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制(Consumer Acknowledgement)。当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态。回执有三种可选值: ack:成功处理消息,RabbitMQ从队列中删除该消息 nack:消息处理失败,RabbitMQ需要再次投递消息 reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息

SpringAMQP已经实现了消息确认功能。并允许我们通过配置文件选择ACK处理方式,有三种方式: ●none(默认):不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用 ●manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活 ●auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果: ◆如果是业务异常,会自动返回nack ◆如果是消息处理或校验异常,自动返回reject

如果在处理消息时宕机了,那么消息就会回到MQ中,然后再接着发送。

rabbitmq:
  listener:
    simple:
      acknowledge-mode: auto

这里可以直接开启SpringAMQP的自动处理,开启后就能实现消费者确认机制。

消费失败处理

当消费者出现异常后,消息会不断requeue(重新入队)到队列,再重新发送给消费者,然后再次异常,再次requeue无限循环,导致q的消息处理飙升,带来不必要的压力。 我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列:

listener:
  simple:
    retry:
      enabled: true   #开启消费者失败重试
      initial-interval: 1000ms    #初始的等待时长为1秒
      multiplier: 1     #下次失败的等待时长倍数
      max-attempts: 3   #最大重试次数
      stateless: true   #ture无状态;false有状态。如果业务中包含事务则改为false,会进行一些上下文保存等操作

失败消息处理策略

在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer:接口来处理,它包含三种不同 的实现: ● RejectAndDontRequeueRecoverer: 重试耗尽后,直接reject,丢弃消息。默认就是这种方式 ● ImmediateRequeueMessageRecoverer: 重试耗尽后,返回nack,消息重新入队 ● RepublishMessageRecoverer: 重试耗尽后,将失败消息投递到指定的交换机

以RepublishMessageRecoverer为例, 将失败处理策略改为RepublishMessageRecoverer: ① 首先,定义接收失败消息的交换机、队列及其绑定关系 ② 然后,定义RepublishMessageRecoverer

@Configuration
@ConditionalOnProperty(prefix = "spring.rabbitmq.listener.simple.retry", name = "enabled", havingValue = "true")
public class ErrorConfiguration {
​
    @Bean
    public DirectExchange errorExchange() {
        return new DirectExchange("error.direct");
    }
​
    @Bean
    public Queue errorQueue() {
        return new Queue("error.queue");
    }
​
    @Bean
    public Binding errorBinding(DirectExchange errorExchange, Queue errorQueue) {
        return BindingBuilder.bind(errorQueue).to(errorExchange).with("error");
    }
​
    @Bean
    public MessageRecoverer repulishMessageRecoverer(RabbitTemplate rabbitTemplate) {
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
    }
}

@ConditionalOnProperty(prefix = "spring.rabbitmq.listener.simple.retry", name = "enabled", havingValue = "true") 这个注解是说明,在此属性为true的时候,下面的配置才生效。

开启后,MQ会在重试失败后讲报错异常存入指定交换机带上指定routingkey。 此处是重试失败后,将会给error.direct交换机发送消息,routingkey为error。

业务幂等性

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

唯一消息id 方案一,是给每个消息都设置一个唯一id,利用id区分是否是重复消息: ① 每一条消息都生成一个唯一的id,与消息一起投递给消费者 ② 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库 ③ 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理

@Bean
public MessageConverter jacksonMessageConverter() {
    Jackson2JsonMessageConverter converter = new Jackson2JsonMessageConverter();
    converter.setCreateMessageIds(true);
    return converter;
}
//可以在发送者发送消息时,带上消息ID

然后在业务接收消息时去查询。

缺点:业务侵入,还需要在查询时去访问数据库,一定程度降低了效率

业务判断

方案二,是结合业务逻辑,基于业务本身做判断。以我们的业务为例:我们要在支付后修改订单状态为已支付,应该在 修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才需要修改,其它状态不做处理

此处可以引入乐观锁机制,比如修改时,update的where条件判断加入AND status = 1的情况,这样也能减少在update前的select操作。

额外的兜底方案

如果交易服务消息处理失败,有没有什么兜底方案? 我们可以在交易服务设置定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。

延迟消息

延迟消息:生产者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息 延迟任务:设置在一定时间之后才执行的任务

比如一个订单消息,在下单后需要半个小时内付款,否则当作交易失败处理。那么交易失败就是一个延迟消息。

实现方案一(死信交换机):

当一个队列中的消息满足下列情况之一时,就会成为死信(dead letter): 消费者使用basic..reject:或basic.nack声明消费失败,并且消息的requeue参数设置为false 消息是一个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费 要投递的队列消息堆积满了,最早的消息可能成为死信

如果队列通过dead-letter--exchange)属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)。

通过以上这种机制就可以实现延迟消息。 simple.queue不绑定消费者,那么消息就会堆积在队列中,等到对于的时间到了之后,过期了,就会进入死信交换机,再去发送给对应的消费者,实现延迟消息。

这里除了simple.queue的创建外,其他都是一样的创建、绑定。 simple.queue的属性则需要选择Dead letter exchange后,等于号后面填写对应的死信交换机。

创建完成后需要的就是带有过期时间的消息了,在控制台中发送消息要带上这个属性的话,它的Headers内应该有expiration,这个就是过期时间。 java代码发送如下

@Test
public void test() {
    Message message = MessageBuilder.withBody("hello".getBytes(StandardCharsets.UTF_8))
            .setExpiration("30000")
            .build();
    rabbitTemplate.send("simple.queue",  message);
​
}

这里的setExpiration就是设置过期时间,以毫秒为单位。这种写法是不使用其他序列化的情况下的,如果用了JSON序列化,则不能这么写。

@Test
public void test() {
    rabbitTemplate.convertAndSend("simple.direct", "hi", "hello", new MessagePostProcessor() {
        @Override
        public Message postProcessMessage(Message message) throws AmqpException {
            message.getMessageProperties().setExpiration("30000");
            return message;
        }
    });
}

如果选择了其他的序列化工具,则应该是这样的写法。

一般死信交换机是用于处理错误消息,作为一种兜底方案的。

实现方案二(延迟消息插件):

RabbitMQ的官方也推出了一个插件,原生支持延迟消总功能。该插件的原理是设计了一种支持延迟消息功能的交换机,当消息投递到交换机后可以暂存一定时间,到期后再投递到队列。

@RabbitListener(bindings @QueueBinding(
    value = @Queue(name="delay.queue",durable="true"),
    exchange = @Exchange(name="delay.direct",delayed="true"),
    key="delay" 
)
public void listenDelayMessage(String msg){
log.info("接收到delay.queue的延迟消息:{}",msg);

用Bean的方式则是

@Bean
public DirectExchange delayExchange(){
    return ExchangeBuilder
        .directExchange("delay.direct")
        .delayed()  //设置delay的属性为true
        .durable(true)//特久化
        .build();
}

发送延迟消息:

@Test
void testPublisherDelayMessage(){
    //1.创建消息
    String message = "hello,delayed message";
    //2,发送消息,利用消息后置处理器添加消息头
    rabbitTemplate.convertAndSend("delay.direct","delay",message,new MessagePostProcessor(){
        @Override
        public Message postProcessMessage(Message message)throws AmqpException{
            //添加延迟消息属性
            message.getMessageProperties().setDelay(5000);
            return message;
        }
    });
}

这样就能实现延迟消息。 这种方法相对以上的死信交换机会更好一点,但是也是会有性能消耗,只要是除了Redis以外的延时都会有损耗。 每一个延迟都会创建一个秒表占用cpu计时,知道计时完成,消息发送出去。这段时间是一直在占用cpu的。

因此MQ的延时消息并不适用长时的消息延迟,以及高并发的情况。

延迟消息优化

在以上的延迟消息插件中,如果我们要延时30分钟的,那么时间相对较长,可能有80%的操作会在1分钟后检查便可执行了,但是要在MQ中等待30Min,浪费了资源。 那么我们就可以把一段较长的时间如30min拆成多个相对较短的时间。比如拆成10s后再隔10s再隔20s再隔1min等等... 同时也可以在此判断时,去查看是否已支付但是消息队列传递消息失败导致订单状态未更改,降低出错率。

具体的java代码

List<Integer> list = new ArrayList<>(Arrays.asList(10000, 20000, 30000, 40000, 50000));
rabbitTemplate.convertAndSend("delay.exchange", "hi", list, new MessagePostProcessor() {
    @Override
    public Message postProcessMessage(Message message) throws AmqpException {
        if(!list.isEmpty()) {
            message.getMessageProperties().setDelay(list.remove(0));
        }
        return message;
    }
});

这里是将list作为消息,传入消息队列。 每次发送消息前都从list中去前一个,作为此处延时的时间。延时结束后,进行检查,若不符合要求,且其中的消息存放的list还有数据,那么就接着remove 0 再去存入消息队列。直到list 为空,那么就彻底消费掉该消息,并执行对应操作。

接下来就是监听器去监听消息

@RabbitListener

//1.查询订单状态
Order order = orderService.getById(msg·getData());
//2.判断是否已经支付
if(order == null ||order.getStatus() == 2){
//订单不存在或者已经被处理
    return;
}
​
//TODO3.去支付服务查询真正的支付状态
boolean isPay = false;
//3.1.已支付,标记订单状态为已支付
if(isPay){
    orderService.markorderPaySuccess(order.getId());
    return;
}
//4.判断是否存在延迟时间
if(msg.hasNextDelay()){
    //4.1.存在,重发延迟消息
    Long nextDelay = msg.removeNextDelay();
    rabbitTemptate.convertAndSend(
        MqConStantS.DELAY_EXCHANGE,MqConStantS.DELAY_ORDER_ROUTING_KEY,
        msg,newDelayMessageProcessor(nextDelay.intValue()));
    return;
}
//5.不存在,取消订单
   orderservice.LambdaUpdate()
   .set(order:getstatus,5)
   .set(order:getcloseTime,LocalDateTime.now())
   .eq(order:getId,order.getId())
   .update();
//6.恢复库存

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值