RabbitMQ

引入

微服务一旦拆分,必然涉及到服务之间的相互调用,目前我们服务之间调用采用的都是基于OpenFeign的调用。这种调用中,调用者发起请求后需要等待服务提供者执行业务返回结果后,才能继续执行后面的业务。也就是说调用者在调用过程中处于阻塞状态,因此我们称这种调用方式为同步调用,也可以叫同步通讯。但在很多场景下,我们可能需要采用异步通讯的方式,为什么呢?

image-20251204152943861

同步通信:如同视频聊天,对方立即能看到,等不到结果会等待阻塞。因此同一时刻你只能跟一个人打视频电话

异步通信:就如同发微信聊天,双方的交互不是实时的,你不需要立刻给对方回应。因此你可以多线操作,同时跟多人聊天

两种方式各有优劣,打电话可以立即得到响应,但是你却不能跟多个人同时通话。发微信可以同时与多个人收发微信,但是往往响应会有延迟。

所以,如果我们的业务需要实时得到服务提供方的响应,则应该选择同步通讯(同步调用)。而如果我们追求更高的效率,并且不需要实时响应,则应该选择异步通讯(异步调用)。

image-20251204153307080

image-20251204153334267

同步调用的方式我们已经学过了,之前的OpenFeign调用就是。但是:

  • 异步调用又该如何实现?
  • 哪些业务适合用异步调用来实现呢?

我们带着问题接着看,后面课程分为了两篇讲解

image-20251204154518854

基础篇

初识MQ

同步调用

余额支付功能为例分析同步调用的优缺点:

image-20251204160228785

目前我们采用的是基于OpenFeign的同步调用,也就是说业务执行流程是这样的:

  • 支付服务需要先调用用户服务完成余额扣减
  • 然后支付服务自己要更新支付流水单的状态
  • 然后支付服务调用交易服务,更新业务订单状态为已支付

三个步骤依次执行。

优势:

  • 时效性强,等待到结果后才返回。【只有知道了扣减余额的结果,才能更新支付状态,应该用同步调用

劣势

image-20251204160938997

  • 拓展性差:但支付完,需要修改交易订单的状态。但这与支付并不是强相关。这不是支付的核心业务,交易服务有需求还得要求我支付服务去干,不仅会增加支付服务中的不必要的代码、还更加耦合了,这可不得了,否者没完没了,后续需求可多了,。【发短信通知、加积分】

  • 性能下降:随着业务越加越多,调用链会越来越长,每次调用耗时会增加,而支付业务往往并发是很高的。

  • 级联失败:其他非核心业务挂了更不好了,增加耗时,甚至导致雪崩。由于我们是基于OpenFeign调用交易服务、通知服务。当交易服务、通知服务出现故障时,整个事务都会回滚,交易失败。

    这其实就是同步调用的级联失败问题。

异步调用

image-20251204163708050

同步调用中分为两个角色,调用者和提供者

提供者保留接口, 调用者通过http请求调用接口实现远程调用

image-20251204163944937

但是异步调用通常是基于消息通知的方式,包含三个角色:

  • 消息发送者:投递消息的人,就是原来的调用者
  • 消息接收者:接收和处理消息的人,就是原来的服务提供者
  • 消息代理:管理、暂存、转发消息,你可以把它理解成微信服务器

通常基于消息通知的方式。不是直接发消息,和微信聊天一样,其实是发给了微信服务器

有个代理就相当于外卖柜子。消费者啥时候有空,啥时候用,提供者给到了就干别的事儿就行。

image-20251204164431770

支付服务不再同步调用业务关联度低的服务,而是发送消息通知到Broker【代理】。

具备下列优势

  • 解除耦合,拓展性强【经典加一层】

  • 无需等待,性能好【广播+监听】

  • 故障隔离,下游服务故障不影响上游业务

  • 缓存消息,流量削峰填谷【321上链接。缓存消息,充分利用服务器资源

    ,减少异常】

具备下列劣势

  • 不能立即得到调用结果,时效性差【或者直接得不到结果

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

  • 业务安全依赖于Broker的可靠性【代理可靠性很重要】

MQ技术选型

MQ (MessageQueue),中文是消息队列【队列就是有个容器,消息对了就是存储消息的容器】,字面来看就是存放消息的队列。也就是异步调用中的Broker。【简单点就是存储和转发消息

RabbitMQ ActiveMQ RocketMQ Kafka
公司/社区 Rabbit Apache 阿里 Apache
开发语言 Erlang Java Java Scala&Java
协议支持 AMQP,XMPP,SMTP,STOMP OpenWire,STOMP,REST,XMPP,AMQP 自定义协议 自定义协议
可用性 一般
单机吞吐量 一般 非常高
消息延迟 微秒级 毫秒级 毫秒级 毫秒以内
消息可靠性 一般 一般
  • 公司/社区:大厂有大厂的优势,小厂有小厂的强项。一生只干一件事儿,这就叫专业。积极维护,社区活跃

  • 开发语言:Erlang语言【99%的任务直接用工具,不用学习语言】,面向并非的语言,而Java这是适配性广泛

  • 协议支持:收发信息的格式,就是协议。jms,自定义的协议,要求比较严格,不太适合于微服务,微服务与语言无关,不同微服务可能使用不同语言。

  • 可用性:健壮性,不能随便挂,支持分布式集群

  • 单机吞吐量:输出存储消息的数量【每秒10万左右(90%的公司已经够了,Q你会解决高并发问题吗,A公司并发量多少……)】【每秒上百万】

  • 消息延迟:从发消息到收消息的时间间隔。基于内存处理,速度非常快

  • 消息可靠性:消费者至少能消费一次。所以Kafka适合大数据场景。数据丢一两个也没问题,吞吐量高就完事儿了

  • 技术使用分布情况

    • 中小型企业RabbitMQ,
    • 大型企业有一些自研能力的RocketMQ,能解决其中的一些bug

RabbitMQ

安装部署

RabbitMQ是基于Erlang语言开发的开源消息通信中间件,官网地址

我们同样基于Docker来安装RabbitMQ,使用下面的命令即可:

docker run \
 -e RABBITMQ_DEFAULT_USER=itheima \
 -e RABBITMQ_DEFAULT_PASS=123321 \
 -v mq-plugins:/plugins \
 --name mq \
 --hostname mq \
 -p 15672:15672 \
 -p 5672:5672 \
 --network hm-net\
 -d \
 rabbitmq:3.8-management

当然,如果有现成的镜像更好,利用docker load命令加载

可以看到在安装命令中有两个映射的端口:

  • 15672:RabbitMQ提供的管理控制台的端口
  • 5672:RabbitMQ的消息发送处理接口

然后我们可以通过浏览器输入ip地址和端口地址信息信息去访问控制台。账号密码就是上面命令中的密码。

其中我们发现涉及到了RabbitMQ的整体架构及核心概念

  • virtual-host:虚拟主机,起到数据隔离的作用
  • publisher:消息发送者
  • consumer:消息的消费者
  • queue:队列,存储消息
  • exchange:交换机,负责路由消息

image-20251204202542585

队列进行存储,发送者发给交换机而不是直接给到队列。交换机会根据配置好的队列路由给队列【可以是多个队列】,在由队列发给消费者【可以是多个消费者】

image-20251204202627996

因为吞吐量很高,所以一个项目往往打不满,在企业中可能同时把多个项目部署在一套RabbitMQ中,不同项目的交换机和队列可能会产生冲突,这里就可以引入VirtualHost来隔离项目。

快速入门

需求:在RabbitMQ的控制台完成下列操作:

  • 新建队列hello.queue1hello.queue2
  • 向默认的amp.fanout交换机发送一条消息
  • 查看消息是否到达hello.queue1hello.queue2
  • 总结规律

我们可以看到默认已经有fanout的交换机。点击名称可以进入交换机

image-20251206155232559

新建队列的时候,把带星号必填的名字填上就可以了,

image-20251206155402020

我们可以再交换机publish message线上模拟发送消息

image-20251206155705863

我们发现发送消息了,但是没有路由。队列也没有获得信息。这是因为我们没有配置路由规则的话,会导致路由失败。

交换机没有存储消息的能力,如果路由失败了,消息也就直接丢失了

我们需要让交换机和队列之间产生关系【路由规则】。可以再交换机中的Bindings模块中绑定。【也可以在队列模块绑定交换机,只不过我们习惯用交换机绑定队列】

image-20251206160045523

在控制台中查看队列中消息的信息

image-20251206160147812

我们可以点击姓名进去,看到队列收到的消息

image-20251206160307425

消息发送的注意事项有哪些?

  • 交换机只能路由消息,无法存储消息
  • 交换机只会路由消息给与其绑定的队列,因此队列必须与交换机绑定

数据隔离

需求:在RabbitMQ的控制台完成下列操作:

  • 新建一个用户hmall
  • 为hmall用户创建一个virtual host
  • 测试不同virtual host之间的数据隔离现象

给不同的项目或业务创建不同的用户,根据用户进行数据隔离【数据隔离就是不同用户拥有不同的虚拟主机,即使交换机相同也是数据隔离的】

新建用户【可以角色类型,不同角色拥有不同的权限】,不在同一个虚拟主机中是看不到别人消息的。比如,看不到别人队列中的信息是get不了的

image-20251206162806858

用哪个用户账号创建的虚拟主机,默认是属于哪个账号的。【和管理员账号一样,可以登入登出】

image-20251206162937943

这样有了另外的账号,就可以为其创建交换机、队列,虚拟主机等资源类。

Java客户端

快速入门

将来我们开发业务功能的时候,肯定不会在控制台收发消息,而是应该基于编程的方式。由于RabbitMQ采用了AMQP协议,因此它具备跨语言的特性。任何语言只要遵循AMQP协议收发消息,都可以与RabbitMQ交互。并且RabbitMQ官方也提供了各种不同语言的客户端。

但是,RabbitMQ官方提供的Java客户端编码相对复杂,一般生产环境下我们更多会结合Spring来使用。而Spring的官方刚好基于RabbitMQ提供了这样一套消息收发的模板工具:SpringAMQP。并且还基于SpringBoot对其实现了自动装配,使用起来非常方便。

Spring Amqp的官方地址

image-20251206171613877

Spring AMQP提供了三个功能:

  • 自动声明队列、交换机及其绑定关系
  • 基于注解的监听器模式,异步接收消息
  • 封装了RabbitTemplate工具,用于发送消息

需求如下:【在快速入门中简化逻辑,直接把消息发给队列】

  • 利用控制台创建队列simple.queue
  • 在publisher服务中,利用SpringAMQP直接向simple.queue发送消息
  • 在consumer服务中,利用SpringAMQP编写消费者,监听simple.queue队列

image-20251206183133350

这里可以建个小demo,分为如下部分

  • mq-demo:父工程,管理项目依赖
  • publisher:消息的发送者
  • consumer:消息的消费者

我们实现的步骤如下【首先我们肯定需要一个队列,名字可以叫做simple.queue

  1. 引入spring-amqp依赖

    在父工程中引入spring-amqp依赖,这样publisher和consumer服务都可以使用【对于固定的东西,CV大法就行,也不容易出错】

    <?xml version="1.0" encoding="UTF-8"?>
    <project xmlns="http://maven.apache.org/POM/4.0.0"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
        <modelVersion>4.0.0</modelVersion>
    
        <groupId>cn.itcast.demo</groupId>
        <artifactId>mq-demo</artifactId>
        <version>1.0-SNAPSHOT</version>
        <modules>
            <module>publisher</module>
            <module>consumer</module>
        </modules>
        <packaging>pom</packaging>
    
        <parent>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-parent</artifactId>
            <version>2.7.12</version>
            <relativePath/>
        </parent>
    
        <properties>
            <maven.compiler.source>8</maven.compiler.source>
            <maven.compiler.target>8</maven.compiler.target>
        </properties>
    
        <dependencies>
            <dependency>
                <groupId>org.projectlombok</groupId>
                <artifactId>lombok</artifactId>
            </dependency>
            <!--AMQP依赖,包含RabbitMQ-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-amqp</artifactId>
            </dependency>
            <!--单元测试-->
            <dependency>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-starter-test</artifactId>
            </dependency>
        </dependencies>
    </project>
    
  2. 配置RabbitMQ服务端信息

    在每个微服务中引入MQ服务端信息,这样微服务才能连接到RabbitMQ

    spring:
      rabbitmq:
        host: 192.168.150.101 # 你的虚拟机IP
        port: 5672 # 端口
        virtual-host: /hmall # 虚拟主机
        username: hmall # 用户名
        password: 123 # 密码
    
  3. 发送消息

    SpringAMQP提供了RabbitTemplate工具类【***Template】,方便我们发送消息。

    publisher服务中编写测试类SpringAmqpTest,并利用RabbitTemplate实现消息发送:

    创建单元测试【选中启动类,alt+enter】

    image-20251206165839596

    package com.itheima.publisher.amqp;
    
    import org.junit.jupiter.api.Test;
    import org.springframework.amqp.rabbit.core.RabbitTemplate;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.test.context.SpringBootTest;
    
    @SpringBootTest
    public class SpringAmqpTest {
    
        @Autowired
        private RabbitTemplate rabbitTemplate;
    
        @Test
        public void testSimpleQueue() {
            // 队列名称
            String queueName = "simple.queue";
            // 消息
            String message = "hello, spring amqp!";
            // 发送消息
            rabbitTemplate.convertAndSend(queueName, message);
        }
    }
    
  4. 接收消息

    SpringAMQP提供声明式的消息监听,我们只需要通过注解在方法上声明要监听的队列名称,将来SpringAMQP就会把消息传递给当前方法:【类是普通的类,方法也是用过普通的方法,加上注解就不一样了】

    package com.itheima.consumer.listener;
    
    import org.springframework.amqp.rabbit.annotation.RabbitListener;
    import org.springframework.stereotype.Component;
    
    @Component
    public class SpringRabbitListener {
            // 利用RabbitListener来声明要监听的队列信息
        // 将来一旦监听的队列中有了消息,就会推送给当前服务,调用当前方法,处理消息。
        // 可以看到方法体中接收的就是消息体的内容
        @RabbitListener(queues = "simple.queue")
        public void listenSimpleQueueMessage(String msg) throws InterruptedException {
            System.out.println("spring 消费者接收到消息:【" + msg + "】");
        }
    }
    

然后可以进行测试运行了

SpringAMQP如何收发消息?

  • 引入spring-boot-starter-amqp依赖
  • 配置rabbitmq服务端信息
  • 利用RabbitTemplate发送消息
  • 利用@RabbitListener注解声明要监听的队列,监听消息

Work Queues

Work queues,任务模型。简单来说就是让多个消费者绑定到一个队列,共同消费队列中的消息。

image-20251206183149833

当消息处理比较耗时的时候,可能生产消息的速度会远远大于消息的消费速度。长此以往,消息就会堆积越来越多,无法及时处理。

此时就可以使用work 模型,多个消费者共同处理消息处理,消息处理的速度就能大大提高了。

我们对快速入门案例进行升级

模拟WorkQueue,实现一个队列绑定多个消费者

基本思路如下:

  1. 在RabbitMQ的控制台创建一个队列,名为work.queue
  2. 在publisher服务中定义测试方法,发送50条消息到work.queue
  3. 在consumer服务中定义两个消息监听者,都监听work.queue队列
  4. 消费者1每秒处理40条消息,消费者2每秒处理5条消息

在这之前我们需要先在控制台新建一个队列,名字可以位work.queue

  1. 这次我们循环发送,模拟大量消息堆积现象。

    在publisher服务中的SpringAmqpTest类中添加一个测试方法:

    /**
         * workQueue
         * 向队列中不停发送消息,模拟消息堆积。
         */
    @Test
    public void testWorkQueue() throws InterruptedException {
        // 队列名称
        String queueName = "simple.queue";
        // 消息
        String message = "hello, message_";
        for (int i = 0; i < 50; i++) {
            // 发送消息,每20毫秒发送一次,相当于每秒发送50条消息
            rabbitTemplate.convertAndSend(queueName, message + i);
            Thread.sleep(20);
        }
    }
    
  2. 消息接收

    要模拟多个消费者绑定同一个队列,我们在consumer服务的SpringRabbitListener中添加2个新的方法:【注意:这里写两个方法进行模拟的,在生产中一个实例写一个方法就行。然后部署多个实例。

    @RabbitListener(queues = "work.queue")
    public void listenWorkQueue1(String msg) throws InterruptedException {
        System.out.println("消费者1接收到消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(20);
    }
    
    @RabbitListener(queues = "work.queue")
    public void listenWorkQueue2(String msg) throws InterruptedException {
        System.err.println("消费者2........接收到消息:【" + msg + "】" + LocalTime.now());
        Thread.sleep(200);
    }
    

消费者消息推送限制

我们可以从控制台可以看出:可以看到消费者1和消费者2竟然每人消费了25条消息

这说明,消费者不会重复消费消息。默认情况下,RabbitMQ的会将消息依次轮询投递给绑定在队列上的每一个消费者。从这可以看出,可以增加消费者,解决消息堆积的问题。但这并没有考虑到消费者是否已经处理完消息,可能出现消息堆积。

如果一个处理的快,一个处理的慢,消息还会平均分配吗。默认还是平均分配的,如何联系到实际中系统资源不同,这样会造成负载不均衡的现象。我们这里可以设置运行,来解决这个问题。这样就负载均衡了。能者多劳

在spring中有一个简单的配置,可以解决这个问题。我们修改consumer服务的application.yml文件,添加配置:

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

Work模型的使用:

  • 多个消费者绑定到一个队列,可以加快消息处理速度
  • 同一条消息只会被一个消费者处理
  • 通过设置prefetch来控制消费者预取的消息数量,处理完一条再处理下一条,实现能者多劳

Fanout交换机

由于前面刚入门,我们没有考虑交换机,接下来我们可以学习一下交换机

交换机的作用主要是接收发送者发送的消息,并将消息路由到与其绑定的队列。

常见交换机的类型有以下三种:

  • Fanout:广播
  • Direct:定向
  • Topic:话题

image-20251206195829112

Fanout Exchange 会将接收到的消息路由到每一个跟其绑定的queue,所以也叫广播模式

广播,复制到n份了【Q:每个消费者都可以吗,如果一个队列绑定了多个消费者呢?A:要求每个队列1个消费者,一对多的话跟Work Queues类似了】。可以达到一个效果,发了一个消息,都能收到到消息了。需求比如支付成功后要干好多事儿,每个微服务可以创建自己的队列。这样就都会收到了

image-20251206195942136

案例:利用SpringAMQP演示FanoutExchange的使用

实现思路如下:【队列和交换机名字都是可以随意的,但尽量要有语义。记得选类型】

  • 在RabbitMQ控制台中,声明队列fanout.queue1fanout.queue2
  • 在RabbitMQ控制台中,声明交换机hmall.fanout,将两个队列与其绑定【交换机和队列记得绑定】
  • 在consumer服务中,编写两个消费者方法,分别监听fanout.queue1fanout.queue2
  • 在publisher中编写测试方法,向hmall.fanout发送消息

示例代码如下

在publisher服务的SpringAmqpTest类中添加测试方法:【发送消息,参数分别是:交互机名称、RoutingKey(暂时为空)、消息 】

@Test
public void testFanoutExchange() {
    // 交换机名称
    String exchangeName = "hmall.fanout";
    // 消息
    String message = "hello, everyone!";
    rabbitTemplate.convertAndSend(exchangeName, "", message);
}

在consumer服务的SpringRabbitListener中添加两个方法,作为消费者:

@RabbitListener(queues = "fanout.queue1")
public void listenFanoutQueue1(String msg) {
    System.out.println("消费者1接收到Fanout消息:【" + msg + "】");
}

@RabbitListener(queues = "fanout.queue2")
public void listenFanoutQueue2(String msg) {
    System.out.println("消费者2接收到Fanout消息:【" + msg + "】");
}

Direct交换机

有的需求是多变的,有的情况希望多个消费者都收到,有的情况希望部分消费者收到【比如用户取消订单,虽然依然要改变订单状态,但不用加积分和发短信了】

Direct Exchange 会将接收到的消息根据规则路由到指定的Queue,因此称为定向路由

  • 每一个Queue都与Exchange设置一个BindingKey【一个交换机和一个队列直接可以同时有多个BindingKey】
  • 发布者发送消息时,指定消息的RoutingKey
  • Exchange将消息路由到BindingKey与消息RoutingKey一致的队列【对应起来】

image-20251206203518353

对暗号,暗号也可以设置成多个,更像前端类选择器

案例:利用SpringAMQP演示DirectExchange的使用

需求如下:

  • 在RabbitMQ控制台中,声明队列direct.queue1direct.queue2

  • 在RabbitMQ控制台中,声明交换机hmall. direct ,将两个队列与其绑定

    image-20251206204317061

  • 在consumer服务中,编写两个消费者方法,分别监听direct.queue1direct.queue2

  • 在publisher中编写测试方法,利用不同的RoutingKey向hmall. direct发送消息

示例代码如下

在consumer服务的SpringRabbitListener中添加方法:

@RabbitListener(queues = "direct.queue1")
public void listenDirectQueue1(String msg) {
    System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
}

@RabbitListener(queues = "direct.queue2")
public void listenDirectQueue2(String msg) {
    System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
}

在publisher服务的SpringAmqpTest类中添加测试方法:

@Test
public void testSendDirectExchange() {
    // 交换机名称
    String exchangeName = "hmall.direct";
    // 消息
    String message = "红色警报!日本乱排核废水,导致海洋生物变异,惊现哥斯拉!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "red", message);
}

我们再切换为blue这个key:

@Test
public void testSendDirectExchange() {
    // 交换机名称
    String exchangeName = "hmall.direct";
    // 消息
    String message = "最新报道,哥斯拉是居民自治巨型气球,虚惊一场!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "blue", message);
}

描述下Direct交换机与Fanout交换机的差异?

  • Fanout交换机将消息路由给每一个与之绑定的队列
  • Direct交换机根据RoutingKey判断路由给哪个队列
  • 如果多个队列具有相同RoutingKey,则与Fanout功能类似

Topic交换机

TopicExchange与DirectExchange类似,区别在于routingKey可以是多个单词的列表,并且以.分割。

Queue与Exchange指定BindingKey时可以使用通配符:【拓展性更强】

  • #:代指0个或多个单词
  • *:代指一个单词

image-20251206210527226

案例:利用SpringAMQP演示DirectExchange的使用

需求如下:

  • 在RabbitMQ控制台中,声明队列topic.queue1topic.queue2
  • 在RabbitMQ控制台中,声明交换机hmall. topic ,将两个队列与其绑定
  • 在consumer服务中,编写两个消费者方法,分别监听topic.queue1topic.queue2
  • 在publisher中编写测试方法,利用不同的RoutingKey向hmall. topic发送消息

image-20251206210705131

新建和绑定关系

image-20251206210757523

示例代码如下

在publisher服务的SpringAmqpTest类中添加测试方法:

/**
 * topicExchange
 */
@Test
public void testSendTopicExchange() {
    // 交换机名称
    String exchangeName = "hmall.topic";
    // 消息
    String message = "喜报!孙悟空大战哥斯拉,胜!";
    // 发送消息
    rabbitTemplate.convertAndSend(exchangeName, "china.news", message);
}

在consumer服务的SpringRabbitListener中添加方法:

@RabbitListener(queues = "topic.queue1")
public void listenTopicQueue1(String msg){
    System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
}

@RabbitListener(queues = "topic.queue2")
public void listenTopicQueue2(String msg){
    System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
}

描述下Direct交换机与Topic交换机的差异?

  • Topic交换机接收的消息RoutingKey可以是多个单词,以 . 分割
  • Topic交换机与队列绑定时的bindingKey可以指定通配符
  • #:代表0个或多个词
  • *:代表1个词

基于Bean声明队列交换机

我们已经学rabbitMQ中基本的API和一些场景的交换机,我们在业务中完成基本的消息收发是没问题了。我们还可以补充一下细节知识。前面,我们都是在控制台完成的队列和交换机的生命。这种手动的方式很容易敲错。将来生成环境和测试环境转换的时候可能会很麻烦。所以,在实际开发中不推荐这种手动创建的。我们推荐用代码自动创建。

因此推荐的做法是由程序启动时检查队列和交换机是否存在,如果不存在自动创建。

如果之前已经创建了,我们可以先把之前的手动删掉

SpringAMQP提供了几个类,用来声明队列、交换机及其绑定关系:

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

SpringAMQP提供了一个Queue类,用来创建队列:

image-20251207144933875

SpringAMQP还提供了一个Exchange接口,来表示所有不同类型的交换机:

image-20251207144219209

我们可以自己创建队列和交换机,不过SpringAMQP还提供了ExchangeBuilder来简化这个过程

image-20251207145050779

而在绑定队列和交换机时,则需要使用BindingBuilder来创建Binding对象:

image-20251207145138311

综上,我们创建交换机和队列的方式有两种,直接new或者通过Builder

image-20251207145355147

创建队列的时候选durable持久化,不容易造成信息丢失。默认就是持久的

实例代码如下:

  • fanout实例

    在consumer中创建一个类,声明队列和交换机:

    package com.itheima.consumer.config;
    
    import org.springframework.amqp.core.Binding;
    import org.springframework.amqp.core.BindingBuilder;
    import org.springframework.amqp.core.FanoutExchange;
    import org.springframework.amqp.core.Queue;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class FanoutConfig {
        /**
         * 声明交换机
         * @return Fanout类型交换机
         */
        @Bean
        public FanoutExchange fanoutExchange(){
            return new FanoutExchange("hmall.fanout");
        }
    
        /**
         * 第1个队列
         */
        @Bean
        public Queue fanoutQueue1(){
            return new Queue("fanout.queue1");
        }
    
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue1(Queue fanoutQueue1, FanoutExchange fanoutExchange){
            return BindingBuilder.bind(fanoutQueue1).to(fanoutExchange);
        }
    
        /**
         * 第2个队列
         */
        @Bean
        public Queue fanoutQueue2(){
            return new Queue("fanout.queue2");
        }
    
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue2(Queue fanoutQueue2, FanoutExchange fanoutExchange){
            //bind队列to交换机,反过来也行,但习惯上这样
            return BindingBuilder.bind(fanoutQueue2).to(fanoutExchange);
        }
    }
    
  • direct示例

    direct模式由于要绑定多个KEY,会非常麻烦,每一个Key都要编写一个binding:

    package com.itheima.consumer.config;
    
    import org.springframework.amqp.core.*;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    public class DirectConfig {
    
        /**
         * 声明交换机
         * @return Direct类型交换机
         */
        @Bean
        public DirectExchange directExchange(){
            return ExchangeBuilder.directExchange("hmall.direct").build();
        }
    
        /**
         * 第1个队列
         */
        @Bean
        public Queue directQueue1(){
            return new Queue("direct.queue1");
        }
    
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue1WithRed(Queue directQueue1, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue1).to(directExchange).with("red");
        }
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue1WithBlue(Queue directQueue1, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue1).to(directExchange).with("blue");
        }
    
        /**
         * 第2个队列
         */
        @Bean
        public Queue directQueue2(){
            return new Queue("direct.queue2");
        }
    
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue2WithRed(Queue directQueue2, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue2).to(directExchange).with("red");
        }
        /**
         * 绑定队列和交换机
         */
        @Bean
        public Binding bindingQueue2WithYellow(Queue directQueue2, DirectExchange directExchange){
            return BindingBuilder.bind(directQueue2).to(directExchange).with("yellow");
        }
    }
    

基于注解声明队列交换机

可以看到direct交换机时的绑定会比较麻烦。每多一个bindingkey,就会多一个bean

SpringAMQP还提供了基于@RabbitListener注解来声明队列和交换机的方式:

示例代码如下:【注解套注解】

  • Direct示例

    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "direct.queue1"),
        exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
        key = {"red", "blue"}
    ))
    public void listenDirectQueue1(String msg){
        System.out.println("消费者1接收到direct.queue1的消息:【" + msg + "】");
    }
    
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "direct.queue2"),
        exchange = @Exchange(name = "hmall.direct", type = ExchangeTypes.DIRECT),
        key = {"red", "yellow"}
    ))
    public void listenDirectQueue2(String msg){
        System.out.println("消费者2接收到direct.queue2的消息:【" + msg + "】");
    }
    
  • Topic示例

    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "topic.queue1"),
        exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
        key = "china.#"
    ))
    public void listenTopicQueue1(String msg){
        System.out.println("消费者1接收到topic.queue1的消息:【" + msg + "】");
    }
    
    @RabbitListener(bindings = @QueueBinding(
        value = @Queue(name = "topic.queue2"),
        exchange = @Exchange(name = "hmall.topic", type = ExchangeTypes.TOPIC),
        key = "#.news"
    ))
    public void listenTopicQueue2(String msg){
        System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
    }
    

消息转换器

我们通关进一步观察convertAndSend方法的参数可以看出,第三个消息参数时Object类型的。可以传任意对象。发消息是把消息传给Rabbit服务器,是通过网络传输的,网络传输是通过字节传输的,而不是直接才能对象,因此在发送的时候需要转换,也对应了方法convert。这个转换就是消息转换器来转的

案例:测试利用SpringAMQP发送对象类型的消息

  • 声明一个队列,名为object.queue
  • 编写单元测试,向队列中直接发送一条消息,消息类型为Map
  • 在控制台查看消息,总结你能发现的问题
// 准备消息
Map<String,Object> msg = new HashMap<>(); 
msg.put("name", "Jack");
msg.put("age", 21);

我们在控制台发现,队列收到了一串base64编码。通过观察源码发现,底层默认采用了jdk自带的序列化方式ObjectOutputStream。

Spring的对消息对象的处理是由org.springframework.amqp.support.converter.MessageConverter来处理的。而默认实现是SimpleMessageConverter,基于JDK的ObjectOutputStream完成序列化。
但这种方式存在下列问题:

  • JDK的序列化有安全风险【数据反序列化时容易被代码注入】
  • JDK序列化的消息太大【这里短短的信息,就生成了一长串的base64编码】
  • JDK序列化的消息可读性差【相比json,base64不是人看的】

那我们建议采用JSON序列化代替默认的JDK序列化,要做两件事情:

  1. 在publisher和consumer中都要引入jackson依赖:

    <dependency> 				
        <groupId>com.fasterxml.jackson.core</groupId>	 
        <artifactId>jackson-databind</artifactId>
    </dependency>
    
  2. 配置消息转换器,在publisherconsumer两个服务的启动类中添加一个Bean即可:【消息转换器注入到rabbittemplate】【接收也需要消息转换器】

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

    消息转换器中添加的messageId可以便于我们将来做幂等性判断。

最后我们还需要接收

我们在consumer服务中定义一个新的消费者,publisher是用Map发送,那么消费者也一定要用Map接收,格式如下:【发的时候用什么发,接的时候就用什么接收】【这样,我们接到的消息和收到的消息就一样了】

@RabbitListener(queues = "object.queue")
public void listenSimpleQueueMessage(Map<String, Object> msg) throws InterruptedException {
    System.out.println("消费者接收到object.queue消息:【" + msg + "】");
}

业务改造

需求:改造余额支付功能,不再同步调用交易服务的OpenFeign接口,而是采用异步MQ通知交易服务更新订单状态。

我们这里后期可能要按需调用,所以不建议用Fanout交换机。我们这里可以选direct交换机。

我们具体改造步骤如下:

  1. 引入mq依赖【消费者和提供者服务都要引入】

    <!--AMQP依赖,包含RabbitMQ-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    
  2. 配置RabbitMQ【消费者和提供者服务都要引入】

    spring:
      rabbitmq:
        host: 192.168.150.101 # 你的虚拟机IP
        port: 5672 # 端口
        virtual-host: /hmall # 虚拟主机
        username: hmall # 用户名
        password: 123 # 密码
    
  3. 配置消息转换器中【因为都要用到,可以放到common模块中】

    package com.hmall.common.config;
    
    import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
    import org.springframework.amqp.support.converter.MessageConverter;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    
    @Configuration
    @ConditionalOnClass(RabbitTemplate.class) //防止在不需要Rabbit的模块中也生效,造成报错
    public class MqConfig {
    
        @Bean
        public MessageConverter messageConverter(){
            return new Jackson2JsonMessageConverter();
        }
    }
    

    记得添加扫描配置

    org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
      com.hmall.common.config.MyBatisConfig,\
      com.hmall.common.config.MvcConfig,\
      com.hmall.common.config.MqConfig,\
      com.hmall.common.config.JsonConfig
    
  4. 编写消费者监听器

    package com.hmall.trade.listener;
    
    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 PayStatusListener {
    
        private final IOrderService orderService;
    
        @RabbitListener(bindings = @QueueBinding(
                value = @Queue(name = "trade.pay.success.queue", durable = "true"),
                exchange = @Exchange(name = "pay.direct"),
                key = "pay.success"
        ))
        public void listenPaySuccess(Long orderId){
            //通过参数传递,然后调用之前调用的逻辑
            orderService.markOrderPaySuccess(orderId);
        }
    }
    
  5. 编写发送逻辑

    修改pay-service服务下的com.hmall.pay.service.impl.PayOrderServiceImpl类中的tryPayOrderByBalance方法:

    private final RabbitTemplate rabbitTemplate;
    
    @Override
    @Transactional
    public void tryPayOrderByBalance(PayOrderDTO payOrderDTO) {
        // 1.查询支付单
        PayOrder po = getById(payOrderDTO.getId());
        // 2.判断状态
        if(!PayStatus.WAIT_BUYER_PAY.equalsValue(po.getStatus())){
            // 订单不是未支付,状态异常
            throw new BizIllegalException("交易已支付或关闭!");
        }
        // 3.尝试扣减余额
        userClient.deductMoney(payOrderDTO.getPw(), po.getAmount());
        // 4.修改支付单状态
        boolean success = markPayOrderSuccess(payOrderDTO.getId(), LocalDateTime.now());
        if (!success) {
            throw new BizIllegalException("交易已支付或关闭!");
        }
        // 5.修改订单状态
        // tradeClient.markOrderPaySuccess(po.getBizOrderNo());
        try {
            rabbitTemplate.convertAndSend("pay.direct", "pay.success", po.getBizOrderNo());
        } catch (Exception e) {
            log.error("支付成功的消息发送失败,支付单id:{}, 交易单id:{}", po.getId(), po.getBizOrderNo(), e);
        }
    }
    

try一下,保证对原有业务没有影响,不管成功还是失败,如果失败了,我们也有兜底的失败,在高级篇可以看到

高级篇

通过前面的学习,我们已经可以应多绝大部分的工作内容了。但是在一些特殊业务当中,我们不仅要做消息的收发,而且要保证消息收发的高可靠性。那要满足高可靠性,就要用到RabbitMQ中的高级的功能了

案例引入:

image-20251208212702205

如果消息丢失了,那么订单状态没有改变,影响用户体验。我们应该保证消息被正确的处理。

我们先分析影响丢失可能的原因

  • 网络故障,发了,没到
  • MQ故障
  • 消费者挂了,没处理

没有办法可以做到百分之百的可靠性,因此,我们还需要做一些兜底的方案【延迟消息的知识】。

下面我们分别从四个方面介绍

  • 发送者的可靠性
  • MQ的可靠性
  • 消费者的可靠性
  • 延迟消息

发送者的可靠性

RabbitMQ提供了两种方式来保证发送者消息的可靠性

  • 发送者重连
  • 发送者确认

发送者重连

有的时候由于网络波动,可能会出现发送者连接MQ失败的情况。通过配置我们可以开启连接失败后的重连机制:【MQ连不上,这个机制是默认关闭的】【等一段时间再重来。(与计算机网络中重连逻辑相似)】

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

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

我们可以手动停止mq服务,模拟网络错误,进行测试。

注意

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

发送者确认

image-20251208215642696

SpringAMQP提供了Publisher Confirm和Publisher Return两种确认机制。开启确机制认后,当发送者发送消息给MQ后,MQ会返回确认结果给发送者。返回的结果有以下几种情况:【发完还可能出现错误。其实MQ会返回确认结果发给发送者。状态有多种】

  • 消息投递到了MQ,但是路由失败。此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功【ACK表示不是发送者和MQ的原因,而是人为的原因】
  • 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功【临时消息保存到内存中,而不用保存到磁盘中】
  • 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功【持久化避免服务宕机的影响】
  • 其它情况都会返回NACK,告知投递失败

SpringAMQP实现发送者确认有如下步骤

  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异步回调返回回执【设置回调函数,返回结果后进行异步处理】

    一般我们推荐使用correlated,回调机制。

  2. 定义ReturnCallback

    每个RabbitTemplate只能配置一个ReturnCallback,因此我们可以在配置类中统一设置。我们在publisher模块定义一个配置类:【@PostConstruct在rabbitTemplate成员变量注入以后在执行,并且指挥在bean创建中执行一次】【回调打印返回的信息】

    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. 定义ConfirmCallback

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

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

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

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

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

    @Test
    void testPublisherConfirm() {
        // 1.创建CorrelationData。这里可以指定id。
        CorrelationData cd = new CorrelationData();
        // 2.给Future添加ConfirmCallback
        cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
            @Override
            public void onFailure(Throwable ex) {
                // 2.1.Future发生异常时的处理逻辑,基本不会触发【处理future过程中出现的异常,一般内部会try catch】
                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.发送消息
        rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
    }
    

    注意

    • 这里不指定也可以,无参构造函数里也会使用UUID赋值给这个id的。也可以指定id【这里用了一个最常见的id生成方法】

      CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
      
    • 3.12版本没有addCallback方法了,没有addCallBack需要改用whenComplete

    执行结果如下:

    asynccode

    可以看到,由于传递的RoutingKey是错误的,路由失败后,触发了return callback,同时也收到了ack。

    当我们修改为正确的RoutingKey以后,就不会触发return callback了,只收到ack。

    而如果连交换机都是错误的,则只会收到nack。

补充定义ReturnCallback和ConfirmCallback的作用和关系

  1. ConfirmCallback(生产者确认回调)

    • 作用:确认消息是否成功到达Exchange(交换机)

    • 触发时机

      • 成功:消息被交换机接收 → 触发 ack

      • 失败:消息未被交换机接收(如:交换机不存在、权限不足等)→ 触发 nack

      • 关注点消息到交换机的可靠性

      // ConfirmCallback 处理的消息流转阶段:
      Publisher → Exchange ✓ 或 ✗
      
  2. ReturnCallback(返回消息回调)

    • 作用:确认消息是否成功从Exchange路由到Queue(队列)
    • 触发时机
      • 失败:消息到达交换机,但无法路由到任何队列(如:路由键不匹配、队列不存在等)
      • 不触发:消息成功路由到队列
    • 关注点交换机到队列的路由可靠性
    // ReturnCallback 处理的消息流转阶段:
    Exchange → Queue ✓ 或 ✗
    
  3. 两者之间的关系

    • 消息投递的两个阶段

      Publisher --Confirm--> Exchange --Return--> Queue
             ↑                        ↑
      ConfirmCallback         ReturnCallback
      
    • 组合使用场景

      • 情况1:完全成功

        消息 → Exchange ✓ → Queue ✓
        ConfirmCallback: onSuccess(ack)
        ReturnCallback: 不触发
        
      • 情况2:Exchange接收失败

        消息 → Exchange ✗
        ConfirmCallback: onSuccess(nack) 或 onFailure
        ReturnCallback: 不触发
        
      • 情况3:路由失败

        消息 → Exchange ✓ → Queue ✗
        ConfirmCallback: onSuccess(ack)
        ReturnCallback: triggered
        

注意

开启生产者确认比较消耗MQ性能,一般不建议开启。而且大家思考一下触发确认的几种情况:【对于nack消息可以有限次数重试,依然失败则记录异常消息】

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

MQ的可靠性

发到了MQ并不代表消息就安全了,MQ本身也可能把消息弄丢。

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

  • 一旦MQ宕机,内存中的消息会丢失

  • 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞。【当队列被占满了,会把之前的消息存到磁盘,但写的比较慢,在这个过程中不可避免的造成消息丢失。写的时候效率低,未满的时候效率较高,呈现出波浪线的效果。】

    image-20251209171419178

数据持久化

我们可以从存储方式入手。

RabbitMQ实现数据持久化包括3个方面:【这三个代码生成或发送默认是持久化】

  • 交换机持久化
  • 队列持久化
  • 消息持久化【重启了依然还在】

可以用如下代码分别对比消息持久化和非持久化的不认同效果【想要发非持久化的消息,需要直接构建消息】

@Test
void testSentMessage() {
    // 1.自定义构建消息
    Message message = MessageBuilder
            .withBody("hello, SpringAMQP".getBytes(StandardCharsets.UTF_8))
            .setDeliveryMode(MessageDeliveryMode.PERSISTENT)
            .build();
    // 2.发送消息
    for (int i = 0; i < 1000000; i++) {
        rabbitTemplate.convertAndSend("simple.queue", message);
    }
}

Spring AMQP默认就是持久的,我们不需要做额外的处理

Lazy Queue

数据持久化确实可以解决问题,但每条消息处理的耗时增加了,这也导致了整体并发能力有所下降。Lazy Queue可以更好的解决。

从RabbitMQ的3.6.0版本开始,就增加了Lazy Queue的概念,也就是惰性队列。
惰性队列的特征如下:

  • 接收到消息后直接存入磁盘,不再存储到内存【不会出现宕机,造成消息丢失】【也不容易阻塞了】
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(可以提前缓存部分消息到内存,最多2048条)【进行了优化】

在3.12版本后,所有队列都是Lazy Queue模式,无法更改。

如果使用的是之前版本。只需要在声明队列时,指定x-queue-mode属性为lazy即可

  • 我们可以在控制台创建。选择的时候点Lazy mode

    image-20251209183250569

  • 更多用到是代码的方式。

    • Bean方式

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

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

      这里是通过QueueBuilderlazy()函数配置Lazy模式,可以通过底层源码看出

    • 注解方式

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

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

当然,对于已经存在的队列,也可以配置为lazy模式,但是要通过设置policy实现。

可以基于命令行设置policy:【在docker容器中执行】

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,即可添加配置

image-20251209184020164

消费者的可靠性

剩下就剩消费者这一环了。

下面从三个角度说

消费者确认机制

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

  • ack:成功处理消息,RabbitMQ从队列中删除该消息【不处理就返回】
  • nack:消息处理失败,RabbitMQ需要再次投递消息【一直重试】
  • reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息【为什么回拒绝呢?发现消息内容是有问题的,比如格式不是json的、不符合业务要求的内容】

image-20251214195045449

那上面监听的代码逻辑还要自己写吗,其实AMQP已经写好了,我们直接配置。

SpringAMQP已经实现了消息确认功能。并允许我们通过配置文件选择ACK处理方式,有三种方式:

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用【处理之前就响应】

  • manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活【麻烦,存在业务入侵】

  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 【auto太行了】

    当业务出现异常时,根据异常判断返回不同结果:

    • 如果是业务异常,会自动返回nack
    • 如果是消息处理或校验异常,自动返回reject

在消费者进行配置。

spring:
  rabbitmq:
    listener:
      simple:
        acknowledge-mode: auto # 自动ack

因此,在编写消息处理代码是不要随意抛出异常,要考虑到排除异常的类型

失败重试机制

nack后一直冲突投递消息,可靠性大大增强了,但是在极端情况下也会出现问题。

比如消费者长时间无法从故障中恢复,每次重新投递时产生异常,然后再次把消息入队到mq。mq再次投递给消费者。两方来回折腾,这样不仅会给mq产生压力,也会给服务端产生压力。

AMQP给我们提供了解决方案。

SpringAMQP提供了消费者失败重试机制,在消费者出现异常时利用本地重试,而不是无限的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

在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现:【我们可以改变默认实现方式】【推荐使用后两种都可以,按照需求选择】

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式【重试三次消息直接没了。这是默认策略,可靠性下降】
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机【然后通过交换机,可以告诉开发者】

image-20251214203158918

具体的代码如何实现配置呢?【以RepublishMessageRecoverer为例】

将失败处理策略改为RepublishMessageRecoverer:

  1. 首先,定义接收失败消息的交换机、队列及其绑定关系,此处略:
  2. 然后,定义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;

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

业务幂等性

上面的工作基本上能够确保,消息发送给消费者了。但是之前给出的方案大多是确认机制和重试机制。在这种机制当中,会出现消费者对同一个消息重复处理的可能性。比如消费者拿到消息去处理,业务也全部执行成功了,spring回帮我发送ack,如果此时出现网络故障,导致消费者和MQ直接的连接断开,ack没发出去,此时MQ发现连接断开了,认为消费者宕机了,一旦网络重新恢复,势必重新把消息投递给消费者。这就导致了重复消费消息的问题。可能导致重复扣减库存的情况。

我们可以用业务幂等性来处理这个问题

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

这样,不管执行多少次,都不会有影响。

如何实现呢?

image-20251214212820409

有的业务是天生幂等的。因此我们针对非幂等的业务即可。

这里给出两种方案:

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

唯一消息ID

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

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

我们该如何给消息添加唯一ID呢?

其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。【id发送的时候,放到消息头中处理。可以在消息转换器中设置】【可以在控制台中看出消息id,观察设置是否生效】

以Jackson的消息转换器为例:

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

消费者也要取出来id【在监听器中接收Message对象,然后通过getMessageProperties().getMessageId()方法获取,然后在进行逻辑判断】

还存在一些问题,现在还要处理与业务无关东西,比如要存id到数据库中,比较id是否存在,如果不存在还要写到数据库中,这些操作其实是跟原本业务无关的健壮性的判断操作,造成了业务侵入的问题,因为涉及到数据库的查询写入操作,也会影响业务本身原有的性能。所以不建议使用这个方式。

业务判断

不是所有业务都适合这种方式,不适合的就要用第一种。

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

image-20251214220116252

如果用户下单后发出的消息延迟比较大,用户又退款后订单状态改为退款,已支付消息来到后,就会造成退款状态被覆盖了

image-20251214220228035

根据业务逻辑进行判断,是否是未支付,如果不是,说明不合理【我们可以直接编写业务代码即可】

如何保证支付服务与交易服务之间的订单状态一致性?【用一个具体的面试题回顾可靠性的问题】

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

只要网络不出故障,基本上发的消息一定能够投递成功,但是,正所谓天有不测风云。万一出现了网络的故障,那怎么办?会有兜底方案

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

延迟消息

现在假设有不可抗的因素导致消息通知没有成功,现在也有不少的兜底方案,其中有一种常见的就是延迟消息

指定时间,在指定事件后才能收到消息

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

如果支付服务和交易服务之间出现故障,不管有没有支付(取消下单)都是有问题的。

  • 如果支付了,交易服务没改支付状态,造成状态不一致
  • 如果没支付(比如取消了)这里显示未支付,看似对,但是商品服务中库存数是减掉的,也就是说没有释放商品,别人也买不了,这也是不行的,比如火车票。

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

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

因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存

例如,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。【在生活中可以想到,我们在12306中下单后回有个付款倒计时,支付超时取消。】

但问题来了:如何才能准确的实现在下单后第30分钟去检查支付状态呢?

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

如果立即支付,那就是支付服务直接给支付成功的消息了,这里延迟是,如果迟迟没有消息,在一定时间后去支付服务中查询结果【建设一段时间后网络是好的】

在RabbitMQ中实现延迟消息也有两种方案:【下面我们将进行对比】

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

其实在rabbitMQ中默认是不支持延迟消息的。这里的两种实现方案都是基于RabbitMQ中的功能拓展出来的。模拟延迟消息的效果

死信交换机

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

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

如果队列通过dead-letter-exchange属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)。【成为死信后默认就被丢了,但是可以指定死信交换机,死信就会被自动的投递到这个交换机中。】

死信交换机有什么作用呢?

  1. 收集那些因处理失败而被拒绝的消息
  2. 收集那些因队列满了而被拒绝的消息
  3. 收集因TTL(有效期)到期的消息

前面两种作用场景可以看做是把死信交换机当做一种消息处理的最终兜底方案,与消费者重试时提到的RepublishMessageRecoverer作用类似。

而最后一种场景,大家设想一下这样的场景:

如图,有一组绑定的交换机(nomal.direct)和队列(nomal.queue)。但是nomal.queue没有消费者监听,而是设定了死信交换机dlx.direct,而队列dlx.queue则与死信交换机绑定,RoutingKey是blue:

image-20251216170019299

中间这个队列(nomal.queue)没有消费者,等到时间后回投递到死信队列中。【遇事不决加一层】

注意:尽管这里的nomal.direct不需要RoutingKey,但是当消息变为死信并投递到死信交换机时,会沿用之前的RoutingKey,这样dlx.direct才能正确路由消息。【两个bandingkey要一致

消息肯定会被投递到nomal.queue之后,由于没有消费者,因此消息无人消费。30秒之后,消息的有效期到期,成为死信。

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

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

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

实现示例如下:

  • 在消费者中创建死信交换机和队列

    @RabbitListener(bindings = @QueueBinding(
            value = @Queue(name = "dlx.queue", durable = "true"),
            exchange = @Exchange(name = "dlx.direct", type = ExchangeTypes.DIRECT),
            key = {"hi"}
    ))
    public void listenDlxQueue(String message){
        log.info("消费者监听到dlx.queue的消息:【{}】", message);
    }
    
  • 然后需要定义普通交换机和普通队列。注意:这个时候就不能直接向上面那样定义了(注解),否则消费者就直接出来了。因此我们需要用传统的方式去定义(Bean)【记得绑定死信交换机】

    @Bean
    public DirectExchang normalExchange(){
        return new DirectExchang("normal.fanout");
    }
    
    @Bean
    public Queue normalQueue(){
        return QueueBuilder
            .durable("normal.queue")
            .deadLetterExchange("dlx.direct") //绑定死信交换机
            .build();
    }
    
    @Bean
    public Binding normalExchangeBinding(Queue normalQueue, DirectExchange normalExchange){
        return BindingBuilder.bind(normalQueue1).to(normalExchange).with("hi");
    }
    
  • 然后在生产者中编写消息发送代码

    可以用自定义message的方式设置过期时间,当然也可以用其他的方式。因为自定义message需要自己定义消息转换格式,而我们统一要用json转换。所有这里用其他的方式发带过期时间的消息

    当消息转换成message对象以后还可以进一步加工

    @Test
    void testSendDelayMessage() {
        rabbitTemplate.convertAndSend(
            "normal.direct",  // exchange
            "hi1",            // routingKey
            "hello",          // message
            new MessagePostProcessor() {
                @Override
                public Message postProcessMessage(Message message) throws AmqpException {
                    message.getMessageProperties().setExpiration("10000");
                    return message;
                }
            }
        );
    }
    

    因为接口中只有一个类,所以可以用lambda表达式简化

    @Test
    void testSendDelayMessage() {
        rabbitTemplate.convertAndSend(
            "normal.direct",  // exchange
            "hi1",            // routingKey
            "hello",          // message
            message -> {
                message.getMessageProperties().setExpiration("10000");
                return message;
            }
        );
    }
    

延迟消息插件

其实上面的实现延迟消息的方式不是官方提供的,是开发人员在使用死信交换机中摸索出来的。所以使用起来比较繁琐,要定义很多交换机队列和辅助的绑定关系。于是,就有大牛在社区开发的插件。官方文档说明

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

image-20251216202019568

在使用之前要安装插件插件下载地址

  • 因为我们是基于Docker安装,所以需要先查看RabbitMQ的插件目录对应的数据卷。

    docker volume ls #查找数据卷。在创建容器的时候就已经创建数据卷了
    docker volume inspect mq-plugins
    

    然后可以看到数据卷挂载的地址。我们直接cd到这个地址

  • 在cd地址后回发现到已经有很多插件了,我们直接把现在的地址放进去。然后输入命令生效【mq是容器名,以直接的容器名为准,】

    docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
    

实现过程如下

  • 交换机队列的定义有如下两种方式

    • 用注解的方式

      // 延迟消息监听器
      @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
      @Bean
      public DirectExchange delayExchange() {
          return ExchangeBuilder
                  .directExchange("delay.direct")
                  .delayed() // 底层就是设置delay属性为true
                  .durable(true) // 持久化
                  .build();
      }
      
  • 发送消息时需要通过消息头x-delay来设置过期时间:

    @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 {
                // 添加延迟消息属性(延迟5000毫秒)
                message.getMessageProperties().setDelay(5000);
                return message;
            }
        });
    }
    

注意

计时是使用时钟的,依赖cpu,是个cpu密集型的任务,因此同一时刻是需要大量任务要计时的话,这样会给cpu带来非常大的压力,因此我们在使用延迟消息的时候,要尽量避免同一时刻有大量的延迟消息,不要把延迟时间设置的太长。

取消超时订单

业务回顾

image-20251217210843201

模拟支付服务没办法发送消息的场景。如果到达时间就直接查询一下支付状态。是一种延迟任务。因此在下单的那一刻发送延迟消息。

image-20251217210908623

先查一下本地是不是已支付,如果是已支付,说明已经改了,就不用做什么了,如果是未支付,那么就去支付服务查询一下,如果未支付因为这到超时时间仍未支付,将要结束订单,如果已支付,就修改状态为已支付。

接下来可以一步一步的完成

  1. 首先我们收发都是在交易服务完成的,那我可以把收发过程中要用到的交换机队列和buildingkey都写成常量【可以是个接口】,这样就不用临时去写了,防止写错。

    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";
    }
    
  2. 另外记得引入AMQP依赖和配置

  3. 然后同样在交易服务中扣减库存之后的编写发送延迟消息逻辑。

    // 5.发送延迟消息,检测订单支付状态
    rabbitTemplate.convertAndSend(
            MQConstants.DELAY_EXCHANGE_NAME,
            MQConstants.DELAY_ORDER_KEY,
            order.getId(), //传订单id就可以了
            message -> {
                message.getMessageProperties().setDelay(10000);//十秒方便测试
                return message;
            }
    );
    
  4. 在后面的逻辑中涉及到了查询支付服务的状态,所有需要改造支付业务,增加查询接口,并提供对应的FeignClient

    • PayController对应的查询接口实现
    • PayOrderDTO:支付单的数据传输实体
    • PayClient:支付系统的Feign客户端
    • PayClientFallback:支付系统的fallback逻辑

    pay-service模块的PayController中实现该接口:

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

    PayOrderDTO代码如下:

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

    PayClientFallback代码如下:

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

    PayClient代码如下:

    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);
    }
    
  5. 编写监听器

    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;
    
    @RequiredArgsConstructor
    public class OrderDelayMessageListenter {
        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 {
                //4.2. 未支付,取消订单,恢复库存【按具体情况实现】
                orderService.cancelOrder(orderId);
            }
        }
    }
    

补充:怎么库存如何恢复,用回滚吗。如何取消订单

  • 点单取消的核心是先校验状态→再原子化执行(改订单状态 + 恢复库存 + 退款)→最后通知和日志,全程保证数据一致性;

  • 恢复库存的核心是精准(按 SKU 和数量)、安全(幂等 + 原子性)、可追溯(日志),区分 “可售 / 锁定” 库存,避免数据错乱;

posted @ 2025-12-04 20:44  韩熙隐ario  阅读(57)  评论(0)    收藏  举报