RabbitMQ高级使用

打印 上一主题 下一主题

主题 1895|帖子 1895|积分 5685

概述

在支付场景中,支付成功后利用RabbitMQ通知买卖业务服务,更新业务订单状态为已支付。但是各人思考一下,如果这里MQ通知失败,支付服务中支付流水表现支付成功,而买卖业务服务中的订单状态却表现未支付,数据出现了不同等。此时前端发送哀求查询支付状态时,肯定是查询买卖业务服务状态,会发现业务订单未支付,而用户本身知道已经支付成功,这就导致用户体验不同等。
因此,这里必须尽可能确保MQ消息的可靠性,即:消息应该至少被斲丧者处理1次
那么题目来了:

  • 该如何确保MQ消息的可靠性
  • 如果真的发送失败,有没有其它的兜底方案?
这些题目,在今天的学习中都会找到答案。
起首,一起分析一下消息丢失的可能性有哪些。
消息从发送者发送消息,到斲丧者处理消息,需要颠末的流程是这样的:

消息从生产者到斲丧者的每一步都可能导致消息丢失:

  • 发送消息时丢失:

    • 生产者发送消息时毗连MQ失败
    • 生产者发送消息到达MQ后未找到Exchange
    • 生产者发送消息到达MQ的Exchange后,未找到符合的Queue
    • 消息到达MQ后,处理消息的进程发生非常

  • MQ导致消息丢失:

    • 消息到达MQ,保存到队列后,尚未斲丧就突然宕机

  • 斲丧者处理消息时:

    • 消息接收后尚未处理突然宕机
    • 消息接收后处理过程中抛出非常

综上,我们要办理消息丢失题目,保证MQ的可靠性,就必须从3个方面入手:

  • 确保生产者一定把消息发送到MQ
  • 确保MQ不会将消息弄丢
  • 确保斲丧者一定要处理消息
发送者的可靠性

生产者重试机制

起首第一种情况,就是生产者发送消息时,出现了网络故障,导致与MQ的毗连停止。
为了办理这个题目,SpringAMQP提供的消息发送时的重试机制。即:当RabbitTemplate与MQ毗连超时后,多次重试。
修改publisher模块的application.yaml文件,添加下面的内容:
  1. spring:
  2.   rabbitmq:
  3.     connection-timeout: 1s # 设置MQ的连接超时时间
  4.     template:
  5.       retry:
  6.         enabled: true # 开启超时重试机制
  7.         initial-interval: 1000ms # 失败后的初始等待时间
  8.         multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
  9.         max-attempts: 3 # 最大重试次数
复制代码
我们利用命令停掉RabbitMQ服务:
  1. docker stop mq
复制代码
然后测试发送一条消息,会发现会每隔1秒重试1次,总共重试了3次。消息发送的超时重试机制设置成功了!
注意:当网络不稳定的时间,利用重试机制可以有效提高消息发送的成功率。不外SpringAMQP提供的重试机制是壅闭式的重试,也就是说多次重试等候的过程中,当前线程是被壅闭的。
如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理设置等候时长和重试次数,当然也可以考虑使用异步线程来实行发送消息的代码。
生产者确认机制

一般情况下,只要生产者与MQ之间的网路毗连顺畅,根本不会出现发送消息丢失的情况,因此大多数情况下我们无需考虑这种题目。
不外,在少数情况下,也会出现消息发送到MQ之后丢失的现象,比如:

  • MQ内部处理消息的进程发生了非常
  • 生产者发送消息到达MQ后未找到Exchange
  • 生产者发送消息到达MQ的Exchange后,未找到符合的Queue,因此无法路由
针对上述情况,RabbitMQ提供了生产者消息确认机制,包罗Publisher Confirm和Publisher Return两种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回差别的回执
具体如图所示:

总结如下:

  • 当消息投递到MQ,但是路由失败时,通过Publisher Return返回非常信息,同时返回ack的确认信息,代表投递成功
  • 暂时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
  • 其它情况都会返回NACK,告知投递失败
此中ack和nack属于Publisher Confirm机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。
默认两种机制都是关闭状态,需要通过设置文件来开启。
实现生产者确认

开启生产者确认

在publisher模块的application.yaml中添加设置:
  1. spring:
  2.   rabbitmq:
  3.     publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
  4.     publisher-returns: true # 开启publisher return机制
复制代码
这里publisher-confirm-type有三种模式可选:

  • none:关闭confirm机制
  • simple:同步壅闭等候MQ的回执
  • correlated:MQ异步回调返回回执
一般我们推荐使用correlated,回调机制。
界说ReturnCallback

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

内容如下:
  1. package com.itheima.publisher.config;
  2. import lombok.AllArgsConstructor;
  3. import lombok.extern.slf4j.Slf4j;
  4. import org.springframework.amqp.core.ReturnedMessage;
  5. import org.springframework.amqp.rabbit.core.RabbitTemplate;
  6. import org.springframework.context.annotation.Configuration;
  7. import javax.annotation.PostConstruct;
  8. @Slf4j
  9. @AllArgsConstructor
  10. @Configuration
  11. public class MqConfig {
  12.     private final RabbitTemplate rabbitTemplate;
  13.     @PostConstruct
  14.     public void init(){
  15.         rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
  16.             @Override
  17.             public void returnedMessage(ReturnedMessage returned) {
  18.                 log.error("触发return callback,");
  19.                 log.debug("exchange: {}", returned.getExchange());
  20.                 log.debug("routingKey: {}", returned.getRoutingKey());
  21.                 log.debug("message: {}", returned.getMessage());
  22.                 log.debug("replyCode: {}", returned.getReplyCode());
  23.                 log.debug("replyText: {}", returned.getReplyText());
  24.             }
  25.         });
  26.     }
  27. }
复制代码
界说ConfirmCallback

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

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

  • id:消息的唯一标示,MQ对差别的消息的回执以此做判断,制止肴杂
  • SettableListenableFuture:回执效果的Future对象
将来MQ的回执就会通过这个Future来返回,我们可以提前给CorrelationData中的Future添加回调函数来处理消息回执:

我们新建一个测试,向体系自带的互换机发送消息,并且添加ConfirmCallback:
  1. @Test
  2. void testPublisherConfirm() {
  3.     // 1.创建CorrelationData
  4.     CorrelationData cd = new CorrelationData();
  5.     // 2.给Future添加ConfirmCallback
  6.     cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
  7.         @Override
  8.         public void onFailure(Throwable ex) {
  9.             // 2.1.Future发生异常时的处理逻辑,基本不会触发
  10.             log.error("send message fail", ex);
  11.         }
  12.         @Override
  13.         public void onSuccess(CorrelationData.Confirm result) {
  14.             // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
  15.             if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
  16.                 log.debug("发送消息成功,收到 ack!");
  17.             }else{ // result.getReason(),String类型,返回nack时的异常描述
  18.                 log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
  19.             }
  20.         }
  21.     });
  22.     // 3.发送消息
  23.     rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
  24. }
复制代码
实行效果如下:

可以看到,由于传递的RoutingKey是错误的,路由失败后,触发了return callback,同时也收到了ack。
当我们修改为正确的RoutingKey以后,就不会触发return callback了,只收到ack。
而如果连互换机都是错误的,则只会收到nack。
注意
开启生产者确认比较斲丧MQ性能,一般不建议开启。而且各人思考一下触发确认的几种情况:

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

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

为了提升性能,默认情况下MQ的数据都是在内存存储的暂时数据,重启后就会消散。为了保证数据的可靠性,必须设置数据持久化,包罗:

  • 互换机持久化
  • 队列持久化
  • 消息持久化
我们以控制台界面为例来阐明。
互换机持久化

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

设置为Durable就是持久化模式,Transient就是暂时模式。
队列持久化

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

除了持久化以外,你可以看到队列还有很多其它参数,有一些我们会在后期学习。
消息持久化

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

阐明:在开启持久化机制以后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性。
不外出于性能考虑,为了减少IO次数,发送到MQ的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在100毫秒左右,这就会导致ACK有一定的延迟,因此建议生产者确认全部采用异步方式。
LazyQueue

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

  • 斲丧者宕机或出现网络故障
  • 消息发送量激增,超过了斲丧者处理速度
  • 斲丧者处理业务发生壅闭
一旦出现消息堆积题目,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会壅闭队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有哀求都会被壅闭。
为了办理这个题目,从RabbitMQ的3.6.0版本开始,就增长了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 斲丧者要斲丧消息时才会从磁盘中读取并加载到内存(也就是懒加载)
  • 支持数百万条的消息存储
而在3.12版本之后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本大概所有队列都设置为LazyQueue模式。
控制台设置Lazy模式

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

代码设置Lazy模式

在利用SpringAMQP声明队列的时间,添加x-queue-mod=lazy参数也可设置队列为Lazy模式:
  1. @Bean
  2. public Queue lazyQueue(){
  3.     return QueueBuilder
  4.             .durable("lazy.queue")
  5.             .lazy() // 开启Lazy模式
  6.             .build();
  7. }
复制代码
这里是通过QueueBuilder的lazy()函数设置Lazy模式,底层源码如下:

当然,我们也可以基于注解来声明队列并设置为Lazy模式:
  1. @RabbitListener(queuesToDeclare = @Queue(
  2.         name = "lazy.queue",
  3.         durable = "true",
  4.         arguments = @Argument(name = "x-queue-mode", value = "lazy")
  5. ))
  6. public void listenLazyQueue(String msg){
  7.     log.info("接收到 lazy.queue的消息:{}", msg);
  8. }
复制代码
更新已有队列为lazy模式

对于已经存在的队列,也可以设置为lazy模式,但是要通过设置policy实现。
可以基于命令行设置policy:
  1. rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues  
复制代码
命令解读:

  • rabbitmqctl :RabbitMQ的命令行工具
  • set_policy :添加一个计谋
  • Lazy :计谋名称,可以自界说
  • "^lazy-queue$" :用正则表达式匹配队列的名字
  • '{"queue-mode":"lazy"}' :设置队列模式为lazy模式
  • --apply-to queues:计谋的作用对象,是所有的队列
当然,也可以在控制台设置policy,进入在控制台的Admin页面,点击Policies,即可添加设置:

斲丧者的可靠性

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

  • 消息投递的过程中出现了网络故障
  • 斲丧者接收到消息后突然宕机
  • 斲丧者接收到消息后,因处理不当导致非常
  • ...
一旦发生上述情况,消息也会丢失。因此,RabbitMQ必须知道斲丧者的处理状态,一旦消息处理失败才华重新投递消息。
但题目来了:RabbitMQ如何得知斲丧者的处理状态呢?
本章我们就一起研究一下斲丧者处理消息时的可靠性办理方案。
斲丧者确认机制

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

  • ack:成功处理消息,RabbitMQ从队列中删除该消息
  • nack:消息处理失败,RabbitMQ需要再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
一般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 but Message is received.
  • java.lang.NoSuchMethodException: Added in version 1.6.3.
  • java.lang.ClassCastException: Added in version 1.6.3.
通过下面的设置可以修改SpringAMQP的ACK处理方式:
  1. spring:
  2.   rabbitmq:
  3.     listener:
  4.       simple:
  5.         acknowledge-mode: none # 不做处理
复制代码
修改consumer服务的SpringRabbitListener类中的方法,模拟一个消息处理的非常:
  1. @RabbitListener(queues = "simple.queue")
  2. public void listenSimpleQueueMessage(String msg) throws InterruptedException {
  3.     log.info("spring 消费者接收到消息:【" + msg + "】");
  4.     if (true) {
  5.         throw new MessageConversionException("故意的");
  6.     }
  7.     log.info("消息处理完成");
  8. }
复制代码
测试可以发现:当消息处理发生非常时,消息依然被RabbitMQ删除了。
我们再次把确认机制修改为auto:
  1. spring:
  2.   rabbitmq:
  3.     listener:
  4.       simple:
  5.         acknowledge-mode: auto # 自动ack
复制代码
在非常位置打断点,再次发送消息,程序卡在断点时,可以发现此时消息状态为unacked(未确定状态):

放行以后,由于抛出的是消息转换非常,因此Spring会自动返回reject,所以消息依然会被删除:

我们将非常改为RuntimeException类型:
  1. @RabbitListener(queues = "simple.queue")
  2. public void listenSimpleQueueMessage(String msg) throws InterruptedException {
  3.     log.info("spring 消费者接收到消息:【" + msg + "】");
  4.     if (true) {
  5.         throw new RuntimeException("故意的");
  6.     }
  7.     log.info("消息处理完成");
  8. }
复制代码
在非常位置打断点,然后再次发送消息测试,程序卡在断点时,可以发现此时消息状态为unacked(未确定状态):
放行以后,由于抛出的是业务非常,所以Spring返回ack,最终消息恢复至Ready状态,并且没有被RabbitMQ删除:

当我们把设置改为auto时,消息处理失败后,会回到RabbitMQ,并重新投递到斲丧者。
失败重试机制

当斲丧者出现非常后,消息会不绝requeue(重入队)到队列,再重新发送给斲丧者。如果斲丧者再次实行依然堕落,消息会再次requeue到队列,再次投递,直到消息处理成功为止。
极度情况就是斲丧者一直无法实行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力:

当然,上述极度情况发生的概率还是非常低的,不外不怕一万就怕万一。为了应对上述情况Spring又提供了斲丧者失败重试机制:在斲丧者出现非常时利用本地重试,而不是无限定的requeue到mq队列。
修改consumer服务的application.yml文件,添加内容:
  1. spring:
  2.   rabbitmq:
  3.     listener:
  4.       simple:
  5.         retry:
  6.           enabled: true # 开启消费者失败重试
  7.           initial-interval: 1000ms # 初识的失败等待时长为1秒
  8.           multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
  9.           max-attempts: 3 # 最大重试次数
  10.           stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
复制代码
重启consumer服务,重复之前的测试。可以发现:

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

  • 开启本地重试时,消息处理过程中抛出非常,不会requeue到队列,而是在斲丧者本地重试
  • 重试到达最大次数后,Spring会返回reject,消息会被丢弃
失败处理计谋

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

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的互换机
比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放非常消息的队列,后续由人工集中处理。
1)在consumer服务中界说处理失败消息的互换机和队列
  1. @Bean
  2. public DirectExchange errorMessageExchange(){
  3.     return new DirectExchange("error.direct");
  4. }
  5. @Bean
  6. public Queue errorQueue(){
  7.     return new Queue("error.queue", true);
  8. }
  9. @Bean
  10. public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
  11.     return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
  12. }
复制代码
2)界说一个RepublishMessageRecoverer,关联队列和互换机
  1. @Bean
  2. public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
  3.     return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
  4. }
复制代码
完备代码如下:
  1. package com.itheima.consumer.config;
  2. import org.springframework.amqp.core.Binding;
  3. import org.springframework.amqp.core.BindingBuilder;
  4. import org.springframework.amqp.core.DirectExchange;
  5. import org.springframework.amqp.core.Queue;
  6. import org.springframework.amqp.rabbit.core.RabbitTemplate;
  7. import org.springframework.amqp.rabbit.retry.MessageRecoverer;
  8. import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer;
  9. import org.springframework.context.annotation.Bean;
  10. @Configuration
  11. @ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")
  12. public class ErrorMessageConfig {
  13.     @Bean
  14.     public DirectExchange errorMessageExchange(){
  15.         return new DirectExchange("error.direct");
  16.     }
  17.     @Bean
  18.     public Queue errorQueue(){
  19.         return new Queue("error.queue", true);
  20.     }
  21.     @Bean
  22.     public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
  23.         return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
  24.     }
  25.     @Bean
  26.     public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
  27.         return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
  28.     }
  29. }
复制代码
业务幂等性

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

  • 根据id删除数据
  • 查询数据
  • 新增数据
但数据的更新往往不是幂等的,如果重复实行可能造成不一样的后果。比如:

  • 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增长的情况
  • 退款业务。重复退款对商家而言会有经济损失。
所以,我们要尽可能制止业务被重复实行。
然而在实际业务场景中,由于不测经常会出现业务被重复实行的情况,比方:

  • 页面卡顿时频繁革新导致表单重复提交
  • 服务间调用的重试
  • MQ消息的重复投递
我们在用户支付成功后会发送MQ消息到买卖业务服务,修改订单状态为已支付,就可能出现消息重复投递的情况。如果斲丧者不做判断,很有可能导致消息被斲丧多次,出现业务故障。
举例:

  • 如果用户刚刚支付完成,并且投递消息到买卖业务服务,买卖业务服务更改订单为已支付状态。
  • 由于某种缘故原由,比方网络故障导致生产者没有得到确认,隔了一段时间后重新投递给买卖业务服务。
  • 但是,在新投递的消息被斲丧之前,用户选择了退款,将订单状态改为了已退款状态。
  • 退款完成后,新投递的消息才被斲丧,那么订单状态会被再次改为已支付。业务非常。
因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:

  • 唯一消息ID
  • 业务状态判断
唯一消息ID

这个思路非常简单:

  • 每一条消息都生成一个唯一的id,与消息一起投递给斲丧者。
  • 斲丧者接收到消息后处理本身的业务,业务处理成功后将消息ID保存到数据库
  • 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
我们该如何给消息添加唯一ID呢?
其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。
以Jackson的消息转换器为例:
  1. @Bean
  2. public MessageConverter messageConverter(){
  3.     // 1.定义消息转换器
  4.     Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
  5.     // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
  6.     jjmc.setCreateMessageIds(true);
  7.     return jjmc;
  8. }
复制代码
业务判断

业务判断就是基于业务本身的逻辑或状态来判断是否是重复的哀求或消息,差别的业务场景判断的思路也不一样。
比方我们当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付。因此我们就可以在实行业务时判断订单状态是否是未支付,如果不是则证明订单已经被处理过,无需重复处理。
相比较而言,消息ID的方案需要改造原有的数据库,所以我更推荐使用业务判断的方案。
以支付修改订单的业务为例,我们需要修改OrderServiceImpl中的markOrderPaySuccess方法:
  1.     @Override
  2.     public void markOrderPaySuccess(Long orderId) {
  3.         // 1.查询订单
  4.         Order old = getById(orderId);
  5.         // 2.判断订单状态
  6.         if (old == null || old.getStatus() != 1) {
  7.             // 订单不存在或者订单状态不是1,放弃处理
  8.             return;
  9.         }
  10.         // 3.尝试更新订单
  11.         Order order = new Order();
  12.         order.setId(orderId);
  13.         order.setStatus(2);
  14.         order.setPayTime(LocalDateTime.now());
  15.         updateById(order);
  16.     }
复制代码
上述代码逻辑上符合了幂等判断的需求,但是由于判断和更新是两步动作,因此在极小概率下可能存在线程安全题目。
我们可以合并上述操纵为这样:
  1. @Override
  2. public void markOrderPaySuccess(Long orderId) {
  3.     // UPDATE `order` SET status = ? , pay_time = ? WHERE id = ? AND status = 1
  4.     lambdaUpdate()
  5.             .set(Order::getStatus, 2)
  6.             .set(Order::getPayTime, LocalDateTime.now())
  7.             .eq(Order::getId, orderId)
  8.             .eq(Order::getStatus, 1)
  9.             .update();
  10. }
复制代码
注意看,上述代码等同于这样的SQL语句:
  1. UPDATE `order` SET status = ? , pay_time = ? WHERE id = ? AND status = 1
复制代码
我们在where条件中除了判断id以外,还加上了status必须为1的条件。如果条件不符(阐明订单已支付),则SQL匹配不到数据,根本不会实行。
兜底方案

虽然我们利用各种机制尽可能增长了消息的可靠性,但也不好说能保证消息100%的可靠。万一真的MQ通知失败该怎么办呢?
有没有其它兜底方案,可以大概确保订单的支付状态同等呢?
其实头脑很简单:既然MQ通知不一定发送到买卖业务服务,那么买卖业务服务就必须本身自动去查询支付状态。这样即便支付服务的MQ通知失败,我们依然能通过自动查询来保证订单状态的同等。
流程如下:

图中黄色线圈起来的部门就是MQ通知失败后的兜底处理方案,由买卖业务服务本身自动去查询支付状态。
不外需要注意的是,买卖业务服务并不知道用户会在什么时间支付,如果查询的时机不正确(比如查询的时间用户正在支付中),可能查询到的支付状态也不正确。
那么题目来了,我们到底该在什么时间自动查询支付状态呢?
这个时间是无法确定的,因此,通常我们接纳的措施就是利用定时任务定期查询,比方每隔20秒就查询一次,并判断支付状态。如果发现订单已经支付,则立即更新订单状态为已支付即可。
定时任务各人之前学习过,具体的实现这里就不再赘述了。
至此,消息可靠性的题目已经办理了。
综上,支付服务与买卖业务服务之间的订单状态同等性是如何保证的?

  • 起首,支付服务会正在用户支付成功以后利用MQ消息通知买卖业务服务,完成订单状态同步。
  • 其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、斲丧者确认、斲丧者失败重试等计谋,确保消息投递的可靠性
  • 最后,我们还在买卖业务服务设置了定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终同等性。
延迟消息

在电商的支付业务中,对于一些库存有限的商品,为了更好的用户体验,通常都会在用户下单时立即扣减商品库存。比方影戏院购票、高铁购票,下单后就会锁定座位资源,其他人无法重复购买。
但是这样就存在一个题目,如果用户下单后一直不付款,就会一直占据库存资源,导致其他客户无法正常买卖业务,最终导致商户利益受损!
因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立即取消订单并释放占用的库存
比方,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟查抄订单支付状态,如果发现未支付,应该立即取消订单,释放库存。
但题目来了:如何才华正确的实现在下单后第30分钟去查抄支付状态呢?
像这种在一段时间以后才实行的任务,我们称之为延迟任务,而要实现延迟任务,最简单的方案就是利用MQ的延迟消息了。
在RabbitMQ中实现延迟消息也有两种方案:

  • 死信互换机+TTL
  • 延迟消息插件
这一章我们就一起研究下这两种方案的实现方式,以及优缺点。
死信互换机和延迟消息

起首我们来学习一下基于死信互换机的延迟消息方案。
死信互换机

什么是死信?
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 斲丧者使用basic.reject或 basic.nack声明斲丧失败,并且消息的requeue参数设置为false
  • 消息是一个过期消息,超时无人斲丧
  • 要投递的队列消息满了,无法投递
如果一个队列中的消息已经成为死信,并且这个队列通过**dead-letter-exchange**属性指定了一个互换机,那么队列中的死信就会投递到这个互换机中,而这个互换机就称为死信互换机(Dead Letter Exchange)。而此时加入有队列与死信互换机绑定,则最终死信就会被投递到这个队列中。
死信互换机有什么作用呢?

  • 收集那些因处理失败而被拒绝的消息
  • 收集那些因队列满了而被拒绝的消息
  • 收集因TTL(有效期)到期的消息
延迟消息

前面两种作用场景可以看做是把死信互换机当做一种消息处理的最终兜底方案,与斲丧者重试时讲的RepublishMessageRecoverer作用类似。
而最后一种场景,各人设想一下这样的场景:
如图,有一组绑定的互换机(ttl.fanout)和队列(ttl.queue)。但是ttl.queue没有斲丧者监听,而是设定了死信互换机hmall.direct,而队列direct.queue1则与死信互换机绑定,RoutingKey是blue:

如果我们现在发送一条消息到ttl.fanout,RoutingKey为blue,并设置消息的有效期为5000毫秒:

:::warning
注意:只管这里的ttl.fanout不需要RoutingKey,但是当消息变为死信并投递到死信互换机时,会沿用之前的RoutingKey,这样hmall.direct才华正确路由消息。
:::
消息肯定会被投递到ttl.queue之后,由于没有斲丧者,因此消息无人斲丧。5秒之后,消息的有效期到期,成为死信:

死信被再次投递到死信互换机hmall.direct,并沿用之前的RoutingKey,也就是blue:

由于direct.queue1与hmall.direct绑定的key是blue,因此最终消息被成功路由到direct.queue1,如果此时有斲丧者与direct.queue1绑定, 也就能成功斲丧消息了。但此时已经是5秒钟以后了:

也就是说,publisher发送了一条消息,但最终consumer在5秒后才收到消息。我们成功实现了延迟消息
总结

注意:
RabbitMQ的消息过期是基于追溯方式来实现的,也就是说当一个消息的TTL到期以后不一定会被移除或投递到死信互换机,而是在消息恰好处于队首时才会被处理。
当队列中消息堆积很多的时间,过期消息可能不会被按时处理,因此你设置的TTL时间不一定正确。
DelayExchange插件

基于死信队列虽然可以实现延迟消息,但是太麻烦了。因此RabbitMQ社区提供了一个延迟消息插件来实现相同的效果。
官方文档阐明:
Scheduling Messages with RabbitMQ | RabbitMQ - Blog
下载

插件下载所在:
GitHub - rabbitmq/rabbitmq-delayed-message-exchange: Delayed Messaging for RabbitMQ
由于我们安装的MQ是3.8版本,因此这里下载3.8.17版本:

安装

因为我们是基于Docker安装,所以需要先查看RabbitMQ的插件目次对应的数据卷。
  1. docker volume inspect mq-plugins
复制代码
效果如下:
  1. [
  2.     {
  3.         "CreatedAt": "2024-06-19T09:22:59+08:00",
  4.         "Driver": "local",
  5.         "Labels": null,
  6.         "Mountpoint": "/var/lib/docker/volumes/mq-plugins/_data",
  7.         "Name": "mq-plugins",
  8.         "Options": null,
  9.         "Scope": "local"
  10.     }
  11. ]
复制代码
插件目次被挂载到了/var/lib/docker/volumes/mq-plugins/_data这个目次,我们上传插件到该目次下。
接下来实行命令,安装插件:
  1. docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
复制代码
运行效果如下:

声明延迟互换机

基于注解方式:
  1. @RabbitListener(bindings = @QueueBinding(
  2.         value = @Queue(name = "delay.queue", durable = "true"),
  3.         exchange = @Exchange(name = "delay.direct", delayed = "true"),
  4.         key = "delay"
  5. ))
  6. public void listenDelayMessage(String msg){
  7.     log.info("接收到delay.queue的延迟消息:{}", msg);
  8. }
复制代码
基于@Bean的方式:
  1. package com.itheima.consumer.config;
  2. import lombok.extern.slf4j.Slf4j;
  3. import org.springframework.amqp.core.*;
  4. import org.springframework.context.annotation.Bean;
  5. import org.springframework.context.annotation.Configuration;
  6. @Slf4j
  7. @Configuration
  8. public class DelayExchangeConfig {
  9.     @Bean
  10.     public DirectExchange delayExchange(){
  11.         return ExchangeBuilder
  12.                 .directExchange("delay.direct") // 指定交换机类型和名称
  13.                 .delayed() // 设置delay的属性为true
  14.                 .durable(true) // 持久化
  15.                 .build();
  16.     }
  17.     @Bean
  18.     public Queue delayedQueue(){
  19.         return new Queue("delay.queue");
  20.     }
  21.    
  22.     @Bean
  23.     public Binding delayQueueBinding(){
  24.         return BindingBuilder.bind(delayedQueue()).to(delayExchange()).with("delay");
  25.     }
  26. }
复制代码
发送延迟消息

发送消息时,必须通过x-delay属性设定延迟时间:
  1. @Test
  2. void testPublisherDelayMessage() {
  3.     // 1.创建消息
  4.     String message = "hello, delayed message";
  5.     // 2.发送消息,利用消息后置处理器添加消息头
  6.     rabbitTemplate.convertAndSend("delay.direct", "delay", message, new MessagePostProcessor() {
  7.         @Override
  8.         public Message postProcessMessage(Message message) throws AmqpException {
  9.             // 添加延迟消息属性
  10.             message.getMessageProperties().setDelay(5000);
  11.             return message;
  12.         }
  13.     });
  14. }
复制代码
注意:
延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时。如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。
因此,不建议设置延迟时间过长的延迟消息
订单状态同步题目

接下来,我们就在买卖业务服务中利用延迟消息实现订单支付状态的同步。其大概思路如下:

如果订单超时支付时间为30分钟,理论上说我们应该在下单时发送一条延迟消息,延迟时间为30分钟。这样就可以在接收到消息时检验订单支付状态,关闭未支付订单。
但是大多数情况下用户支付都会在1分钟内完成,我们发送的消息却要在MQ中停留30分钟,额外斲丧了MQ的资源。因此,我们最好多检测频频订单支付状态,而不是在最后第30分钟才检测。
比方:我们在用户下单后的第10秒、20秒、30秒、45秒、60秒、1分30秒、2分、...30分分别设置延迟消息,如果提前发现订单已经支付,则后续的检测取消即可。
这样就可以有效制止对MQ资源的浪费了。
优化后的实现思路如下:

由于我们要多次发送延迟消息,因此需要先界说一个记录消息延迟时间的消息体,处于通用性考虑,我们将其界说到hm-common模块下:

代码如下:
  1. package com.hmall.common.domain;
  2. import com.hmall.common.utils.CollUtils;
  3. import lombok.Data;
  4. import java.util.List;
  5. @Data
  6. public class MultiDelayMessage<T> {
  7.     /**
  8.      * 消息体
  9.      */
  10.     private T data;
  11.     /**
  12.      * 记录延迟时间的集合
  13.      */
  14.     private List<Long> delayMillis;
  15.     public MultiDelayMessage(T data, List<Long> delayMillis) {
  16.         this.data = data;
  17.         this.delayMillis = delayMillis;
  18.     }
  19.     public static <T> MultiDelayMessage<T> of(T data, Long ... delayMillis){
  20.         return new MultiDelayMessage<>(data, CollUtils.newArrayList(delayMillis));
  21.     }
  22.     /**
  23.      * 获取并移除下一个延迟时间
  24.      * @return 队列中的第一个延迟时间
  25.      */
  26.     public Long removeNextDelay(){
  27.         return delayMillis.remove(0);
  28.     }
  29.     /**
  30.      * 是否还有下一个延迟时间
  31.      */
  32.     public boolean hasNextDelay(){
  33.         return !delayMillis.isEmpty();
  34.     }
  35. }
复制代码
界说常量

无论是消息发送还是接收都是在买卖业务服务完成,因此我们在trade-service中界说一个常量类,用于记录互换机、队列、RoutingKey等常量:

内容如下:
  1. package com.hmall.trade.constants;
  2. public interface MqConstants {
  3.     String DELAY_EXCHANGE = "trade.delay.topic";
  4.     String DELAY_ORDER_QUEUE = "trade.order.delay.queue";
  5.     String DELAY_ORDER_ROUTING_KEY = "order.query";
  6. }
复制代码
抽取共享mq设置

我们将mq的设置抽取到nacos中,方便各个微服务共享设置。
在nacos中界说一个名为shared-mq.xml的设置文件,内容如下:
  1. spring:
  2.   rabbitmq:
  3.     host: ${hm.mq.host:192.168.150.101} # 主机名
  4.     port: ${hm.mq.port:5672} # 端口
  5.     virtual-host: ${hm.mq.vhost:/hmall} # 虚拟主机
  6.     username: ${hm.mq.un:hmall} # 用户名
  7.     password: ${hm.mq.pw:123} # 密码
  8.     listener:
  9.       simple:
  10.         prefetch: 1 # 每次只能获取一条消息,处理完成才能获取下一个消息
复制代码
这里只添加一些基础设置,至于生产者确认,斲丧者确认设置则由微服务根据业务本身决定。
在trade-service模块添加共享设置:

改造下单业务

接下来,我们改造下单业务,在下单完成后,发送延迟消息,查询支付状态。
1)引入依赖
在trade-service模块的pom.xml中引入amqp的依赖:
  1.   
  2.   <dependency>
  3.       <groupId>org.springframework.boot</groupId>
  4.       <artifactId>spring-boot-starter-amqp</artifactId>
  5.   </dependency>
复制代码
2)改造下单业务
修改trade-service模块的com.hmall.trade.service.impl.OrderServiceImpl类的createOrder方法,添加消息发送的代码:

编写查询支付状态接口

由于MQ消息处理时需要查询支付状态,因此我们要在pay-service模块界说一个这样的接口,并提供对应的FeignClient.
起首,在hm-api模块界说三个类:

阐明:

  • PayOrderDTO:支付单的数据传输实体
  • PayClient:支付体系的Feign客户端
  • PayClientFallback:支付体系的fallback逻辑
PayOrderDTO代码如下:
  1. package com.hmall.api.dto;
  2. import io.swagger.annotations.ApiModel;
  3. import io.swagger.annotations.ApiModelProperty;
  4. import lombok.Data;
  5. import java.time.LocalDateTime;
  6. /**
  7. * <p>
  8. * 支付订单
  9. * </p>
  10. */
  11. @Data
  12. @ApiModel(description = "支付单数据传输实体")
  13. public class PayOrderDTO {
  14.     @ApiModelProperty("id")
  15.     private Long id;
  16.     @ApiModelProperty("业务订单号")
  17.     private Long bizOrderNo;
  18.     @ApiModelProperty("支付单号")
  19.     private Long payOrderNo;
  20.     @ApiModelProperty("支付用户id")
  21.     private Long bizUserId;
  22.     @ApiModelProperty("支付渠道编码")
  23.     private String payChannelCode;
  24.     @ApiModelProperty("支付金额,单位分")
  25.     private Integer amount;
  26.     @ApiModelProperty("付类型,1:h5,2:小程序,3:公众号,4:扫码,5:余额支付")
  27.     private Integer payType;
  28.     @ApiModelProperty("付状态,0:待提交,1:待支付,2:支付超时或取消,3:支付成功")
  29.     private Integer status;
  30.     @ApiModelProperty("拓展字段,用于传递不同渠道单独处理的字段")
  31.     private String expandJson;
  32.     @ApiModelProperty("第三方返回业务码")
  33.     private String resultCode;
  34.     @ApiModelProperty("第三方返回提示信息")
  35.     private String resultMsg;
  36.     @ApiModelProperty("支付成功时间")
  37.     private LocalDateTime paySuccessTime;
  38.     @ApiModelProperty("支付超时时间")
  39.     private LocalDateTime payOverTime;
  40.     @ApiModelProperty("支付二维码链接")
  41.     private String qrCodeUrl;
  42.     @ApiModelProperty("创建时间")
  43.     private LocalDateTime createTime;
  44.     @ApiModelProperty("更新时间")
  45.     private LocalDateTime updateTime;
  46. }
复制代码
PayClient代码如下:
  1. package com.hmall.api.client;
  2. import com.hmall.api.client.fallback.PayClientFallback;
  3. import com.hmall.api.dto.PayOrderDTO;
  4. import org.springframework.cloud.openfeign.FeignClient;
  5. import org.springframework.web.bind.annotation.GetMapping;
  6. import org.springframework.web.bind.annotation.PathVariable;
  7. @FeignClient(value = "pay-service", fallbackFactory = PayClientFallback.class)
  8. public interface PayClient {
  9.     /**
  10.      * 根据交易订单id查询支付单
  11.      * @param id 业务订单id
  12.      * @return 支付单信息
  13.      */
  14.     @GetMapping("/pay-orders/biz/{id}")
  15.     PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id);
  16. }
复制代码
PayClientFallback代码如下:
  1. package com.hmall.api.client.fallback;
  2. import com.hmall.api.client.PayClient;
  3. import com.hmall.api.dto.PayOrderDTO;
  4. import lombok.extern.slf4j.Slf4j;
  5. import org.springframework.cloud.openfeign.FallbackFactory;
  6. @Slf4j
  7. public class PayClientFallback implements FallbackFactory<PayClient> {
  8.     @Override
  9.     public PayClient create(Throwable cause) {
  10.         return new PayClient() {
  11.             @Override
  12.             public PayOrderDTO queryPayOrderByBizOrderNo(Long id) {
  13.                 return null;
  14.             }
  15.         };
  16.     }
  17. }
复制代码
最后,在pay-service模块的PayController中实现该接口:
  1. @ApiOperation("根据id查询支付单")
  2. @GetMapping("/biz/{id}")
  3. public PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id){
  4.     PayOrder payOrder = payOrderService.lambdaQuery().eq(PayOrder::getBizOrderNo, id).one();
  5.     return BeanUtils.copyBean(payOrder, PayOrderDTO.class);
  6. }
复制代码
消息监听

接下来,我们在trader-service编写一个监听器,监听延迟消息,查询订单支付状态:

代码如下:
  1. package com.hmall.trade.listener;
  2. import com.hmall.api.client.PayClient;
  3. import com.hmall.api.dto.PayOrderDTO;
  4. import com.hmall.common.domain.MultiDelayMessage;
  5. import com.hmall.trade.constants.MqConstants;
  6. import com.hmall.trade.domain.po.Order;
  7. import com.hmall.trade.service.IOrderService;
  8. import lombok.RequiredArgsConstructor;
  9. import lombok.extern.slf4j.Slf4j;
  10. import org.springframework.amqp.core.ExchangeTypes;
  11. import org.springframework.amqp.rabbit.annotation.Exchange;
  12. import org.springframework.amqp.rabbit.annotation.Queue;
  13. import org.springframework.amqp.rabbit.annotation.QueueBinding;
  14. import org.springframework.amqp.rabbit.annotation.RabbitListener;
  15. import org.springframework.amqp.rabbit.core.RabbitTemplate;
  16. import org.springframework.stereotype.Component;
  17. @Slf4j
  18. @Component
  19. @RequiredArgsConstructor
  20. public class OrderStatusListener {
  21.     private final IOrderService orderService;
  22.     private final PayClient payClient;
  23.     private final RabbitTemplate rabbitTemplate;
  24.     @RabbitListener(bindings = @QueueBinding(
  25.             value = @Queue(name = MqConstants.DELAY_ORDER_QUEUE, durable = "true"),
  26.             exchange = @Exchange(name = MqConstants.DELAY_EXCHANGE, type = ExchangeTypes.TOPIC),
  27.             key = MqConstants.DELAY_ORDER_ROUTING_KEY
  28.     ))
  29.     public void listenOrderCheckDelayMessage(MultiDelayMessage<Long> msg) {
  30.         // 1.获取消息中的订单id
  31.         Long orderId = msg.getData();
  32.         // 2.查询订单,判断状态:1是未支付,大于1则是已支付或已关闭
  33.         Order order = orderService.getById(orderId);
  34.         if (order == null || order.getStatus() > 1) {
  35.             // 订单不存在或交易已经结束,放弃处理
  36.             return;
  37.         }
  38.         // 3.可能是未支付,查询支付服务
  39.         PayOrderDTO payOrder = payClient.queryPayOrderByBizOrderNo(orderId);
  40.         if (payOrder != null && payOrder.getStatus() == 3) {
  41.             // 支付成功,更新订单状态
  42.             orderService.markOrderPaySuccess(orderId);
  43.             return;
  44.         }
  45.         // 4.确定未支付,判断是否还有剩余延迟时间
  46.         if (msg.hasNextDelay()) {
  47.             // 4.1.有延迟时间,需要重发延迟消息,先获取延迟时间的int值
  48.             int delayVal = msg.removeNextDelay().intValue();
  49.             // 4.2.发送延迟消息
  50.             rabbitTemplate.convertAndSend(MqConstants.DELAY_EXCHANGE, MqConstants.DELAY_ORDER_ROUTING_KEY, msg,
  51.                     message -> {
  52.                         message.getMessageProperties().setDelay(delayVal);
  53.                         return message;
  54.                     });
  55.             return;
  56.         }
  57.         // 5.没有剩余延迟时间了,说明订单超时未支付,需要取消订单
  58.         orderService.cancelOrder(orderId);
  59.     }
  60. }
复制代码
注意,这里要在OrderServiceImpl中实现cancelOrder方法

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

络腮胡菲菲

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表