黑马商城MQ高级学习笔记

55 阅读31分钟

一、消息可靠性问题

消息丢失的三种情况

  1. 发送者发送消息时网络故障
  2. MQ故障导致消息丢失
  3. 消费者处理业务时故障

从三方面解决可靠性问题,发送者的可靠性,MQ的可靠性,消费者的可靠性。在以上可靠性都失败的情况下,还需要延迟消息这一方案保障。


二、发送者的可靠性

发送者重连

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

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

修改publisher模块的application.yaml文件。

关闭RabbitMQ服务。

测试发送一条消息,可以看到重连了三次,说明发送者重连机制成功运行了。

注意:
当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的,会影响业务性能。如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。


发送者确认

SpringAMQP提供了Publisher Confirm和Publisher Return两种确认机制。开启确认机制后,当发送者发送消息给MQ后,MQ会返回确认结果给发送者。返回的结果有以下几种情况:

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

1. 在publisher这个微服务的application.yml中添加配置

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

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

  • none:关闭confirm机制
  • simple:同步阻塞等待MQ的回执消息
  • correlated:MQ异步回调方式返回回执消息

2. 每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目启动过程中配置

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

3. 发送消息,指定消息ID、消息ConfirmCallback

启动MQ。

启动debug日志记录。

编写testConfirmCallback方法。

    @Test
    public void testConfirmCallback() throws InterruptedException {

        // 0.创建correlationData
        CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());

        cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                log.error("spring amqp 处理结果异常",ex);
            }

            @Override
            public void onSuccess(CorrelationData.Confirm result) {

                // 判断是否成功
                if(result.isAck()) {

                    log.debug("收到ConfirmCallback ack,消息发送成功!");
                } else {

                    log.error("收到ConfirmCallback nack,消息发送失败!reason:{}",result.getReason());
                }
            }
        });
        // 1.交换机名
        String exchangeName = "hmall.direct";

        // 2.消息
        String message = "hello,雪miku!";

        // 3.发送消息,参数分别是:交互机名称、RoutingKey(暂时为空)、消息、correlationData
        rabbitTemplate.convertAndSend(exchangeName,"blue",message,cd);


        Thread.sleep(2000);
        // 休眠2s,以便在测试结束前能够接收回调


    }

收到ConfirmCallback ack,消息发送成功。

将RoutingKey改为错误值,测试消息投递到了MQ,但是路由失败时的情况。此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功。

将交换机名改为错误值,测试回执情况。可以看到,当交换机名字错误时,返回nack。

只要正确编写returncallback,confirmcallback,在nack时重发消息,那么消息可靠性会有极大的提升。


总结

SpringAMQP中发送者消息确认的几种返回值情况:

  • 消息投递到了MQ,但是路由失败。会return路由异常原因,返回ACK
  • 临时消息投递到了MQ,并且入队成功,返回ACK
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK
  • 其它情况都会返回NACK,告知投递失败

如何处理发送者的确认消息?

  • 发送者确认需要额外的网络和系统资源开销,尽量不要使用
  • 对于nack消息可以有限次数重试,依然失败则记录异常消息

三、MQ可靠性

在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。这样会导致两个问题:

  • 一旦MQ宕机,内存中的消息会丢失
  • 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞

数据持久化

RabbitMQ实现数据持久化包括3个方面:

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

交换机创建时持久性默认是Durable持久化的,用spring amqp代码创建时也默认是持久化的。

队列创建时持久性默认也是Durable持久化的,用spring amqp代码创建时也默认是持久化的。

消息发送时投递模式默认为非持久化,要手动设置为持久化。

分别发送非持久消息“123”和持久消息“持久消息”,此时在get message可以查到。

重启mq服务。

重启mq后只能查到持久化的消息了。


向队列中分别大量发送两种消息,测试使用非持久化消息和持久化消息哪种性能更高。非持久化消息即临时消息,如果消息量非常大,发送速度过高,出现消息堆积,有可能出现MQ阻塞。

自定义消息为非持久化模式测试

为了避免性能过差,关闭消息的确认机制,将confirm和return都关闭。

消息开始在内存中,随着内存耗尽,后来paged out即写出到磁盘。当内存达到上限时,把内存的数据写出到磁盘中。写出到磁盘瞬间,消息处理速度降到0,当写完后,速度又恢复,再次paged out,不断恢复和写出,导致消息处理出现波浪线效果。每次把消息从内存写出到磁盘过程中,MQ处于阻塞状态,消息处理速度降为0,这是纯内存模式的弊端。可能信息丢失或阻塞。

将消息改为持久化模式测试

清除之前的消息,以便观察重新测试数据。

每次发送的消息都直接持久化,没有阻塞,性能很好,而且重启mq后消息也不会丢失。


Lazy Queue

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

  • 消费者宕机或出现网络故障
  • 消息发送量激增,超过了消费者处理速度
  • 消费者处理业务发生阻塞

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

为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queue的概念,也就是惰性队列。在3.12版本后,所有队列都是Lazy Queue模式,无法更改。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘,不再存储到内存
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(可以提前缓存部分消息到内存,最多2048条)

要设置一个队列为惰性队列,只需要在声明队列时,指定x-queue-mode属性为lazy即可:

在控制台添加

先删掉之前的消息。

创建lazy.queue,在Arguments中设置x-queue-mode为lazy。

修改代码,设定接收消息的队列为lazy.queue。

运行测试,向lazy.queue发送大量消息。

由控制台信息可以看到,lazy.queue接收的消息直接被paged out即写到磁盘,而没有暂存在在内存中。

用代码添加

声明bean,用QueueBuilder

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

用RabbitListener注解

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

总结

RabbitMQ如何保证消息的可靠性

  • 首先通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息会持久化到磁盘,MQ重启消息依然存在。
  • RabbitMQ在3.6版本引入了LazyQueue,并且在3.12版本后会称为队列的默认模式。LazyQueue会将所有消息都持久化。
  • 开启持久化和发送者确认时, RabbitMQ只有在消息持久化完成后才会给发送者返回ACK回执

四、消费者的可靠性

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

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

一旦发生上述情况,消息也会丢失。因此,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

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

spring:
    rabbitmq:
        listener:
            simple:
                acknowledge-mode: none # 不做处理
# none,关闭ack;manual,手动ack;auto:自动ack

none模式

在consumer模块修改application.yaml配置,先测试消息处理方式为none时。

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

向simple.queue队列发送消息,进行测试。

可以看到此时simple.queue中存储了一条消息。

随后用debug模式启动消费者consumer。发现程序进入抛出异常处的断点,这表明消费者刚收到消息,正准备处理。

放行后消费者抛出异常,说明消息没有正常处理需要队列重发,但控制台中可以看到simple.queue中的消息丢失了。故none方式不可取,消息会丢失。


auto模式

将消息处理方式改为auto模式进行测试。

再次发送消息。

可以看到消息进入simple.queue。

启动调试,消费者接收到消息并暂停在抛出异常的断点之前。

此时消息处于unacked即未确认状态。

消费者此时拿到消息开始处理,放行后在处理过程中抛出了Runtime业务异常。spring会自动对该方法进行环绕增强,当发现抛出异常时,会抛出nack。nack会导致消息重新投递回来。因此放行方法再次进入断点。因为返回的是nack,rabbitmq收到消息会认为处理有问题,会再次投递消息,一直重试到宕机。

结束消费者的运行。

关闭消费者程序即宕机后,回到浏览器可以看到消息状态发生改变,变回ready了。只要没有ack,就会一直保留消息,直到成功为止。因此消费者宕机后,重启动即可。


reject模式

如果不是抛出业务异常,而是消息转换异常比如MessageConversionException,这种异常表示消息本身转换过程中出现了问题。

修改异常类型为MessageConversionException并重启调试。

执行断点前,消息处于unacked。

放行断点。

放行后发现没有再次收到消息。这是因为消息转换异常会直接reject拒绝这个消息,这个消息被丢弃了或路由到死信交换机。此时还没有死信交换机,故消息被丢弃。此时控制台可看到simple.queue消息队列中消息已经被删除了。

基于springamqp提供的自动确认机制,我们只要正常写业务代码,将来它会自动根据业务执行情况实现确认,大大增强了消息的可靠性。但有一些消息格式的异常,spring检测不到,这种情况下需要自己对业务做判断。


失败重试机制

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力。因此SpringAMQP提供了消费者失败重试机制,在消费者出现异常时利用本地重试,而不是无限的requeue到mq,mq再把消息投递给消费者。

无限循环重新入队和重新投递的情况示例

消费者的消息监听器中异常类型改回RuntimeException。

向simple.queue发送消息。

启动消费者。

消费者一直在抛异常。simple.queue中消息状态为redelivered即重新投递,由于投递失败一直重新入队并重新投递,导致MQ压力非常大,消费者压力也很大。


使用重试机制

为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。我们可以通过在application.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

在consumer的application.yaml中添加消费者失败重试配置。

此时消息还在simple.queue队列中。只要重启消费者就会接收消息。

日志信息概览

监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:21:567  INFO 23636 --- [           main] c.itheima.consumer.ConsumerApplication   : Started ConsumerApplication in 2.807 seconds (JVM running for 3.599)
01-29 13:07:22:567  INFO 23636 --- [ntContainer#8-1] c.i.consumer.mq.SpringRabbitListener     : 监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:23:570  INFO 23636 --- [ntContainer#8-1] c.i.consumer.mq.SpringRabbitListener     : 监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:23:578  WARN 23636 --- [ntContainer#8-1] o.s.a.r.r.RejectAndDontRequeueRecoverer  : Retries exhausted for message (Body:'"hello,spring amqp"' MessageProperties [headers={__TypeId__=java.lang.String}, contentType=application/json, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=true, receivedExchange=, receivedRoutingKey=simple.queue, deliveryTag=1, consumerTag=amq.ctag-WO4M5YLPXZ7HqY6AWv4WZA, consumerQueue=simple.queue])

消费者服务三次监听到消息队列 simple.queue 中的消息 “hello,spring amqp”,并尝试处理,但均失败。其中o.s.a.r.r.RejectAndDontRequeueRecoverer,表示Spring AMQP的重试恢复器。RejectAndDontRequeueRecoverer : Retries exhausted for message表示消息hello,spring amqp重试次数已耗尽,最终被拒绝且不再重新入队(RejectAndDontRequeue),意味着消息将被丢弃或进入死信队列。

可知消费者收到一次消息。然后并没有把消息重新入队到队列和重新投递,而是在本地又重试两次。在simple.queue队列中,并没有重新入队,说明投递过去了。三次重试之后,重试耗尽了,消息队列中消息也没了,导致消费者可靠性降低。RejectAndDontRequeueRecoverer是springamqp的重试机制开启后的默认消息处理策略,即“拒绝且不要重新入队”,可靠性低,所以需要我们修改消息处理策略。


更改失败消息处理策略

在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现:

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

将失败处理策略改为RepublishMessageRecoverer:

首先,定义接收失败消息的交换机、队列及其绑定关系,然后,定义RepublishMessageRecoverer。

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;
import org.springframework.context.annotation.Configuration;

/**
* 错误消息处理配置类
* 功能:配置消息消费失败后的重试和重新发布机制
* 核心原理:通过定义错误交换机和队列,将无法处理的消息路由到指定位置
*/
@Configuration  // 声明为Spring配置类
public class ErrorMessageConfiguration {

    /**
    * 创建错误消息专用直连交换机
    * 作用:作为错误消息的路由中心
    * 特性:
    * - 直连交换机根据精确匹配的routingKey路由消息
    * - 持久化默认开启(Durable)
    */
    @Bean
    public DirectExchange errorExchange() {
        return new DirectExchange("error.direct");  // 创建名为error.direct的直连交换机
    }

    /**
    * 创建错误消息队列
    * 作用:存储所有处理失败的消息
    * 特性:
    * - 队列持久化(默认)
    * - 未被消费的消息会一直保留直到被消费
    */
    @Bean
    public Queue errorQueue() {
        return new Queue("error.queue");  // 创建名为error.queue的队列
    }

    /**
    * 绑定错误队列到错误交换机
    * 作用:建立路由规则,将发送到error.direct交换机且routingKey=error的消息路由到error.queue
    * @param errorQueue 通过参数注入已定义的errorQueue Bean
    * @param errorExchange 通过参数注入已定义的errorExchange Bean
    */
    @Bean
    public Binding errorQueueBinding(Queue errorQueue, DirectExchange errorExchange) {
        return BindingBuilder.bind(errorQueue)   // 绑定队列
                .to(errorExchange)              // 到交换机
                .with("error");                 // 使用routingKey="error"
    }

    /**
    * 配置消息恢复处理器
    * 作用:当消息重试耗尽后,将消息重新发布到指定交换机
    * 工作流程:
    * 1. 消息消费失败触发重试机制
    * 2. 达到最大重试次数后调用此处理器
    * 3. 将原始消息发送到error.direct交换机,携带error路由键
    * @param rabbitTemplate 自动注入的RabbitMQ操作模板
    */
    @Bean
    public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
        return new RepublishMessageRecoverer(
                rabbitTemplate,          // RabbitMQ操作模板
                "error.direct",          // 目标交换机
                "error"                  // routingKey
        );
    }
}

重启消费者。

再次向simple.queue发送消息。

可以看到消息重试了三次,之后RepublishMessageRecoverer生效,将失败消息发到了error.direct交换机。

可以看到error.direct中的消息记录。

error.queue中的消息记录。

可以查看到error.direct绑定了error.queue,且error.queue也接收到了消息,在get message可以看到详细信息。

总结

如何开启消费者失败重试机制?

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

如何配置失败重试处理策略?

@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
    return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}

业务幂等性

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

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

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

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

所以,我们要尽可能避免业务被重复执行。 然而在实际业务场景中,由于意外经常会出现业务被重复执行的情况,例如:

  • 页面卡顿时频繁刷新导致表单重复提交
  • 服务间调用的重试
  • MQ消息的重复投递

我们在用户支付成功后会发送MQ消息到交易服务,修改订单状态为已支付,就可能出现消息重复投递的情况。如果消费者不做判断,很有可能导致消息被消费多次,出现业务故障。 举例:

  1. 假如用户刚刚支付完成,并且投递消息到交易服务,交易服务更改订单为已支付状态。
  2. 由于某种原因,例如网络故障导致生产者没有得到确认,隔了一段时间后重新投递给交易服务。
  3. 但是,在新投递的消息被消费之前,用户选择了退款,将订单状态改为了已退款状态。
  4. 退款完成后,新投递的消息才被消费,那么订单状态会被再次改为已支付。业务异常。

因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:

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

唯一消息id

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

  1. 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
  2. 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
  3. 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。

我们该如何给消息添加唯一ID呢? 其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。 以Jackson的消息转换器为例:

@Bean
public MessageConverter messageConverter(){
    // 1.定义消息转换器
    Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
    // 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
    jjmc.setCreateMessageIds(true);
    return jjmc;
}

在发送者启动类中的消息转换器方法添加创建消息id的配置。

向simple.queue发送消息。

可以看到simple.queue收到的消息有消息id了。

消息监听者接收消息时,用Message类型接收消息。

启动消费者,可以看到监听日志中输出了simple.queue接收到的消息id和消息体。

业务判断

方案二,是结合业务逻辑,基于业务本身做判断。以我们的余额支付业务为例:

修改对支付状态的监听器,增加先查询订单,判断订单支付状态的逻辑,仅在支付状态为未支付时才执行标记订单状态为已支付的逻辑。

总结

面试题:如何保证支付服务与交易服务之间的订单状态一致性?

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

    • 为什么用异步通知,而不是用openfeign的方式?
    • 回答同步和异步调用的区别。
  • 通知失败怎么办? 其次,为了保证MQ消息的可靠性,我们采用了生产者(发送者)确认机制、消费者确认、消费者失败重试等策略,确保消息投递和处理的可靠性。同时也开启了MQ的持久化,避免因服务宕机导致消息丢失。可以确保消息一定投递到消费者,至少让消费者处理一次(因为可能出现重复投递)。

    • 什么是消费者确认?
    • 什么是生产者确认?
    • 什么是消费者重试策略?
  • 最后,由于可能重复投递消息,我们还在交易服务更新订单状态时做了业务幂等判断,避免因消息重复消费导致订单状态异常。

    • 业务幂等的常见方案?

如果交易服务消息处理失败,有没有什么兜底方案?

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

五、延迟消息

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

延迟消息:发送者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。在RabbitMQ中实现延迟消息也有两种方案:

  • 死信交换机+TTL
  • 延迟消息插件

死信交换机

当一个队列中的消息满足下列情况之一时,就会成为死信(dead letter):

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

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

在消息队列系统(如RabbitMQ)中,死信交换机(Dead Letter Exchange, DLX) 本身不直接支持延迟消息,但可以通过结合 消息TTL(Time-To-Live)队列绑定规则 实现延迟消息功能。以下是具体实现原理和步骤:

实现原理

  1. 消息TTL机制

    • 为消息设置过期时间(TTL),当消息在队列中存活超过TTL时,会变成 死信(Dead Letter)
    • TTL可通过两种方式设置:
      • 消息级别TTL:每条消息单独设置过期时间。
      • 队列级别TTL:队列中所有消息统一过期时间(灵活性较低)。
  2. 死信路由规则

    • 定义队列的死信交换机(DLX)和路由键(Routing Key)。
    • 当消息过期后,自动转发到DLX,并由DLX路由到目标队列。
  3. 延迟效果

    • 消息首先进入一个 缓冲队列(带TTL),过期后通过DLX路由到 目标队列,消费者从目标队列消费时即实现延迟。

在消费者的监听器中编写监听死信队列的方法listenDlxQueue,并用注解方式设置死信交换机和死信队列。

编写普通交换机和普通队列,并为普通队列设定死信交换机。

启动消费者。

可以看到dlx.direct与dlx.queue绑定成功,normal.direct与normal.queue绑定成功,并且normal.queue设置了死信交换机为dlx.direct。

编写向normal.direct发送延迟消息的测试方法。

发送者向normal.direct发送消息,且设置消息过期时间为10s。此时消息发送时间为37分33秒。

normal.queue中收到消息。

dlx.queue中收到消息。

可以看到,发送消息时间为37分33秒,消费者的listenDlxQueue方法监听到死信队列消息的时间为37分44秒,基本相差10s,成功实现了延迟消息。


延迟消息插件

这个插件可以将普通交换机改造为支持延迟消息功能的交换机,当消息投递到交换机后可以暂存一定时间,到期后再投递到队列。

因为我们是基于Docker安装,所以需要先查看RabbitMQ的插件目录对应的数据卷。使用docker volume ls查看所有数据卷,用docker volume inspect mq-plugins查看mq的插件目录。查询结果显示插件目录被挂载到了/var/lib/docker/volumes/mq-plugins/_data这个目录。使用cd /var/lib/docker/volumes/mq-plugins/_data切换到该目录,上传资料里的插件到该目录下。

执行docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange命令,安装插件。

在消费者监听方法使用注解方式设置延迟交换机和延迟队列。

启动消费者,开启监听的同时会创建延迟交换机和延迟队列。

可以看到delay.queue和delay.direct已绑定,且delay.direct为支持延迟消息类型即x-delayed-message的交换机。

编写发送延迟消息的方法,通过x-delay属性设定延迟时间为10s。

运行测试方法,向delay.direct发送延迟消息。发出时间约为39分41秒。

可以看到delay.queue中收到了消息。

39分41秒向delay.direct发出消息,在39分51秒时由监听器接收到delay.queue的消息,证明实现了消息的延迟发送。

注意: 延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时。如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。因此,不建议设置延迟时间过长的延迟消息。

实现订单超时取消功能

用户下单完成后,发送15分钟延迟消息,在15分钟后接收消息,检查支付状态:

  • 已支付:更新订单状态为已支付
  • 未支付:更新订单状态为关闭订单,恢复商品库存

无论是消息发送还是接收都是在交易服务完成,因此我们在trade-service中定义一个MQ常量类,用于记录交换机、队列、RoutingKey等常量。

修改创建订单方法,在扣减库存下面增加发送延迟消息的逻辑。为了方便测试,将延迟时间设置为10s。

PayOrderDTO为支付单的数据传输实体。

支付系统的Feign客户端PayClient中编写查询支付流水的接口。

PayClientFallback中编写支付系统的fallback逻辑。

在pay-service模块的PayController中编写支付流水的查询方法,实现payclient要调用的queryPayOrderByBizOrderNo接口。

在order模块编写一个监听器,监听延迟消息,查询订单支付状态。如果订单处于待支付状态,该代码会查询对应的支付流水。如果支付流水显示已支付,则更新订单状态为已支付;否则,取消订单并恢复库存。

编写还未实现的cancelOrder方法。

在PayClient中编写根据业务订单号修改支付订单状态的方法接口updatePayOrderStatusByBizOrderNo

PayClientFallback中编写updatePayOrderStatusByBizOrderNo的失败回退逻辑。

PayController中编写updatePayOrderStatusByBizOrderNo的实现方法。

IPayOrderService中编写updateStatusByOrderId的接口。

PayOrderServiceImpl中编写updateStatusByOrderId的实现方法,根据参数中的订单号,将查询到的支付单的status更改为参数status的值。

ItemClient中编写恢复库存的接口restoreStock

ItemClientFallbackFactory中编写恢复库存的接口restoreStock的失败回退逻辑。

ItemController中实现恢复库存的方法restoreStock

IItemService中编写restoreStock接口。

ItemServiceImpl中编写restoreStock的实现。遍历所有订单详情,从数据库中查找对应的商品,对每个商品,将其库存更新为当前库存加上订单详情中的数量之和。

在pay-service模块中,暂时注释掉支付和修改支付单状态后发送消息到pay.direct的部分,这样使得order-service模块中的支付状态监听器无法立刻收到支付消息来修改order表中的支付状态,方便我们去观察延迟消息到达后的执行情况。

在MqConfig中添加Spring Boot 的条件化配置注解,仅在类路径中存在 RabbitTemplate 类时,才启用当前配置类 MqConfig。 具体表现为:

  • 依赖检查:当项目引入 RabbitMQ 客户端依赖(如 spring-boot-starter-amqp)时生效
  • 自动装配控制:避免在未使用 RabbitMQ 的项目中加载相关配置,防止 ClassNotFoundException

进行下单和支付操作。

order表中查到刚下的订单,status为2即“已付款,未发货”。由于order-service模块中的支付状态监听器没有收到pay.direct的支付消息来修改order表的支付状态。所以只能是延迟消息监听器中的orderService.markOrderPaySuccess(orderId)代码执行,对order中的status进行了修改。

pay_order支付流水表中,status为3即“支付成功”,表示该订单为已支付。

TradeApplication的日志中,可以看到订单服务中的延迟消息监听器OrderDelayMessageListener执行调用了PayClientqueryPayOrderByBizOrderNo方法,根据交易订单id查询了支付单。发现支付单为已支付,而订单状态为未支付,进而执行了orderService.markOrderPaySuccess(orderId),该方法內部执行了MyBatis-Plus提供的updateById方法。可以看到日志信息中修改订单状态的sql语句。最终实现了将订单状态同步为已支付。

总结: 在下单时,发送一条延迟消息,延迟消息时长取决于业务需求,根据订单超时支付时间确定。超过延迟时间后,查询order支付状态,如果查本地order发现已经支付,说明支付服务通知到位了。如果查本地order未支付,可能是用户没有支付或支付服务支付成功但通知失败。需要查询payorder支付流水来确定支付状态,判断查询结果。如果payorder未支付,需要取消订单,并恢复库存。如果payorder已支付,则标记order为已支付。

六、MQ高级学习总结

  1. 学习了发送者的可靠性,包括发送者重连机制和发送者确认机制。
  2. 学习了MQ的可靠性,包括数据持久化和LazyQueue。
  3. 学习了消费者的可靠性,包括消费者确认机制,失败重试机制,失败处理策略,业务幂等性。
  4. 学习了延迟消息,包括通过死信交换机和DelayExchange插件两种实现方式。
  5. 增加了用延迟消息处理超时订单的业务。