RabbitMQ高级篇,进阶内容

强烈建议在看本篇博客之前快速浏览文章:RabbitMQ基础有这一篇就够了

RabbitMQ高级篇

  • [0. 前言](#0. 前言)
  • [1. 发送者的可靠性](#1. 发送者的可靠性)
    • [1.1 生产者重试机制](#1.1 生产者重试机制)
    • [1.2 生产者确认机制](#1.2 生产者确认机制)
    • [1.3 实现生产者确认](#1.3 实现生产者确认)
  • [2. MQ的可靠性](#2. MQ的可靠性)
    • [2.1 MQ持久化](#2.1 MQ持久化)
    • [2.2 LazyQueue](#2.2 LazyQueue)
  • [3. 消费者的可靠性](#3. 消费者的可靠性)
    • [3.1 消费者确认机制](#3.1 消费者确认机制)
    • [3.2 失败重试策略](#3.2 失败重试策略)
    • [3.3 业务幂等性](#3.3 业务幂等性)
      • [3.3.1 唯一消息ID](#3.3.1 唯一消息ID)
      • [3.3.2 业务判断](#3.3.2 业务判断)
  • [4. 延迟消息](#4. 延迟消息)
    • [4.1 死信交换机](#4.1 死信交换机)
    • [4.2 取消超时订单案例](#4.2 取消超时订单案例)
  • 常见面试题

0. 前言

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

  • 发送消息时丢失
    • 生产者发送消息时连接MQ失败
    • 生产者发送消息到达MQ后未找到Exchange
    • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue
    • 消息到达MQ后,处理消息的进程发生异常
  • MQ导致消息丢失
    • 消息到达MQ,保存到队列后,尚未消费就突然宕机
  • 消费者处理消息时
    • 消息接收后尚未处理突然宕机
    • 消息接收后处理过程中抛出异常

解决消息丢失问题,保证MQ的可靠性,就必须从3个方面入手:

  • 确保生产者一定把消息发送到MQ
  • 确保MQ不会将消息弄丢
  • 确保消费者一定要处理消息

1. 发送者的可靠性

1.1 生产者重试机制

生产者发送消息时,出现了网络故障,导致与MQ的连接中断SpringAMQP提供的消息发送时的重试机制 。即:当RabbitTemplate与MQ连接超时后,多次重试。

修改publisher模块的application.yaml文件,添加下面的内容:

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

这段配置代码是针对 Spring 框架中 RabbitMQ 的设置,主要用于配置 RabbitMQ 的连接和消息发送的重试机制。下面是对每个配置项的详细解释:

1. spring.rabbitmq.connection-timeout
- RabbitMQ 连接的超时时间。
- 表示连接超时时间为 1 秒。如果在 1 秒内无法建立连接,程序将抛出异常。

2. spring.rabbitmq.template.retry
这个部分配置了消息发送时的重试策略。

a. enabled
- 开启或关闭重试机制。
- 表示开启超时重试机制。如果消息发送失败,Spring 会自动尝试重新发送消息。

b. initial-interval
- 设置首次重试的等待时间。
- 表示首次重试的等待时间为 1 秒。如果第一次发送失败,程序将在 1 秒后再次尝试发送。

c. multiplier
- 重试时等待时间的倍数。
- 表示每次重试的等待时间不增加。计算公式为:  
  下次等待时长 = initial-interval × multiplier。  
  在此配置中,所有重试的等待时间都将保持在 1 秒,因为乘以 1 不会改变初始间隔。

d. `max-attempts`
- 设置最大重试次数。
- 表示最多会尝试 3 次发送消息。如果消息在 3 次尝试后仍然无法发送成功,则会抛出异常并终止重试。

特别注意 :当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式的重试 ,也就是说多次重试等待的过程中,当前线程是被阻塞的。

如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。

1.2 生产者确认机制

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

  • MQ内部处理消息的进程发生了异常
  • 生产者发送消息到达MQ后未找到Exchange(程序员书写问题)
  • 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由(程序员书写问题)

针对上述情况,RabbitMQ提供了生产者消息确认机制 ,包括Publisher ConfirmPublisher Return两 种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执。返回的结果有以下几种情况:

  • 消息投递到了MQ,但是路由失败 。此时会通过Publisher Return返回路由异常原因,然后返回ACK,告知投递成功**(例如:成功送到交换机但是没有绑定队列,一般是程序员忘记添加)**
    • 路由失败情况1:发送者成功发送到MQ,但是exchange没有绑定一个queue,一般是程序员书写问题;
    • 路由失败情况2:发送者成功发送到MQ,但是RountingKey 和 BindingKey都不一致,一般是程序员书写问题;
  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
  • 其它情况都会返回NACK,告知投递失败(比如持久化消息没有写到磁盘等情况)

其中acknack属于Publisher Confirm机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。默认两种机制都是关闭状态,需要通过配置文件来开启。

1.3 实现生产者确认

publisher模块的application.yaml中添加配置:

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

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

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

测试案例

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

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

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

import javax.annotation.PostConstruct;

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

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

定义ConfirmCallback:

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

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

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

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

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

java 复制代码
@Test
void testPublisherConfirm() {
    // 1.创建CorrelationData
    CorrelationData cd = new CorrelationData();
    // 2.给Future添加ConfirmCallback
    cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
        @Override
        public void onFailure(Throwable ex) {
            // 2.1.Future发生异常时的处理逻辑,基本不会触发
            log.error("send message fail", ex);
        }
        @Override
        public void onSuccess(CorrelationData.Confirm result) {
            // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
            if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
                log.debug("发送消息成功,收到 ack!");
            }else{ // result.getReason(),String类型,返回nack时的异常描述
                log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
            }
        }
    });
    // 3.发送消息,参数依次为:交换机名称、RountingKey、需要发送的消息
    rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}

执行结果如下:

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

2. MQ的可靠性

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

2.1 MQ持久化

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

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

交换机持久化 :在控制台的Exchanges页面,添加交换机时可以配置交换机的Durability参数,设置为Durable就是持久化模式,Transient就是临时模式。

队列持久化:在控制台的Queues页面,添加队列时,同样可以配置队列的Durability参数。

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

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

2.2 LazyQueue

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

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

为什么要提出LazyQueue的原因,PageOut阻塞=================

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

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

  • 接收到消息后直接存入磁盘而非内存;
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载);
  • 支持数百万条的消息存储;

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

配置Lazy模式1:控制台配置Lazy模式

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

配置Lazy模式2:代码配置Lazy模式

在利用SpringAMQP声明队列的时候,添加x-queue-mod=lazy参数也可设置队列为Lazy模式:

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

也可以基于注解来声明队列并设置为Lazy模式:

java 复制代码
@RabbitListener(queuesToDeclare = @Queue(
        name = "lazy.queue",
        durable = "true",
        arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
    log.info("接收到 lazy.queue的消息:{}", msg);
}

3. 消费者的可靠性

3.1 消费者确认机制

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

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

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

  • ack:成功处理消息,RabbitMQ从队列中删除该消息
  • nack:消息处理失败,RabbitMQ需要再次投递消息
    • 网络延迟,或者自身问题处理失败
  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
    • 例如发送过来的json格式存在问题,再次重试还是存在问题的,所以直接决绝

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

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活
  • auto:自动模式 。SpringAMQP利用AOP 对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:
  • 如果是业务异常,会自动返回nack;
  • 如果是消息处理或校验异常,自动返回reject;

通过下面的配置可以修改SpringAMQPACK处理方式(消费者端):

yaml 复制代码
spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: none # 不做处理
        acknowledge-mode: auto # 自动ack

3.2 失败重试策略

当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。

极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力:

为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。

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

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

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

失败处理策略

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

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

失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。完整代码如下:

java 复制代码
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.retry.MessageRecoverer;
import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer;
import org.springframework.context.annotation.Bean;

@Configuration
// 根据配置文件中的属性决定是否加载这个配置类。
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")
public class ErrorMessageConfig {
    @Bean
    public DirectExchange errorMessageExchange(){
        return new DirectExchange("error.direct");
    }
    
    @Bean
    public Queue errorQueue(){
        return new Queue("error.queue", true);
    }
    
	// 关联交换机和队列
    @Bean
    public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){
        return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
    }

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

3.3 业务幂等性

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

常见的幂等

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

常见的非幂等

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

非幂等性实际案例

  • 情况1:消息被重复消费,如果消费者和mq之间的网络连接断开,消费者的ack未能成功发送到mq,那么等到连接好了之后,mq又会重新发送消息,此时消息重复被消费。如果这个消息是用于扣减库存的,那么就会出现问题。
  • 情况2:用户在支付服务完成支付后,MQ通知交易业务标记订单为已支付,交易服务标记成功后返回ack给mq,但是此时交易服务和MQ之间网络故障,ack未被mq收到,mq可能认为交易服务宕机,消息重新入队,此时用户又进行了退款,交易服务立马将订单状态修改为退款中,而mq和交易服务之间的网络又恢复了,mq又将消息发送给交易服务,其又将订单修改为已支付,此时出现问题。

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

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

3.3.1 唯一消息ID

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

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

SpringAMQPMessageConverter自带了MessageID的功能,我们只要开启这个功能即可。以Jackson的消息转换器为例

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


缺点:业务侵入、且有数据库的操作影响业务性能

3.3.2 业务判断

业务判断就是基于业务本身的逻辑或状态 来判断是否是重复的请求或消息,不同的业务场景判断的思路也不一样。例如当前案例中,处理消息的业务逻辑是把订单状态从未支付修改为已支付 。因此我们就可以在执行业务时判断订单状态是否是未支付,如果不是未支付状态则证明订单已经被处理过(已支付,申请退款等状态)无需重复处理。相比较而言,消息ID的方案需要改造原有的数据库,所以我更推荐使用业务判断的方案。

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

java 复制代码
@Override
public void markOrderPaySuccess(Long orderId) {
    // 1.查询订单
    Order old = getById(orderId);
    // 2.判断订单状态
    // 订单的状态,1、未付款 2、已付款,未发货 3、已发货,未确认 4、确认收货,交易成功5、交易取消,订单关闭6、交易结束,已评价
    if (old == null || old.getStatus() != 1) {
        // 订单不存在或者订单状态不是1,放弃处理
        return;
    }
    // 3.尝试更新订单
    Order order = new Order();
    order.setId(orderId);
    order.setStatus(2);
    order.setPayTime(LocalDateTime.now());
    updateById(order);
}


// 考虑到线程安全问题,可以进行合并
@Override
public void markOrderPaySuccess(Long orderId) {
    // UPDATE `order` SET status = ? , pay_time = ? WHERE id = ? AND status = 1
    lambdaUpdate()
            .set(Order::getStatus, 2)
            .set(Order::getPayTime, LocalDateTime.now())
            .eq(Order::getId, orderId)
            .eq(Order::getStatus, 1)
            .update();
}

4. 延迟消息

在电商的支付业务中,对于一些库存有限的商品,为了更好的用户体验,通常都会在用户下单时立刻扣减商品库存。例如电影院购票、高铁购票,下单后就会锁定座位资源,其他人无法重复购买。

但是这样就存在一个问题,假如用户下单后一直不付款,就会一直占有库存资源,导致其他客户无法正常交易,最终导致商户利益受损!

因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存。例如,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。

如何才能准确的实现在下单后第30分钟去检查支付状态呢

像这种在一段时间以后才执行的任务,我们称之为延迟任务,而要实现延迟任务,最简单的方案就是利用MQ的延迟消息了。

在RabbitMQ中实现延迟消息也有两种方案:

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

4.1 死信交换机


注意 :要确保normal.directdlx.directRountingKey一致

4.2 取消超时订单案例

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

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

查询支付状态有两次

  • 查询本地订单状态,如果已经正常通知了,支付和交易服务的通知正常着,订单状态已经修改未为支付了,此时直接结束即可。
  • 如果本地查询到的订单状态不是已支付,那么有可能是没能通知到,此时需要去向支付服务查询支付流水状态,如果是已支付,则修改,如果不是,那么就说明超时了,则取消订单。

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

java 复制代码
package com.hmall.trade.constants;

public interface MQConstants {
    String DELAY_EXCHANGE_NAME = "trade.delay.direct";
    String DELAY_ORDER_QUEUE_NAME = "trade.delay.order.queue";
    String DELAY_ORDER_KEY = "delay.order.query";
}

配置MQ: 在trade-service模块的pom.xml中引入amqp的依赖:

yaml 复制代码
  <!--amqp-->
  <dependency>
      <groupId>org.springframework.boot</groupId>
      <artifactId>spring-boot-starter-amqp</artifactId>
  </dependency>

trade-serviceapplication.yaml中添加MQ的配置:

yaml 复制代码
spring:
  rabbitmq:
    host: 192.168.150.101
    port: 5672
    virtual-host: /hmall
    username: hmall
    password: 123

改造下单业务,发送延迟消息 : 在下单完成后,发送延迟消息,查询支付状态。修改trade-service模块的com.hmall.trade.service.impl.OrderServiceImpl类的createOrder方法,添加消息发送的代码:

编写查询支付状态接口:由于MQ消息处理时需要查询支付状态,因此要在pay-service模块定义一个这样的接口,并提供对应的FeignClient。 首先,在hm-api模块定义三个类:

  • PayOrderDTO:支付单的数据传输实体
  • PayClient:支付系统的Feign客户端
  • PayClientFallback:支付系统的fallback逻辑

PayOrderDTO代码如下:

java 复制代码
package com.hmall.api.dto;

import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * <p>
 * 支付订单
 * </p>
 */
@Data
@ApiModel(description = "支付单数据传输实体")
public class PayOrderDTO {
    @ApiModelProperty("id")
    private Long id;
    @ApiModelProperty("业务订单号")
    private Long bizOrderNo;
    @ApiModelProperty("支付单号")
    private Long payOrderNo;
    @ApiModelProperty("支付用户id")
    private Long bizUserId;
    @ApiModelProperty("支付渠道编码")
    private String payChannelCode;
    @ApiModelProperty("支付金额,单位分")
    private Integer amount;
    @ApiModelProperty("付类型,1:h5,2:小程序,3:公众号,4:扫码,5:余额支付")
    private Integer payType;
    @ApiModelProperty("付状态,0:待提交,1:待支付,2:支付超时或取消,3:支付成功")
    private Integer status;
    @ApiModelProperty("拓展字段,用于传递不同渠道单独处理的字段")
    private String expandJson;
    @ApiModelProperty("第三方返回业务码")
    private String resultCode;
    @ApiModelProperty("第三方返回提示信息")
    private String resultMsg;
    @ApiModelProperty("支付成功时间")
    private LocalDateTime paySuccessTime;
    @ApiModelProperty("支付超时时间")
    private LocalDateTime payOverTime;
    @ApiModelProperty("支付二维码链接")
    private String qrCodeUrl;
    @ApiModelProperty("创建时间")
    private LocalDateTime createTime;
    @ApiModelProperty("更新时间")
    private LocalDateTime updateTime;
}

PayClient代码如下:

java 复制代码
package com.hmall.api.client;

import com.hmall.api.client.fallback.PayClientFallback;
import com.hmall.api.dto.PayOrderDTO;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;

@FeignClient(value = "pay-service", fallbackFactory = PayClientFallback.class)
public interface PayClient {
    /**
     * 根据交易订单id查询支付单
     * @param id 业务订单id
     * @return 支付单信息
     */
    @GetMapping("/pay-orders/biz/{id}")
    PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id);
}

PayClientFallback代码如下:

java 复制代码
package com.hmall.api.client.fallback;

import com.hmall.api.client.PayClient;
import com.hmall.api.dto.PayOrderDTO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.openfeign.FallbackFactory;

@Slf4j
public class PayClientFallback implements FallbackFactory<PayClient> {
    @Override
    public PayClient create(Throwable cause) {
        return new PayClient() {
            @Override
            public PayOrderDTO queryPayOrderByBizOrderNo(Long id) {
                return null;
            }
        };
    }
}

最后,在pay-service模块的PayController中实现该接口:

java 复制代码
@ApiOperation("根据id查询支付单")
@GetMapping("/biz/{id}")
public PayOrderDTO queryPayOrderByBizOrderNo(@PathVariable("id") Long id){
    PayOrder payOrder = payOrderService.lambdaQuery().eq(PayOrder::getBizOrderNo, id).one();
    return BeanUtils.copyBean(payOrder, PayOrderDTO.class);
}

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

java 复制代码
package com.hmall.trade.listener;

import com.hmall.api.client.PayClient;
import com.hmall.api.dto.PayOrderDTO;
import com.hmall.trade.constants.MQConstants;
import com.hmall.trade.domain.po.Order;
import com.hmall.trade.service.IOrderService;
import lombok.RequiredArgsConstructor;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class OrderDelayMessageListener {

    private final IOrderService orderService;
    private final PayClient payClient;

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = MQConstants.DELAY_ORDER_QUEUE_NAME),
            exchange = @Exchange(name = MQConstants.DELAY_EXCHANGE_NAME, delayed = "true"),
            key = MQConstants.DELAY_ORDER_KEY
    ))
    public void listenOrderDelayMessage(Long orderId){
        // 1.查询订单
        Order order = orderService.getById(orderId);
        // 2.检测订单状态,判断是否已支付
        if(order == null || order.getStatus() != 1){
            // 订单不存在或者已经支付
            return;
        }
        // 3.未支付,需要查询支付流水状态
        PayOrderDTO payOrder = payClient.queryPayOrderByBizOrderNo(orderId);
        // 4.判断是否支付
        if(payOrder != null && payOrder.getStatus() == 3){
            // 4.1.已支付,标记订单状态为已支付
            orderService.markOrderPaySuccess(orderId);
        }else{
            // TODO 4.2.未支付,取消订单,回复库存
            orderService.cancelOrder(orderId);
        }
    }
}

常见面试题

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

  • 首先,支付服务 会在用户支付成功以后利用MQ消息通知交易服务,完成订单状态同步。
  • 其次,为了保证MQ消息的可靠性,我们采用了生产者确认机制、消费者确认、消费者失败重试等策略,确保消息投递和处理的可靠性。同时也开启了MQ的持久化,避免因服务宕机导致消息丢失。
  • 最后,我们还在交易服务更新订单状态时做了业务幂等判断 ,避免因消息重复消费导致订单状态异常。

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

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

相关推荐
谢小涛5 个月前
Spring Boot集成Redisson实现延迟队列
spring boot·redis·redisson·延迟队列·延迟消息
勉之~10 个月前
RabbitMQ之延迟消息实战
rabbitmq·延迟消息
求知.10 个月前
【RabbitMQ】 RabbitMQ 消息的延迟 —— 深入探索 RabbitMQ 的死信交换机,消息的 TTL 以及延迟队列
rabbitmq·延迟消息
胡子哥5021 年前
Centos安装RabbitMQ,JavaSpring发送RabbitMQ延迟延时消息,JavaSpring消费RabbitMQ消息
java·centos·rabbitmq·延迟消息