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.恢复库存

1万+

被折叠的 条评论
为什么被折叠?



