三、RocketMQ_18(笔记)

文章目录

RocketMQ 概述

一、什么是中间件

1、中间件

举个例子,就像是你在超市里买东西,你需要排队付款。如果超市里只有一个收银员,那么你需要等待这个收银员把前面的顾客都处理完才能轮到你。但是如果超市里有多个收银员,那么你就可以选择其中一个收银员排队付款,这样你就可以更快地付款,而不需要等待所有收银员都处理完前面的顾客。这就像是中间件在多个应用程序之间传递消息,帮助它们更快地协调数据和服务。

2、消息中间件

a.消息中间件(Message Queue,MQ)是基于队列与消息传递技术,在网络环境中为应用系统提供同步或异步可靠的消息传输的支撑性软件系统

b.消息中间件是一种用于在多个应用程序之间传递消息的软件系统。它可以帮助应用程序之间协调数据和服务提高系统的可扩展性和容错性。

举个例子来说,就像是你在一个公司里工作,你需要和其他部门的同事协作完成项目。如果你们之间没有一个沟通的桥梁,那么你们就需要通过人工传递信息,这样会很费时间和精力。但是如果公司里有一个消息中间件系统,你就可以通过这个系统向其他部门的同事发送消息,他们也可以通过这个系统向你发送消息。这样你们之间就有了一个沟通的桥梁,可以更快地协调数据和服务,提高工作效率。

消息中间件是在分布式系统中完成消息的发送和接收的基础工具。消息中间件也可以称消息队列,是指用高效可靠的消息传递机制进行与平台无关的数据交流,并基于数据通信来进行分布式系统的集成。通过提供消息传递和消息队列模型,可以在分布式环境下扩展进程的通信。

image-20230821115324056

RPC是远程过程调用(Remote Procedure Call)的缩写形式。SAP系统RPC调用的原理其实很简单,有一些类似于三层构架的C/S系统,第三方的客户程序通过接口调用SAP内部的标准或自定义函数,获得函数返回的数据进行处理后显示或打印。

二、消息中间件的应用场景

应用解耦

image-20230821115410072

通常的写法是(上图),用户下单后,订单系统需要分别调用支付系统、物流系统和库存系统,假如物流系统无法访问,则订单出库将失败,从而导致订单失败。

那么如何解决呢?

我们可以引入消息队列机制,在用户下单后,订单系统完成持久化处理,将消息写入消息队列,直接返回用户订单下单成功,支付系统、物流系统和库存系统分别从消息队列中订阅订单信息,进行各自相应操作。

举个例子来说,就像是你在一个餐厅里点餐,你需要和服务员协作完成点餐的任务。如果你们之间没有一个沟通的桥梁,那么你就需要通过手势或者大声喊叫来传递信息,这样会很费时间和精力。

但是如果餐厅里有一个消息中间件系统,你就可以通过这个系统向服务员发送消息,比如点一份牛肉炒饭和一份糖醋排骨,而服务员也可以通过这个系统向你发送消息,比如告诉你牛肉炒饭已经做好了。这样你们之间就有了一个沟通的桥梁,可以更快地完成点餐的任务,提高点餐的效率。

同时,如果服务员因为某些原因不能继续为你服务,你也可以通过消息中间件系统将任务发送给其他服务员,而不需要重新联系之前的服务员。这样就可以更好地解耦应用程序,提高系统的可扩展性和容错性。

流量削峰

image-20230821115449124

流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。

例如过去的情况下,每秒可能会有5K个请求访问A系统,A系统需要将用户的订单写入到 MySQL 中进行持久化操作,但是 MySQL 最大只能处理200个请求,就会出现大量请求积攒,最后可能压垮我们的服务器。

引入消息队列后,用户的请求北服务器接收后,写入消息队列中,用户的订单请求就结束了,A系统在每秒从队列中拉取200个请求进行持久化操作。是一种时间换空间的操作。也就是说如果我们的高峰时间为10S,那么我们对应的处理时间就是250S。

举个例子来说,就像是你在一个游乐园里玩过山车,游乐园里有很多游客,有时候会出现游客数量过多的情况,导致过山车的等待时间变长,游客们也会感到不耐烦。

但是如果游乐园里有一个流量削峰的系统,就可以在游客数量过多的时候,将一部分游客引导到其他游乐设施,让过山车的等待时间保持在一个合理的范围内,这样游客们就可以更好地享受游乐的体验。

同时,如果游乐园里的某些游乐设施出现故障,游客也可以通过流量削峰的系统,将他们引导到其他游乐设施,而不需要等待维修完成。这样就可以更好地控制游客的流量,提高游乐园的运营效率。

异步处理

image-20230821115555981

用户注册后,需要发注册邮件和注册短信。
传统的做法有两种 :

  1. 串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端。
  2. 并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间。

假设三个业务节点每个使用50毫秒钟,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)。

如果使用消息队列,用户的响应时间相当于是注册信息写入数据库的时间,也就是50毫秒。注册邮件,发送短信写入消息队列后,直接返回,因为写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是50毫秒。因此架构改变后,系统的吞吐量提高到每秒20 QPS。比串行提高了3倍,比并行提高了两倍。

举个例子来说,就像是你在一个网站上购买商品,需要填写收货地址和付款信息。如果网站的服务器只有一个,那么你需要等待这个服务器把你的订单处理完才能完成购买。

但是如果网站有多个服务器,那么你就可以选择其中一个服务器填写收货地址和付款信息,这样你就可以更快地完成购买,而不需要等待所有服务器都处理完前面的订单。这就像是异步处理在多个应用程序之间传递消息,帮助它们更快地协调数据和服务。

三、中间件中的各个角色

image-20230821115634357

NameServer 角色

NameServer 负责维护 Producer 和 Consumer 的配置信息、状态信息,并且协调各个角色的协同执行,类似于服务的注册与发现中心。通过 NameServer 各个角色可以了解到集群的整体信息,并且他们会定期向 NameServer 上报状态。

NameServer是中间件中的一种角色,它主要负责管理和分发消息的地址信息。在一个分布式系统中,不同的应用程序可能会在不同的服务器上运行,消息中间件系统需要知道这些应用程序的地址信息,才能将消息发送到正确的地方。NameServer就是用来管理这些地址信息的系统。

举个例子来说,就像是你在一个城市里,你需要找到某个地方,比如一家餐厅。你可以通过查找地图或者问路的方式来找到这个地方。但是如果你知道这家餐厅的地址,你就可以直接使用导航软件,输入餐厅的地址,让导航软件帮你找到这个地方。

这就像是NameServer在分布式系统中的作用,它帮助消息中间件系统找到应用程序的地址信息,让消息能够被正确地发送到目标应用程序。

Broker Cluster 角色

主要负责消息的存储、查询消费,支持主从部署,一个 Master 可以对应多个 Slave,Master 支持读写,Slave 只支持读。Broker 会向集群中的每一台 NameServer 注册自己的路由信息。是个容器的概念。

Broker Cluster是中间件中的一种角色,它主要负责消息的存储、查询和消费。在一个分布式系统中,消息中间件系统需要将消息发送到不同的应用程序,但是这些应用程序可能在不同的服务器上运行,消息中间件系统需要有一个地方来存储和分发这些消息。

举个通俗易懂的例子来说,就像是你在一个超市里购买商品,你需要排队付款。如果超市里只有一个收银员,那么你需要等待这个收银员把前面的顾客都处理完才能轮到你。但是如果超市里有多个收银员,那么你就可以选择其中一个收银员排队付款,这样你就可以更快地付款,而不需要等待所有收银员都处理完前面的顾客。

这就像是Broker Cluster在分布式系统中的作用,它帮助消息中间件系统存储和分发消息,让消息能够被更快地发送到目标应用程序。同时,Broker Cluster支持主从部署,一个Master可以对应多个Slave,这样就可以更好地提高系统的可扩展性和容错性。

Producer 角色

消息的发送者,它负责产生消息,可以集群部署。它会先和 NameServer 集群中的随机一台建立长连接,得知当前要发送的 Topic 存在哪台 Broker Master上,然后再与其建立长连接,支持多种负载平衡模式发送消息。

Producer是中间件中的一种角色,它主要负责将消息发送到消息中间件系统。在一个分布式系统中,应用程序需要将消息发送到不同的地方,比如发送到其他应用程序或者存储到数据库中。Producer就是用来将这些消息发送到消息中间件系统的系统。

举个例子来说,就像是你在一个网站上发布商品信息,需要将这些信息发送到其他网站上,让更多的人能够看到这些信息。你可以使用一个发布商品信息的系统,将这些信息发送到其他网站上。

这就像是Producer在分布式系统中的作用,它帮助应用程序将消息发送到消息中间件系统,让消息能够被更多的应用程序看到。

Consumer 角色

消息的消费者,也可以集群部署。它也会先和 NameServer 集群中的随机一台建立长连接,得知当前要消费的 Topic 存在哪台 Broker Master、Slave上,然后它们建立长连接,支持集群消费和广播消费消息。

先启动 NameServer 集群,各 NameServer 之间无任何数据交互,Broker 启动之后会向所有 NameServer 定期(每 30s)发送心跳包,包括:IP、Port、TopicInfo,NameServer 会定期扫描 Broker 存活列表,如果超过 120s 没有心跳则移除此 Broker 相关信息,代表下线。
这样每个 NameServer 就知道集群所有 Broker 的相关信息,此时 Producer 上线从 NameServer 就可以得知它要发送的某 Topic 消息在哪个 Broker 上,和对应的 Broker (Master 角色的)建立长连接,发送消息。Consumer 上线也可以从 NameServer 得知它所要接收的 Topic 是哪个 Broker ,和对应的 Master、Slave 建立连接,接收消息。

四、中间件中的基本概念

image-20230821115713580

主题(Topic)

同一类消息的标识,例如某宝电商,衣服、手机、咖啡等都是一个主题,一般我们在生产或者消费时都会指定一个主题,要么生产这个主题的消息,要么订阅这个主题的消息

分组(Group)

可以对生产者或者消费者分组,一般都针对于消费者的分组,分组是一个很有意义的事情,因为消费者都是对某一类消息的消费,消费的逻辑都是一致的,比如是一个订单主题的消息,我们可以有一个物流组来消费它,同时也可以定位一个通知组来消费这个消息。相互之间是隔离的,有可能会消费重复的消息。

消息队列(Message Queue)

是一个容器的概念,同一个主题有可能根据分组的不同,会产生不同的队列以供不同的消费组别进行消费。

标签(Tag)

更加细分的划分,在消费的时候我们可以根据 Tag 的不同来订阅不同标签消息,类似于 MySQL 查询中的条件。

偏移量(Offset)

message queue 是无限长的数组,一条消息进来下标就会涨1,下标就是 offset,消息在某个 MessageQueue 里的位置,通过 offset 的值可以定位到这条消息,或者指示 Consumer 从这条消息开始向后处理。

message queue 中的 maxOffset 表示消息的最大 offset,maxOffset 并不是最新的那条消息的 offset,而是最新消息的 offset+1,minOffset 则是现存在的最小 offset。

fileReserveTime=48 默认消息存储48小时后,消费会被物理地从磁盘删除,message queue 的 minOffset 也就对应增长。所以比 minOffset 还要小的那些消息已经不在 broker上了,就无法被消费。

举个例子来说,就像是你在一个图书馆里借阅书籍,每本书都有一个唯一的索书号,用于标识这本书在图书馆中的位置。当你想要借阅一本书时,你可以通过索书号来定位到这本书,并从图书馆中将其借出。

同时,当你想要归还一本书时,你也需要指定书的索书号,以便图书馆能够正确地将书放回到正确的位置。这就像是消息中间件系统中的偏移量(Offset)的作用,它帮助消息在消息队列中被正确地放置和移除

五、RocketMQ 的安装

下来我们来安装 RocketMQ ,我们安装的环境是 CentOS 7,已安装 JDK8 环境。

Linux 下的安装

1、下载

在官网上下载:https://rocketmq.apache.org/dowloading/releases/

注:不要下最新版

image-20230821115825086

2、上传解压

没什么好说的,通过 ftp 上传到指定目录然后运行解压命令。

image-20230821175320503

[root@weilai rocketmq]# unzip rocketmq-all-4.9.7-bin-release.zip
启动 NameServer

image-20230821120631383

# nohup sh ...  & nohup是后台运行 sh超级管理员 ... 代表需要启动的程序
[root@weilai bin]# nohup sh mqnamesrv &

检查日志

[root@weialai bin]# tail -f ~/logs/rocketmqlogs/namesrv.log

image-20230821120819815

启动 Broker

启动 Broker 前需要注意两件事,因为 Broker启动时要加载配置文件。

cd /opt/soft/rocketmq/rocketmq-all-4.9.7-bin-release/conf/
vim broker.conf 
  1. 首先修改 conf 文件夹下的 broker.conf 文件,设置IP。

    image-20230821121003673

添加一个 IP ,其中地址是当前虚拟机地址。

image-20230821121103606

如果识别不到,手动加入

image-20230821165858117

  1. 修改启动文件

    image-20230821121223927

修改文件 runbroker.sh 文件,修改堆空间的初始值。因为这个文件默认启动所需内存较大,我们当前虚拟机没有这么大的内存容量,按照实际配置即可。

image-20230821121422816

修改指定位置内容分别是:512m 512m 256m 就行。

  1. 启动
#-c 是启动时加载配置文件
#-n 是启动IP和端口号
#autoCreateTopicEnable=true 是自动创建主题(可以不写)
[root@weilai bin]# nohup sh mqbroker -c ../conf/broker.conf -n 192.168.65.3:9876 autoCreateTopicEnable=true &

image-20230821123123280

4、查看日志

 [root@weilai bin]# tail -f ~/logs/rocketmqlogs/broker.log

5、从外部导入自定义端口的控制台

image-20230821123406935

6、启动java控制台

[root@weilai /]# cd /opt/workspace/java -jar rocketmq-dashboard-8087.jar

image-20230821123508586

7、在浏览器搜索192.168.65.3:8087,回车之后可以看到

image-20230821123627573

8、退出java控制台(ctrl+c)

总结
cd /opt/soft/rocketmq/rocketmq-all-4.9.7-bin-release/bin

如何启动Namesrv 和 Broker 呢?

  1. 首先启动 Namesrv:nohup sh mqnamesrv &
  2. 在启动 Broker:nohup sh mqbroker -c …/conf/broker.conf -n 192.168.65.3:9876 &

如何停止 Namesrv 和 Broker 呢?

  1. 首先停止 Broker:./mqshutdown broker
  2. 在停止 Namesrv:./mqshutdown namesrv

源码的安装

  1. 下载和解压

还是刚才 RocketMQ 的官网,这次下载 Source 源代码。

image-20230821122001340

  1. IDEA 中导入

    image-20230821122048535

  2. mvn 引入依赖

等待 idea 项目进度完成后执行

mvn install -Dmaven.test.skip=true

如果出现如图

image-20230821122201950

配置 maven 的环境变量

以控制台打开继续执行命令

image-20230821122243208

  1. 创建配置文件夹

    image-20230821122311494

创建 conf 文件夹,将 distribution 项目中 conf 目录下的一下三个文件复制过来。

image-20230821122329969

image-20230821122343607

​ 创建空文件夹 logs,存放日志。

​ 创建空文件夹 store,存放消息。

  1. 启动 Namesrv

    image-20230821122415078

5.1 启动前编辑启动类,设置环境变量

image-20230821122457946

5.2 运行启动类

image-20230821122522501

  1. 启动 Broker

    image-20230821122546294

6.1 编辑 broker.conf

#nameServer
namesrvAddr=127.0.0.1:9876
autoCreateTopicEnable = true
 
storePathRootDir = E:\\workspace\\rocketmq\\store
#commitLog存储路径
storePathCommitLog = E:\\workspace\\rocketmq\\store\\commitlog
#消费队列存储路径
storePathConsumeQueue = E:\\workspace\\rocketmq\\store\\consumequeue
#消息索引存储路径
storePathindex = E:\\workspace\\rocketmq\\store\\index
#checkpoint文件存储路径
storeCheckpoint = E:\\workspace\\rocketmq\\store\\checkpoint
#abort文件存储路径
abortFile = E:\\workspace\\rocketmq\\store\\abort

image-20230821122627936

6.2 同样要编辑启动类,设置启动参数和环境变量

image-20230821122653320

6.3 运行启动类

image-20230821122715489

控制台的安装

  1. 下载

老官网地址:https://github.com/apache/rocketmq-externals

新官网地址:https://github.com/apache/rocketmq-dashboard

它是一个 Java Maven项目,你可以把这个项目引入到 Idea 中。 新项目对于较早的电脑可能不是很合适😊!

建议在 Linux 下安装,安装前请自行安装 jdk8 ,maven 工具。

image-20230821122738577

  1. 编辑

进入项目文件夹并修改 application.yml(老地址修改application.properties) 配置文件(中文注释是为了方便解释,请删除,不然打包报错:Not allow chinese character !)。

server:
  port: 8087               ##这里是控制台项目的端口号
  servlet:
    encoding:
      charset: UTF-8
      enabled: true
      force: true
spring:
  application:
    name: rocketmq-dashboard
 
logging:
  config: classpath:logback.xml
 
rocketmq:
  config:
    # if this value is empty,use env value rocketmq.config.namesrvAddr  NAMESRV_ADDR | now, default localhost:9876
    # configure multiple namesrv addresses to manage multiple different clusters
    namesrvAddrs:
      - 127.0.0.1:9876             ##这里是namesrvAddr地址
      - 127.0.0.2:9876
    # if you use rocketmq version < 3.5.8, rocketmq.config.isVIPChannel should be false.default true
    isVIPChannel:
    # timeout for mqadminExt, default 5000ms
    timeoutMillis:
    # rocketmq-console's data path:dashboard/monitor
    dataPath: /tmp/rocketmq-console/data
    # set it false if you don't want use dashboard.default true
    enableDashBoardCollect: true
    # set the message track trace topic if you don't want use the default one
    msgTrackTopicName:
    ticketKey: ticket
    # must create userInfo file: ${rocketmq.config.dataPath}/users.properties if the login is required
    loginRequired: false
    useTLS: false
    # set the accessKey and secretKey if you used acl
    accessKey: # if version > 4.4.0
    secretKey: # if version > 4.4.0
 
threadpool:
  config:
    coreSize: 10
    maxSize: 10
    keepAliveTime: 3000
    queueSize: 5000
  1. 打包

打开 cmd ,切换到项目路径下,执行

mvn clean package -Dmaven.test.skip=true
  1. 运行

在生产的 jar 包目录下执行

java -jar rocketmq-console-ng-1.0.0.jar

六、普通消息的发送

在 Linux 中前后启动 NameSrv、Broker 和 控制台。

image-20230821122840336

引入依赖

<dependencies>
        <!-- rocketMQ 客户端依赖 -->
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-client</artifactId>
            <version>4.9.4</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.32</version>
        </dependency>
    </dependencies>

消息发送步骤

  1. 创建消息生产者 producer,并指定生产者组名
  2. 指定 NameSrv 地址
  3. 启动 producer
  4. 创建消息对象,指定 Topic、Tag 和消息体
  5. 发送消息
  6. 关闭生产者 producer

同步发送

每发送一次消息,MQ 都会同步进行响应。

@Slf4j
public class 同步消息生产 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_a");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 11; i++) {
            // 创建消息对象,指定 Topic、Tag 和消息体
            String topic = "putong";
            String tag = "tongbu";
            String body = "Hello,RocketMQ,index:" + i;
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 发送消息,等待回调结果
            SendResult result = producer.send(message);
            // 输出发送结果
            log.info("回调消息是:{}", result);
        }

        // 关闭生产者 producer
        producer.shutdown();
    }
}

发送结果:

image-20230821191647921

异步发送

同样能得到响应,但是却是通过异步监听方式来获取的。所以在异步发送时,并不会阻塞当前线程。

@Slf4j
public class 异步消息生产 {

    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_b");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 11; i++) {
            // 创建消息对象,指定 Topic、Tag 和消息体
            String topic = "putong";
            String tag = "yibu";
            String body = "Hello,yibu! index:" + i;
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 发送异步消息
            producer.send(message, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    // 发送成功
                    log.info("发送成功:" + sendResult);
                }

                @Override
                public void onException(Throwable throwable) {
                    // 发送失败
                    log.info("发送失败" + throwable.getMessage());
                }
            });
        }

        // 等待一段时间
        Thread.sleep(10000);
        // 关闭生产者 producer
        producer.shutdown();
    }
}

异步发送的好处是:消息发送者不需要长时间等待消息的响应,是通过异步监听获取到响应结果。

发送结果:

image-20230821191748831

单向发送

消息的发送者只关系发送,并不关心响应结果,有没有收到了。

@Slf4j
public class 单向消息生产 {

    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_c");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 11; i++) {
            // 创建消息对象,指定 Topic、Tag 和消息体
            String topic = "putong";
            String tag = "danxiang";
            String body = "Hello,danxiang! index:" + i;
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 发送 oneway 消息
            producer.sendOneway(message);
        }

        // 关闭生产者 producer
        producer.shutdown();
    }
}

单向发送不关心是否收到,但是发送速度最快,比较适合发送耗时短,对于可靠性要求较低的数据。

发送结果:

image-20230821191815206

三种发送的对比

三者的特点和主要区别如下:

发送方式发送 TPS发送结果反馈可靠性适用场景
同步发送不丢失此种方式应用场景非常广泛,例如重要通知邮件、报名短信通知、营销短信系统等。
异步发送不丢失异步发送一般用于链路耗时较长,对响应时间较为敏感的业务场景,例如用户视频上传后通知启动转码服务,转码完成后通知推送转码结果等。
单向发送最快可能丢失适用于某些耗时非常短,但对可靠性要求并不高的场景,例如日志收集。

七、普通消息的消费

消息队列是基于发布/订阅模型的消息系统。消费者,即消息的订阅方订阅关注的 Topic,以获取并消费消息。由于消费者应用一般是分布式系统,以集群方式部署,因此消息队列约定以下概念:

  • 集群:使用相同 Group ID 的消费者属于同一个集群。同一个集群下的消费者消费逻辑必须完全一致(包括 Tag 的使用)。
  • 集群消费:当使用集群消费模式时,消息队列认为任意一条消息只需要被集群内(单 Zone 内)的任意一个消费者处理即可。
  • 广播消费:当使用广播消费模式时,消息队列会将每条消息推送给集群内(单 Zone 内)所有注册过的消费者,保证消息至少被每个消费者消费一次。

消息消费步骤

  1. 创建消费者 Consumer,指定消费者组名
  2. 指定 NameSrv 地址
  3. 订阅主题 Topic 和 Tag
  4. 设置回调函数,处理消息
  5. 启动消费者 Consumer

集群消费

image-20230821184926149

@Slf4j
public class 集群消息消费1 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建消费者 Consumer,指定消费者组名
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_consumer_a");
        // 指定 NameSrv 地址
        consumer.setNamesrvAddr("192.168.65.3:9876");
        // 订阅主题 Topic 和 Tag
        consumer.subscribe("putong", "*");
        // 设置回调函数,处理消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 当订阅的消息生产后会自动执行这里的代码
                log.info("订阅的消息有生产了");
                list.forEach((e) -> {
                    String topic = e.getTopic();
                    String tag = e.getTags();
                    String body = new String(e.getBody());
                    log.info("topic:{},tag:{},body:{}", topic, tag, body);
                });
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者 Consumer
        consumer.start();
    }
}
@Slf4j
public class 集群消息消费2 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建消费者 Consumer,指定消费者组名
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_consumer_a");
        // 指定 NameSrv 地址
        consumer.setNamesrvAddr("192.168.65.3:9876");
        // 订阅主题 Topic 和 Tag
        consumer.subscribe("putong", "*");
        // 设置回调函数,处理消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 当订阅的消息生产后会自动执行这里的代码
                log.info("订阅的消息有生产了");
                list.forEach((e) -> {
                    String topic = e.getTopic();
                    String tag = e.getTags();
                    String body = new String(e.getBody());
                    log.info("topic:{},tag:{},body:{}", topic, tag, body);
                });
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者 Consumer
        consumer.start();
    }
}

运行结果

image-20230821194526767

注意事项

  • 集群消费模式下,每一条消息都只会被分发到一台机器上处理。如果需要被集群下的每一台机器都处理,请使用广播模式。
  • 集群消费模式下,不保证每一次失败重投的消息路由到同一台机器上。

广播消费

适用于消费端集群化部署,每条消息需要被集群下的每个消费者处理的场景。具体消费示例如下图所示。

image-20230821191947357


运行结果

image-20230821193821906

注意事项

  • 广播消费模式下不支持顺序消息
  • 广播消费模式下不支持重置消费位点
  • 广播模式下不支持线下联调分组消息
  • 每条消息都需要被相同订阅逻辑多台机器处理
  • 消费进度在客户端维护,出现重复消费的概率稍大于集群模式。
  • 广播模式下,消息队列保证每条消息至少被每台客户端消费一次,但是并不会重投消费失败的消息,因此业务方需要关注消费失败的情况。
  • 广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。
  • 广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。
  • 广播模式下服务端不维护消费进度,所以消息队列控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。

八、收发顺序消息

顺序消息(FIFO 消息)是消息队列提供的一种严格按照顺序来发布和消费的消息类型。可以被分为:

  • 全局顺序消息
  • 部分顺序消息

顺序消费的原理解析:

  • 默认的情况下消息发送会采取 Round Robbin 轮询方式把消息发送到不同的 queue (分区队列);而消费消息的时候从多个 queue 上拉取消息,这种情况发送和消费是不能保证顺序
  • 但是如果控制发送的顺序消息只依次发送到同一个queue中,消费的时候只从这个queue上依次拉取,则就保证了顺序。
  • 当发送和消费参与的queue只有一个,则是全局有序;如果多个queue参与,则为分区有序,即相对每个queue,消息都是有序的。

顺序消息的生产

全局顺序消息生产

@Slf4j
public class 全局顺序消息生产 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建默认的消息生产者
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_a");
        // 设置名称服务地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动消息生产者
        producer.start();


        //第一种写法
//         获取所有顺序队列
//        List<MessageQueue> shunxu = producer.fetchPublishMessageQueues("shunxu");
//         循环发送消息
//        for (int i = 1; i < 11; i++) {
//            // 设置消息主题
//            String topic = "shunxu";
//            // 设置消息标签
//            String tag = "quanju";
//            // 设置消息体
//            String body = "全局消息发送,i:" + i;
//            // 创建消息实例
//            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
//            // 发送消息到第一个顺序队列
//            producer.send(message, shunxu.get(0));
//        }

        //第二种写法
        for (int i = 1; i < 11; i++) {
            // 设置消息主题
            String topic = "shunxu";
            // 设置消息标签
            String tag = "quanju";
            // 设置消息体
            String body = "全局消息发送,i:" + i;
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 发送消息
            SendResult result = producer.send(message, (list, message1, o) -> list.get(0), 0);
            // 输出发送结果
            log.info("result:{}", result);
        }

        // 关闭消息生产者
        producer.shutdown();

    }
}

bean包(根据订单模拟多个状态的订单队列,方便说明局部顺序)

@Data
public class Order implements Serializable {

    private String id;
    private String name;
    private Date time;
    private Integer state;// 1.未付款 2.已付款 3.未发货 4.已发货

}

局部顺序消息生产

@Slf4j
public class 局部顺序消息生产 {

    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("abc2");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 21; i++) {
            // 创建消息对象,指定 Topic、Tag 和消息体
            String topic = "shunxu";
            String tag = "jubu";
            Order order = new Order();
            order.setId(UUID.randomUUID().toString());
            order.setTime(new Date());
            order.setName("订单:" + i);
            int state = (int) (Math.random() * 4 + 1);
            order.setState(state);
            String body = JSONArray.toJSONString(order);
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 发送消息,指定顺序号和回调函数
            SendResult result = producer.send(message, (list, message1, o) -> list.get(state - 1), state - 1);
            // 输出发送结果
            log.info("result:{}", result);
        }

        // 关闭生产者 producer
        producer.shutdown();
    }
}

顺序消息的消费

@Slf4j
public class 顺序消息消费 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建一个DefaultMQPushConsumer实例
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("abcd1");

        // 设置NamesrvAddr为ONS实例的地址
        consumer.setNamesrvAddr("192.168.65.3:9876");

        // 订阅主题为"shunxu"、标签为"jubu"的消息
        consumer.subscribe("shunxu", "jubu");

        // 注册一个MessageListenerOrderly,用于处理接收到的消息
        consumer.registerMessageListener((MessageListenerOrderly) (list, consumeOrderlyContext) -> {
            try {
                // 遍历接收到的消息
                list.forEach(e -> {
                    // 获取消息的主题、标签、正文和队列ID
                    String topic = e.getTopic();
                    String tag = e.getTags();
                    String body = new String(e.getBody(), StandardCharsets.UTF_8);
                    int queue = e.getQueueId();

                    // 打印消息的相关信息
                    log.info("topic:{},tag:{},body:{},queue:{}", topic, tag, body, queue);
                });

                // 消费消息成功
                return ConsumeOrderlyStatus.SUCCESS;
            } catch (Exception e) {
                // 消费消息失败,暂停当前队列的消费
                log.info("消费消息失败!");
                return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
            }
        });

        // 启动消费者
        consumer.start();
    }
}

运行结果:

1、发送和消费参与的queue只有一个,则是全局有序

image-20230821232043193

2、多个queue参与,则为分区有序

image-20230821233154222

九、收发延时消息

在电商里,提交了一个订单就可以发送一个延时消息,1h后去检查这个订单的状态,如果还是未付款就取消订单释放库存。

使用限制

// org/apache/rocketmq/store/config/MessageStoreConfig.java
private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
  • RocketMq 并不支持任意时间的延时,需要设置几个固定的延时等级,从1s到2h分别对应着等级1到18。
  • 使用 setDelayTimeLevel(int level) ;设置延时等级,level 从 0 开始 。

延时消息的生产

@Slf4j
public class 延迟消息生产 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建一个DefaultMQProducer实例
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_b");
        // 设置NamesrvAddr为ONS实例的地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动生产者
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 11; i++) {
            // 定义消息的主题、标签和正文
            String topic = "putong";
            String tag = "yanchi";
            String body = "Hello,yanchi! index:" + i + ",time:" + new SimpleDateFormat("HH:mm:ss").format(new Date());
            // 创建消息对象
            Message message = new Message(topic, tag, body.getBytes(StandardCharsets.UTF_8));
            // 设置延迟时间级别为2(单位:秒)
            message.setDelayTimeLevel(2);
            // 发送消息
            SendResult send = producer.send(message);
            // 打印发送结果
            log.info("send:{}", send);
            // 等待1秒钟,再发送下一条消息
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        // 等待10秒钟,再关闭生产者
        try {
            Thread.sleep(10000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 关闭生产者
        producer.shutdown();
    }
}

延时消息的消费

@Slf4j
public class 延时消息消费 {
    @SneakyThrows
    public static void main(String[] args) throws Exception {
        // 创建一个DefaultMQPushConsumer实例
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_group_b");

        // 设置NamesrvAddr为ONS实例的地址
        consumer.setNamesrvAddr("192.168.65.3:9876");

        // 订阅主题为"GuanWei"、标签为"yanshi"的消息
        consumer.subscribe("putong", "yanshi");

        // 注册一个MessageListenerConcurrently,用于处理接收到的消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
                try {
                    // 遍历接收到的消息
                    for (MessageExt msg : msgs) {
                        // 获取消息的主题、标签、正文和队列ID
                        String topic = msg.getTopic();
                        String tags = msg.getTags();
                        String body = new String(msg.getBody(), "UTF-8");

                        // 打印消息的相关信息和延迟时间
                        log.info("消费了消息【topic:{},tags:{},body:{},延迟时间:{}】", topic, tags, body, System.currentTimeMillis() - msg.getStoreTimestamp());
                    }

                    // 消费消息成功
                    return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
                } catch (UnsupportedEncodingException e) {
                    // 消费消息失败,返回RECONSUME_LATER
                    log.info("消费消息失败!");
                    return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
            }
        });

        // 启动消费者
        consumer.start();
    }
}

运行结果:

image-20230821234904278

十、收发批量消息

  • 批量发送消息能显著提高传递小消息的性能。
  • 但是同一批次的消息应该有相同的topic,相同的waitStoreMsgOK,而且不能是延时消息。
  • 此外,这一批消息的总大小不应超过1MB。

waitStoreMsgOK:表示发送消息后,是否需要等待消息同步刷新到磁盘上。如果broker配置为ASYNC_MASTER,那么只需要消息在master上刷新到磁盘即可;如果配置为SYNC_MASTER,那么还需要等待slave也刷新到磁盘。需要注意的是,waitStoreMsgOK默认为false,只有将设置为true的情况下,才会等待刷盘成功再返回。

批量消息的生产

@Slf4j
public class 批量消息生产 {

    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_c");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 创建消息列表
        ArrayList<Message> list = new ArrayList<>();
        // 循环发送消息
        for (int i = 1; i < 11; i++) {
            // 创建消息对象,指定 Topic、Tag 和消息体
            String topic = "putong";
            String tag = "piliang";
            String body = "hello,piliang!,index:" + i;
            // 创建消息实例
            Message message = new Message(topic, tag, body.getBytes());
            // 添加到消息列表
            list.add(message);
        }
        // 发送消息
        SendResult send = producer.send(list);
        // 输出发送结果
        log.info("send:{}", send);
        // 关闭生产者 producer
        producer.shutdown();
    }
}

批量消息的消费

这里没有什么特别的代码,就用普通消息的消费就行(以广播消息消费2举例)。

@Slf4j
public class 广播消息消费2 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建消费者 Consumer,指定消费者组名
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_consumer_a");
        // 指定 NameSrv 地址
        consumer.setNamesrvAddr("192.168.65.3:9876");
        // 订阅主题 Topic 和 Tag
        consumer.subscribe("putong", "*");
        // 设置广播还是集群(默认是集群)
        consumer.setMessageModel(MessageModel.BROADCASTING);
        // 设置回调函数,处理消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 当订阅的消息生产后会自动执行这里的代码
                log.info("订阅的消息有生产了");
                list.forEach((e) -> {
                    String topic = e.getTopic();
                    String tag = e.getTags();
                    String body = new String(e.getBody());
                    log.info("topic:{},tag:{},body:{}", topic, tag, body);
                });
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者 Consumer
        consumer.start();
    }
}

运行结果:

image-20230821235853363

十一、收发过滤消息

在大多数情况下,TAG是一个简单而有用的设计,可以来选择您想要的消息。例如:

DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_group_a");
consumer.subscribe("zgh", "TagA || TagB || TagC");

消费者将接收包含 TagA 、TagB 或 TagC 的消息,但是限制是一个消息只能有一个标签,这对于复杂的场景可能不起作用。这时可以在发送消息时设置一些属性,再使用 SQL 表达式通过筛选属性来筛选消息。

SQL 特性可以通过发送消息时的属性来进行计算。在RocketMQ定义的语法下,可以实现一些简单的逻辑。下面是一个例子:

------------
| message  |
|----------|  a > 5 AND b = 'abc'
| a = 10   |  --------------------> Gotten
| b = 'abc'|
| c = true |
------------
------------
| message  |
|----------|   a > 5 AND b = 'abc'
| a = 1    |  --------------------> Missed
| b = 'abc'|
| c = true |
------------

SQL 基本语法

RocketMQ只定义了一些基本语法来支持这个特性。你也可以很容易地扩展它。

  • 数值比较,比如:>,>=,<,<=,BETWEEN,=。
  • 字符比较,比如:=,<>,IN。
  • IS NULL 或者 IS NOT NULL。
  • 逻辑符号 AND,OR,NOT。

常量支持类型为:

  • 数值,比如:123,3.1415。
  • 字符,比如:‘abc’,必须用单引号包裹起来。
  • NULL,特殊的常量。
  • 布尔值,TRUE 或 FALSE。

过滤消息的生产

发送消息时,通过 putUserProperty 方法来设置消息的属性。

@Slf4j
public class 过滤消息生产 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建消息生产者 producer,并指定生产者组名
        DefaultMQProducer producer = new DefaultMQProducer("zgh_producer_a");
        // 指定 NameSrv 地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 启动 producer
        producer.start();
        // 循环发送消息
        for (int i = 1; i < 21; i++) {
            // 生成随机年龄
            int age = (int) (Math.random() * 100 + 1);
            // 定义消息的主题、标签和正文
            String topic = "putong";
            String tag = "guolv";
            String body = "Hello,RocketMQ,index:" + i + ",age:" + age;
            // 创建消息对象
            Message message = new Message(topic, tag, body.getBytes());
            // 设置条件
            message.putUserProperty("id", String.valueOf(i));
            message.putUserProperty("age", String.valueOf(age));
            // 发送消息
            SendResult result = producer.send(message);
            // 打印发送结果
            log.info("result:{}", result);
        }
        // 关闭生产者
        producer.shutdown();
    }
}

过滤消息的消费

用 MessageSelector.bySql 方法来使用 sql 筛选消息。

@Slf4j
public class 过滤消息消费 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建一个DefaultMQPushConsumer实例
        DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("zgh_consumer_a");

        // 设置NamesrvAddr为ONS实例的地址
        consumer.setNamesrvAddr("192.168.65.3:9876");

        // 订阅主题为"putong"、消息过滤条件为"id>7 and age<44"的消息
        consumer.subscribe("putong", MessageSelector.bySql("id>7 and age<44"));
        // 注册一个MessageListenerConcurrently,用于处理接收到的消息
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                // 遍历接收到的消息
                list.forEach(e -> {
                    // 打印消息的正文
                    log.info("body:{}", new String(e.getBody()));
                });
                // 消费消息成功
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者
        consumer.start();
    }
}

一个常见错误

如果你在消费过滤消息时,出现如下错误:

image-20230822001819374

要使用基于标准的 sql92 模式过滤消息,必须找到 RocketMQ 的安装目录的 conf 下的对应配置文件去修改配置,然后停止broker服务,再重启broker服务,我这里修改的是 broker.conf 文件:

cd /opt/soft/rocketmq/rocketmq-all-4.9.7-bin-release/conf/
vim broker.conf
#开启消息过滤。
enablePropertyFilter = true

image-20230822002605020

运行结果:

image-20230822001648293

十二、事务消息

在一些对数据一致性有强需求的场景,可以用 Apache RocketMQ 事务消息来解决,从而保证上下游数据的一致性

在这里插入图片描述

以电商交易场景为例,用户支付订单这一核心操作的同时会涉及到下游物流发货、积分变更、购物车状态清空等多个子系统的变更。当前业务的处理分支包括:

  • 主分支订单系统状态更新:由未支付变更为支付成功。
  • 物流系统状态新增:新增待发货物流记录,创建订单物流记录。
  • 积分系统状态变更:变更用户积分,更新用户积分表。
  • 购物车系统状态变更:清空购物车,更新用户购物车记录。

使用普通消息和订单事务无法保证一致的原因,本质上是由于普通消息无法像单机数据库事务一样,具备提交、回滚和统一协调的能力。 而基于 RocketMQ 的分布式事务消息功能,在普通消息基础上,支持二阶段的提交能力。将二阶段提交和本地事务绑定,实现全局提交结果的一致性

在这里插入图片描述

代码实现

事务监听器

重写执行本地事务方法以及事务回查方法:

/**
 * 事务监听器,重写执行本地事务方法以及事务回查方法
 */
@Slf4j
public class TransactionListenerImpl implements TransactionListener {
 
    @Override
    public LocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        String msgKey = msg.getKeys();
        log.info("开始执行executeLocalTransaction操作,msgKey:{}", msgKey);
        return switch (msgKey) {
            case "Num0", "Num1" ->
                // 明确回复回滚操作,消息将会被删除,不允许被消费。
                    LocalTransactionState.ROLLBACK_MESSAGE;
            case "Num8", "Num9" ->
                // 消息无响应,代表需要回查本地事务状态来决定是提交还是回滚事务
                    LocalTransactionState.UNKNOW;
            default ->
                // 消息通过,允许消费者消费消息
                    LocalTransactionState.COMMIT_MESSAGE;
        };
    }
 
    @Override
    public LocalTransactionState checkLocalTransaction(MessageExt msg) {
        log.info("回查本地事务状态,消息Key: {},消息内容:{},重使次数:{}", msg.getKeys(), new String(msg.getBody()), msg.getReconsumeTimes());
        if (msg.getReconsumeTimes() > 0) {
            log.info("消息消费失败");
            return LocalTransactionState.ROLLBACK_MESSAGE;
        }
        // 需要根据业务,查询本地事务是否执行成功,这里直接返回COMMIT
        return LocalTransactionState.COMMIT_MESSAGE;
    }
 
}

事务消息发送

@Slf4j
public class 事务消息生产 {
    @SneakyThrows
    public static void main(String[] args) {
        // 创建事务类型的生产者
        TransactionMQProducer producer = new TransactionMQProducer("transaction-producer-group");
        // 设置NameServer的地址
        producer.setNamesrvAddr("192.168.65.3:9876");
        // 设置事务监听器
        producer.setTransactionListener(new TransactionListenerImpl());
        // 启动生产者
        producer.start();

        // 发送10条消息
        for (int i = 0; i < 10; i++) {
            try {
                Message msg = new Message("TransactionTopic", "", ("Hello RocketMQ Transaction Message" + i).getBytes(RemotingHelper.DEFAULT_CHARSET));
                // 设置消息Key
                msg.setKeys("Num" + i);
                // 使用事务方式发送消息
                SendResult sendResult = producer.sendMessageInTransaction(msg, null);
                log.info("sendResult:{}", sendResult);
                Thread.sleep(10);
            } catch (MQClientException | UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }
        //producer.shutdown();
    }
}

事务消息消费

@Slf4j
public class CustomerReceive {
    public static void main(String[] args) throws MQClientException {
 
        // 创建DefaultMQPushConsumer类并设定消费者名称
        DefaultMQPushConsumer mqPushConsumer = new DefaultMQPushConsumer("consumer-group-test");
 
        // 设置NameServer地址,如果是集群的话,使用分号;分隔开
        mqPushConsumer.setNamesrvAddr("81.68.205.226:9876");
 
        // 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
        // 如果不是第一次启动,那么按照上次消费的位置继续消费
        mqPushConsumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_LAST_OFFSET);
 
        // 订阅一个或者多个Topic,以及Tag来过滤需要消费的消息,如果订阅该主题下的所有tag,则使用*
        mqPushConsumer.subscribe("TransactionTopic", "*");
 
        // 注册回调实现类来处理从broker拉取回来的消息
        mqPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
            // 监听类实现MessageListenerConcurrently接口即可,重写consumeMessage方法接收数据
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgList, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
                MessageExt messageExt = msgList.get(0);
                String body = new String(messageExt.getBody(), StandardCharsets.UTF_8);
                String msgKey = messageExt.getKeys();
                if (msgKey.equals("Num8")) {
                    log.info("模拟消息消费失败!");
                    throw new RuntimeException("模拟消息消费失败!");
                    //return ConsumeConcurrentlyStatus.RECONSUME_LATER;
                }
                log.info("消费者接收到消息: {},---消息内容为:{}", messageExt.toString(), body);
                // 标记该消息已经被成功消费
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
 
        // 启动消费者实例
        mqPushConsumer.start();
    }
}

运行结果:

image-20230822115801767

十三、常见属性和方法

这里罗列了一些在消息生产和消费中常见的一些属性设置和方法。

消息生产的属性和方法

属性

image-20230821205842993

方法

单向发送方法

image-20230821205938044

同步发送方法

在这里插入图片描述

异步发送方法

在这里插入图片描述

消息消费的属性和方法

属性

image-20230821210141782

方法

订阅相关方法

image-20230821210213634

注册监听器相关方法

并发事件监听器

image-20230821210434323

顺序事件监听器

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-QpYkXcud-1692792392302)(https://gitee.com/zgh-study-news/zgh_local-images/raw/master/images/202308212105175.png)]

十三、

十四、RocketMQ 的高可用机制

集群部署模式

  • 单 master 模式
  • 多 master 模式
  • 多 master 多 slave 模式(同步)
  • 多 master 多 slave 模式(异步)

在这里插入图片描述

RocketMQ 分布式集群是通过 Master 和 Slave 的配合达到高可用性的。

Master 和 Slave 的区别:在 Broker 的配置文件中,参数 brokerId 的值为0表明这个 Broker 是 Master,大于0表明这个 Broker 是 Slave。

Master 角色的 Broker 支持读和写,Slave 角色的Broker仅支持读,也就是 Producer 只能和 Master 角色的 Broker 连接写入消息;Consumer 可以连接 Master 角色的 Broker,也可以连接 Slave 角色的 Broker 来读取消息。

# 如果是Broker集群模式,这里的名称一致,代表在一个集群中
brokerClusterName=DefaultCluster
# 当前 Broker 的名称,集群中通过 brokerName 来区分不同 BrokerMaster 和 对应的 Slave 名称相同
brokerName=broker-a
# 当为0 时 代表 master 非0时 代表slave
brokerId=0 # broker的唯一标识
deleteWhen=04 # 数据删除时间
fileReservedTime=48 #文件保留时间
brokerRole=ASYNC_MASTER  #brokerRole:broker角色,ASYNC_FLUSH表示异步刷新
flushDiskType=ASYNC_FLUSH #flushDiskType:刷新磁盘类型

消息消费高可用

在 Consumer 的配置文件中,并不需要设置是从 Master 读还是从 Slave 读,当 Master 不可用或者繁忙的时候,Consumer 会被自动切换到从 Slave 读。有了自动切换 Consumer 这种机制,当一个 Master 角色的机器出现故障后,Consumer 仍然可以从 Slave 读取消息,不影响 Consumer 程序。这就达到了消费端的高可用性。

消息发送高可用

在创建 Topic 的时候,把 Topic 的多个 Message Queue 创建在多个 Broker 组上,这样当一个 Broker 组的 Master 不可用后,其他组的 Master 仍然可用,Producer 仍然可以发送消息。 RocketMQ 目前还不支持把 Slave 自动转成 Master,如果机器资源不足, 需要把 Slave 转成 Master,则要手动停止 Slave 角色的 Broker,更改配置文件,用新的配置文件启动 Broker。

刷盘与主从同步

  • 同步刷盘和异步刷盘
  • 同步复制和异步复制

在这里插入图片描述

同步刷盘和异步刷盘

RocketMQ 的消息是存储到磁盘上的,这样既能保证断电后恢复,又可以让存储的消息量超出内存的限制。RocketMQ 为了提高性能,会尽可能地保证磁盘的顺序写。消息在通过 Producer 写入 RocketMQ 的时候,有两种写磁盘方式:

异步刷盘方式:

在返回写成功状态时,消息可能只是被写入了内存的 PAGECACHE(缓冲区),写操作的返回快,吞吐量大;当内存里的消息量积累到一定程度时,统一触发写磁盘操作,快速写入。

优点:性能高

缺点:Master宕机,磁盘损坏的情况下,会丢失少量的消息, 导致MQ的消息状态和生产者/消费者的消息状态不一致

同步刷盘方式:

在返回应用写成功状态前,消息已经被写入磁盘。具体流程是,消息写入内存的 PAGECACHE(缓冲区) 后,立刻通知刷盘线程刷盘,然后等待刷盘完成,刷盘线程执行完成后唤醒等待的线程,给应用返回消息写成功的状态。

优点:可以保持MQ的消息状态和生产者/消费者的消息状态一致

缺点:性能比异步的低

同步刷盘还是异步刷盘,是通过Broker配置文件里的flushDiskType参数设置的,这个参数被设置成SYNC_FLUSH, ASYNC_FLUSH中的一个。

同步复制和异步复制

同步复制方式是等 Master 和 Slave 均写成功后才反馈给客户端写成功状态,在同步复制方式下,如果 Master 出故障, Slave 上有全部的备份数据,容易恢复,但是同步复制会增大数据写入延迟,降低系统吞吐量。

异步复制方式是只要 Master 写成功 即可反馈给客户端写成功状态,在异步复制方式下,系统拥有较低的延迟和较高的吞吐量,但是如果 Master 出了故障,有些数据因为没有被写入 Slave,有可能会丢失。

同步复制和异步复制是通过 Broker 配置文件里的 brokerRole 参数进行设置的,这个参数可以被设置成 ASYNC_MASTER、 SYNC_MASTER、SLAVE(从节点配置)三个值中的一个。

三个值的说明:

  • SYNC_MASTER同步方式,Maste r角色 Broker 中的消息要立刻同步过去。
  • ASYNC_MASTER异步方式,Master 角色 Broker 中的消息通过异步处理的方式同步到 Slave 角色的机器上。
  • SLAVE 表明当前是从节点,无需配置 brokerRole。

十五、RocketMQ 存储结构

在 RocketMQ 存储架构设计中,采用的是混合存储(多个 Topic 的消息实体内容都存储于一个 CommitLog 中),其中有3个重要的存储文件,分别是 CommitLog、ConsumeQueue、IndexFile。

CommitLog 文件

image-20230822004711644

commitLog 是存储消息的主体。Producer 发送的消息都会顺序写入 commitLog 文件,所以随着写入的消息增多,文件也会随之变大。单个文件大小默认1G, 文件名长度为20位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为0,文件大小为1G=1073741824;当第一个文件写满了,第二个文件为00000000001073741824,起始偏移量为1073741824,以此类推。

commitLog 以物理文件的方式存放,每台 Broker 上的 commitLog 被本机器所有 consumeQueue 共享。在 commitLog,一个消息的存储长度是不固定的,RocketMQ 采用了一些机制,尽量向 commitLog 中顺序写,但是随即读。

为什么要顺序写,随机读?

  • [ 磁盘存储的“快”——顺序写 ]

​ 磁盘存储,使用得当,磁盘的速度完全可以匹配上网络的数据传输速度,目前的高性能磁盘,顺序写速度可以达到600MB/s,超过了一般网卡的传输速度。

  • [ 磁盘存储的“慢”——随机写 ]

​ 磁盘的随机写的速度只有100KB/s,和顺序写的性能差了好几个数量级。

  • [ 存储机制这样设计的好处——顺序写,随机读 ]

​ CommitLog 顺序写,可以大大提高写入的效率;虽然是随机读,但是利用 package 机制,可以批量地从磁盘读取,作为 cache 存到内存中,加速后续的读取速度。
​ 为了保证完全的顺序写,需要 ConsumeQueue 这个中间结构,因为 ConsumeQueue 里只存储偏移量信息,所以尺寸是有限的。

​ 在实际情况中,大部分 ConsumeQueue 能够被全部读入内存,所以这个中间结构的操作速度很快,可以认为是内存读取的速度。

ConsumeQueue 文件

ConsumeQueue (逻辑消费队列)可以看成基于 topic 的 commitLog 的索引文件。因为 CommitLog 是按照顺序写入的,不同的 topic 消息都会混淆在一起,而 Consumer 又是按照 topic 来消费消息的,这样的话势必会去遍历 commitLog 文件来过滤 topic,这样性能肯定会非常差,所以 RocketMQ 采用 ConsumeQueue 来提高消费性能。即每个 Topic 下的每个 queueId 对应一个 ConsumeQueue,其中存储了单条消息对应在 commitLog 文件中的物理偏移量 offset,消息大小 size,消息 Tag 的 hash 值。

IndexFile 文件

因为所有的消息都存在 CommitLog 中,如果要实现根据 key 查询 消息的方法,就会变得非常困难,所以为了解决这种业务需求,有了 IndexFile 的存在。用于为生成的索引文件提供访问服务,通过消息 Key 值查询消息真正的实体内容。在实际的物理存储上,文件名则是以创建时的时间戳命名的,固定的单个 IndexFile 文件大小约为400M,一个 IndexFile 可以保存 2000W个索引。

消息存储整体架构

image-20230822004800268

上图即为 RocketMQ 的消息存储整体架构,RocketMQ 采用的是混合型的存储结构,即为 Broker 单个实例下所有的队列共用一个日志数据文件(即为CommitLog)来存储。RocketMQ 采用混合型存储结构的缺点在于,会存在较多的随机读操作,因此读的效率偏低。同时消费消息需要依赖 ConsumeQueue,构建该逻辑消费队列需要一定开销。

上面图中假设 Consumer 端默认设置的是同一个 ConsumerGroup,因此 Consumer 端线程采用的是负载订阅的方式进行消费。从架构图中可以总结出如下几个关键点:

  1. 消息生产与消息消费相互分离,Producer 端发送消息最终写入的是 CommitLog (消息存储的日志数据文件),Consumer 端先从 ConsumeQueue(消息逻辑队列)读取持久化消息的起始物理位置偏移量 offset、大小 size 和消息 Tag 的 HashCode 值,随后再从 CommitLog 中进行读取待拉取消费消息的真正实体内容部分。

  2. RocketMQ 的 CommitLog 文件采用混合型存储(所有的 Topic 下的消息队列共用同一个 CommitLog 的日志数据文件),并通过建立类似索引文件— ConsumeQueue 的方式来区分不同Topic 下面的不同 MessageQueue 的消息,同时为消费消息起到一定的缓冲作用。这样,只要消息写入并刷盘至 CommitLog 文件后,消息就不会丢失,即使 ConsumeQueue 中的数据丢失,也可以通过 CommitLog 来恢复。

  3. RocketMQ 每次读写文件的时候真的是完全顺序读写么?这里,发送消息时,生产者端的消息确实是顺序写入 CommitLog;订阅消息时,消费者端也是顺序读取 ConsumeQueue,然而根据其中的起始物理位置偏移量 offset 读取消息真实内容却是随机读取 CommitLog。 在 RocketMQ 集群整体的吞吐量、并发量非常高的情况下,随机读取文件带来的性能开销影响还是比较大的。

十六、SpringBoot 中使用 RocketMQ

下面我们在 Boot 项目中使用 RocketMQ,使用起来很简单,按照如下步骤即可:

引入依赖

<dependency>
    <groupId>org.apache.rocketmq</groupId>
    <artifactId>rocketmq-spring-boot-starter</artifactId>
    <version>2.0.2</version>
</dependency>

配置文件

# RocketMQ 相关配置
rocketmq:
  # 指定 nameServer
  name-server: 192.168.65.3:9876
  # Producer 生产者
  producer:
    group: zgh_group_a  # 指定发送者组名
    send-message-timeout: 3000 # 发送消息超时时间,单位:毫秒。默认为 3000 。
    compress-message-body-threshold: 4096 # 消息压缩阀值,当消息体的大小超过该阀值后,进行消息压缩。默认为 4 * 1024B
    max-message-size: 4194304 # 消息体的最大允许大小。。默认为 4 * 1024 * 1024B
    retry-times-when-send-failed: 2 # 同步发送消息时,失败重试次数。默认为 2 次。
    retry-times-when-send-async-failed: 2 # 异步发送消息时,失败重试次数。默认为 2 次。
    retry-next-server: false # 发送消息给 Broker 时,如果发送失败,是否重试另外一台 Broker 。默认为 false

消息的生产

private RocketMQTemplate rocketMQTemplate;
 
DefaultMQProducer producer = rocketMQTemplate.getProducer();
producer.setProducerGroup("zgh_group_a");
producer.setNamesrvAddr("192.168.65.3:9876");
Message message1 = new Message();
message1.setTopic("zgh");
message1.setTags("TagA");
message1.setBody(message.getBytes(StandardCharsets.UTF_8));
producer.sendOneway(message1);
// producer.shutdown();
// rocketMQTemplate.convertAndSend("TopicA", message);

消息的消费

// <String>是泛型,也可以是其他的类型
@RocketMQMessageListener(topic = "TopicA", consumerGroup = "dailyblue_group_b")
public class ConsumerController implements RocketMQListener<String> {
    @Override
    public void onMessage(String s) {
        System.out.println("获取了消息:" + s);
    }
}

RocketMQTemplate 类

RocketMQTemplate 是RocketMQ 集成到 SpringBoot 之后提供的个方便发送消息的模板类,它是基本 Spring 的消息机制实现的,对外只提供了 Spring 抽象出来的消息发送接口。

    private RocketMQTemplate rocketMQTemplate;
 
    /**
     * 普通字符串消息
     */
    public void sendMessage() {
        String json = "普通消息";
        rocketMQTemplate.convertAndSend("sendMessage", json);
    }
 
    /**
     * 同步消息
     */
    public void syncSend() {
        Message message1 = new Message();
        message1.setTopic("GUANWEI");
        message1.setTags("TagA");
        message1.setBody(message.getBytes(StandardCharsets.UTF_8));
        SendResult sendMessage = rocketMQTemplate.syncSend("sendMessage", message);
        System.out.println(sendMessage);
    }
 
    /**
     * 异步消息
     */
    public void asyncSend() {
        Message message1 = new Message();
        message1.setTopic("GUANWEI");
        message1.setTags("TagA");
        message1.setBody(message.getBytes(StandardCharsets.UTF_8));
        SendCallback callback = new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                System.out.println("123");
            }
 
            @Override
            public void onException(Throwable throwable) {
                System.out.println("456");
            }
        };
        rocketMQTemplate.asyncSend("sendMessage", message, callback);
    }
 
    /**
     * 单向消息
     */
    public void onewaySend() {
        Message message1 = new Message();
        message1.setTopic("GUANWEI");
        message1.setTags("TagA");
        message1.setBody(message.getBytes(StandardCharsets.UTF_8));
        rocketMQTemplate.sendOneWay("sendMessage", message);
    }
// ------------------------------------------------------------------------------
// 另一种写法
/**
     * 同步消息
     */
    public void syncSend() {
        SendResult sendMessage = rocketMQTemplate.syncSend("topic:tag", "同步消息");
        System.out.println(sendMessage);
    }
 
    /**
     * 异步消息
     */
    public void asyncSend() {
        SendCallback callback = new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                System.out.println("123");
            }
 
            @Override
            public void onException(Throwable throwable) {
                System.out.println("456");
            }
        };
        rocketMQTemplate.asyncSend("topic:tag", "异步消息", callback);
    }
 
    /**
     * 单向消息
     */
    public void onewaySend() {
        rocketMQTemplate.sendOneWay("topic:tag", "单向消息");
    }

@RocketMQMessageListener 监听器

1. consumerGroup 消费者分组
2. topic 主题
3. selectorType 消息选择器类型
默认值 SelectorType.TAG 根据TAG选择
仅支持表达式格式如:“tag1 || tag2 || tag3”,如果表达式为null或者“*”标识订阅所有消息
SelectorType.SQL92 根据SQL92表达式选择
关键字:
AND, OR, NOT, BETWEEN, IN, TRUE, FALSE, IS, NULL
数据类型:
Boolean, like: TRUE, FALSE
String, like: ‘abc’
Decimal, like: 123
Float number, like: 3.1415
语法:
AND, OR
>, >=, <, <=, =
BETWEEN A AND B, equals to >=A AND <=B
NOT BETWEEN A AND B, equals to >B OR <A
IN ('a', 'b'), equals to ='a' OR ='b', this operation only support String type.
IS NULL, IS NOT NULL, check parameter whether is null, or not.
=TRUE, =FALSE, check parameter whether is true, or false.
1
2
3
4
5
6
7
样例:
(a > 10 AND a < 100) OR (b IS NOT NULL AND b=TRUE)
1
4. selectorExpression 选择器表达式
默认值 ”*5. consumeMode 消费模式
默认值 ConsumeMode.CONCURRENTLY 并行处理
ConsumeMode.ORDERLY 按顺序处理
6. messageModel 消息模型
默认值 MessageModel.CLUSTERING 集群
MessageModel.BROADCASTING 广播
7. consumeThreadMax 最大线程数
默认值 64
 
8. consumeTimeout 超时时间
默认值 30000ms
 
9. accessKey
默认值 ${rocketmq.consumer.access-key:}
 
10. secretKey
默认值 ${rocketmq.consumer.secret-key:}
 
11. enableMsgTrace 启用消息轨迹
默认值 true
 
12. customizedTraceTopic 自定义的消息轨迹主题
默认值 ${rocketmq.consumer.customized-trace-topic:}
没有配置此配置项则使用默认的主题
 
13. nameServer 命名服务器地址
默认值 ${rocketmq.name-server:}
 
14. accessChannel
默认值 ${rocketmq.access-channel:}

十七、案例1

搭建项目

image-20230822150519134

引入依赖

<dependencies>
    <dependency>
        <groupId>org.apache.rocketmq</groupId>
        <artifactId>rocketmq-spring-boot-starter</artifactId>
        <version>2.0.2</version>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>2.0.32</version>
    </dependency>
    <!-- 公共模块-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring_boot_commons</artifactId>
        <version>1.0.0</version>
    </dependency>
</dependencies>

配置文件

rocketmq:
  name-server: 192.168.65.3:9876
  producer:
    group: zgh_group_a
    send-message-timeout: 3000
    compress-message-body-threshold: 4096
    max-message-size: 4194304
    retry-times-when-send-failed: 2
    retry-times-when-send-async-failed: 2
    retry-next-server: false

启动项

@SpringBootApplication
public class SpringBootRocketMQ2Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootRocketMQ2Application.class, args);
    }
}

service包

RocketProviderService

public interface RocketProviderService {

    JsonResult send1(String message);

    JsonResult send2(String message);

    JsonResult send3(String message);

    JsonResult send4(String message);
}

RocketProviderServiceImpl

//是一个Spring组件,可以被其他类注入使用
@Service
//使用了log4j日志框架
@Slf4j
public class RocketProviderServiceImpl implements RocketProviderService {
    //注入了一个RocketMQTemplate对象
    @Resource
    private RocketMQTemplate rocketMQTemplate;

    // 同步消息
    // send1方法指定了需要发送的消息的内容和主题
    public JsonResult send1(String message) {
        // rocketMQTemplate.syncSend方法用于发送同步消息,返回值是一个SendResult对象,包含了发送消息的结果信息
        SendResult result = rocketMQTemplate.syncSend("topic:sync", message);
        log.info("result:{}", result);
        return ResultTool.success();
    }

    //异步消息
    //这个方法中的抛出的异常会被忽略,不会抛到调用者那里
    @SneakyThrows
    // send2方法指定了需要发送的消息的内容和主题
    public JsonResult send2(String message) {
        //用于保存发送消息的结果
        final JsonResult[] result = new JsonResult[1];
        // rocketMQTemplate.asyncSend方法用于发送异步消息,第一个参数是主题,
        // 第二个参数是消息内容,第三个参数是一个SendCallback对象,用于处理发送消息的结果
        // SendCallback中的onSuccess和onException方法会根据发送消息的结果来赋值result[0]
        rocketMQTemplate.asyncSend("topic:async", message, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                log.info("result:{}", sendResult);

                result[0] = ResultTool.success();
            }

            @Override
            public void onException(Throwable throwable) {
                log.info("throwable:{}", throwable.getMessage());
                result[0] = ResultTool.fail(throwable.getMessage());
            }
        });
        //模拟发送消息后等待一段时间再返回结果
        Thread.sleep(1000);
        //返回发送消息的结果
        return result[0];
    }

    //发送单向消息
    @Override
    public JsonResult send3(String message) {
        // rocketMQTemplate.sendOneWay方法用于发送单向消息,第一个参数是主题,第二个参数是消息内容
        rocketMQTemplate.sendOneWay("topic:oneway", message);
        return ResultTool.success();
    }


    // 发送异步消息(Message)
    // Message类是RocketMQ中的消息类,包含了消息的内容、主题、标签等信息
    public JsonResult send4(String message) {
        Message message1 = new Message();
        message1.setKeys("Num1");
        message1.setBody(message.getBytes());
        message1.setTopic("topic");
        message1.setTags("async");
        // rocketMQTemplate.asyncSend方法用于发送异步消息,第一个参数是主题,第二个参数是消息内容,
        // 第三个参数是一个SendCallback对象,用于处理发送消息的结果
        // SendCallback中的onSuccess和onException方法会根据发送消息的结果来赋值result[0]
        rocketMQTemplate.asyncSend("topic", message1, new SendCallback() {
            @Override
            public void onSuccess(SendResult sendResult) {
                //发送消息成功的日志信息
                log.info("1");
            }

            @Override
            public void onException(Throwable throwable) {
                //发送消息失败的日志信息
                log.info("2");
            }
        });
        //返回发送消息的结果
        return ResultTool.success();
    }
}

listener

ConsumerTopicListener

@Component
@Slf4j
//是一个RocketMQ消息监听器,consumerGroup指定了消费者组的名称,topic指定了需要订阅的主题,selectorExpression指定了消息选择器
@RocketMQMessageListener(consumerGroup = "abc02", topic = "topic", selectorExpression = "async")
public class ConsumerTopicListener implements RocketMQListener<String> {
    //重写父类的方法
    @Override
    //onMessage方法是RocketMQ监听器的核心方法,当有消息发送到指定的消费者组和主题时会被调用
    public void onMessage(String s) {
        log.info("我会在消息发送到broker后开始订阅执行");
        log.info("s:{}", s);
    }
}

controller包

FirstController

//表示这是一个Spring MVC控制器
@RestController
//表示这个控制器处理的是所有以/first开头的请求
@RequestMapping("/first")
public class FirstController {
    //注入了一个RocketProviderService对象
    @Resource
    private RocketProviderService service;

    //表示这个控制器处理的是GET请求,/a请求对应的是a方法
    @GetMapping("/a")

    public JsonResult a(String message) {
        //返回send1方法的结果
        // send1方法发送同步消息,返回的是一个JsonResult对象,包含了发送消息的结果信息
        return service.send1(message);
    }

    // @GetMapping注解表示这个控制器处理的是GET请求,/b请求对应的是b方法
    @GetMapping("/b")
    public JsonResult b(String message) {
        // return service.send2(message);返回send2方法的结果
        // send2方法发送异步消息,返回的是一个JsonResult对象,包含了发送消息的结果信息
        // JsonResult是一个包含了返回值和错误信息的类
        return service.send2(message);
    }

    // @GetMapping注解表示这个控制器处理的是GET请求,/c请求对应的是c方法
    @GetMapping("/c")
    public JsonResult c(String message) {
        // return service.send3(message);返回send3方法的结果
        // send3方法发送单向消息,返回的是一个JsonResult对象,包含了发送消息的结果信息
        // JsonResult是一个包含了返回值和错误信息的类
        return service.send3(message);
    }

    // @GetMapping注解表示这个控制器处理的是GET请求,/d请求对应的是d方法
    @GetMapping("/d")
    public JsonResult d(String message) {
        // return service.send4(message);返回send4方法的结果
        // send4方法发送异步消息(Message),返回的是一个JsonResult对象,包含了发送消息的结果信息
        // JsonResult是一个包含了返回值和错误信息的类
        return service.send4(message);
    }
}

测试结果

1、同步消息
image-20230822153141125

image-20230822153211041

image-20230822153242568
image-20230822153319999

2、异步消息

image-20230822153544111

image-20230822153642552

3、发送单向消息

image-20230822153742726
image-20230822162944176

4、发送异步消息(Message)

image-20230823202631261
image-20230822163031054

十八、案例2

基于购物车案例

需求

实现用户订单下单成功后,扣减库存,写入日志和物流信息操作

搭建项目

导入依赖

 <dependencies>
        <dependency>
            <groupId>org.apache.rocketmq</groupId>
            <artifactId>rocketmq-spring-boot-starter</artifactId>
            <version>2.0.2</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>fastjson</artifactId>
            <version>2.0.32</version>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring_boot_commons</artifactId>
            <version>1.0.0</version>
        </dependency>
        <dependency>
            <groupId>mysql</groupId>
            <artifactId>mysql-connector-java</artifactId>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>3.5.2</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba</groupId>
            <artifactId>druid-spring-boot-starter</artifactId>
            <version>1.2.9</version>
        </dependency>
    </dependencies>

配置文件

spring:
  datasource:
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource
    url: jdbc:mysql://localhost:3306/shoppingcart
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: false
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
# RocketMQ 相关配置
rocketmq:
  # 指定 nameServer
  name-server: 192.168.65.3:9876
  # Producer 生产者
  producer:
    group: zgh_group_a # 指定发送者组名
    send-message-timeout: 3000 # 发送消息超时时间,单位:毫秒。默认为 3000 。
    compress-message-body-threshold: 4096 # 消息压缩阀值,当消息体的大小超过该阀值后,进行消息压缩。默认为 4 * 1024B
    max-message-size: 4194304 # 消息体的最大允许大小。。默认为 4 * 1024 * 1024B
    retry-times-when-send-failed: 2 # 同步发送消息时,失败重试次数。默认为 2 次。
    retry-times-when-send-async-failed: 2 # 异步发送消息时,失败重试次数。默认为 2 次。
    retry-next-server: false # 发送消息给 Broker 时,如果发送失败,是否重试另外一台 Broker 。默认为 false

启动项

@SpringBootApplication
public class SpringBootRocketMQ3Application {
    public static void main(String[] args) {
        SpringApplication.run(SpringBootRocketMQ3Application.class, args);
    }
}

数据库准备


# 创建数据库
create database shoppingcart;
use shoppingcart;
create table book
(
    id varchar(100) primary key ,
    name varchar(20) not null unique ,
    `desc` varchar(1000) ,
    price decimal(8,2),
    creationTime datetime,
    img varchar(200) default 'img/default.jpg',
    state int default 1
    # 分类id,出版社id,作者id等
);

create table stock
(
    bookId varchar(100),
    stock bigint,
    foreign key(bookId) references book(id)
);

create table user
(
    id varchar(100) primary key ,
    username varchar(20) not null unique ,
    password varchar(100) not null,
    nickname varchar(20)
);

select UUID();
insert into book values(uuid(),'三国演义','话说....',28.7,now(),default,1);
insert into book values(uuid(),'红楼梦','话说....',99.4,now(),default,1);
insert into book values(uuid(),'西游记','话说....',37.8,now(),default,1);
insert into book values(uuid(),'水浒传','话说....',24.6,now(),default,1);
insert into book values(uuid(),'新华大辞典','话说....',21.3,now(),default,1);
insert into book values(uuid(),'我爱编程','话说....',18.5,now(),default,1);
insert into book values(uuid(),'从编程菜鸟到烧饼铺老板','话说....',22.9,now(),default,1);
insert into book values(uuid(),'大话设计模式','话说....',108.2,now(),default,1);

select * from book;
insert into stock select id,floor(rand()*100+1) from book;
select * from stock;

insert into user values(uuid(),'admin','123456','管理员');

# 创建订单表
create table `order`
(
    id varchar(100) primary key ,
    createdTime datetime default now(),
    subject varchar(100),
    uid varchar(100),
    totalPrice decimal(9,2),
    state int comment '1:未付款 2:已付款',
    recipientAddress varchar(200) comment '收件人地址',
    recipientPhone varchar(15) comment '收件人手机',
    recipientName varchar(30) comment '收件人姓名'
);
#创建订单详情表
create table order_desc
(
    orderid varchar(100),
    bookid varchar(100),
    number int
);
create table logistics
(
    id varchar(100) primary key ,
    recipientAddress varchar(200) comment '收件人地址',
    recipientPhone varchar(15) comment '收件人手机',
    recipientName varchar(30) comment '收件人姓名',
    orderId varchar(100),
    delivery_time datetime comment '发货时间',
    state int default 1
);
select * from `order`;
select * from order_desc;
select * from stock;  # 20,60,37  3 ,4 ,2
select * from logistics;

1、优化中

收集数据,然后拆分数据

bean包

Order

//订单
@Data
public class Order implements Serializable {
    //表示这个字段是一个主键,类型为IdType.ASSIGN_UUID表示使用UUID算法生成主键
    @TableId(type = IdType.ASSIGN_UUID)
    //
    private String id;
    //创建时间
    private String createdTime;
    //订单名称
    private String subject;
    //用户id
    private String uid;
    //总价
    private Double totalPrice;
    //订单状态
    private Integer state;
    //收件人地址
    private String recipientAddress;
    //收件人电话
    private String recipientPhone;
    //收件人姓名
    private String recipientName;
    /*
    @TableField注解用于指定该字段对应的数据库表字段的名称,
    exist = false表示该字段并不存在于数据库表中,只是在Java对象中存在。
    desc和orderDesc,两个字段并不会被存储到数据库表中,只是在Java对象中起到辅助作用。
    可能是用于存储一些额外的信息,或者是为了保持对象的完整性而添加的。
     */
    @TableField(exist = false)
    private List<OrderDesc> desc;
    @TableField(exist = false)
    private String[] orderDesc;
}

OrderDesc

//订单详情
@Data
public class OrderDesc implements Serializable {
    //订单id
    private String orderId;
    //图书id
    private String bookId;
    //订单数
    private Integer number;
}
controller包
@Slf4j
//用于处理HTTP请求
@RestController
//标注该控制器处理的请求路径为/order
@RequestMapping("/order")
public class OrderController {
    @PostMapping
    public JsonResult save(Order order) {
         通过日志记录器输出日志信息
        log.info("order:{}", order);
        //返回成功的JSON响应,其中ResultTool是一个工具类,success()方法返回一个表示成功的JSON对象。
        return ResultTool.success();
    }
}
测试结果

1、ApiPost结果

image-20230822192922905

2、后端控制台输出

image-20230822193108911

2、优化中

mapper包
/*
@Mapper注解告诉编译器,该接口是一个Mapper接口,需要被MyBatis-Plus管理。
因此,这个Mapper接口可以在需要使用MyBatis-Plus的地方被引用,比如Service或者DAO接口中的方法。
 */
@Mapper
//继承了BaseMapper<Order>,意味着它可以使用BaseMapper提供的CRUD方法,以及一些其他的便利方法。
public interface OrderMapper extends BaseMapper<Order> {
}
service包

OrderServiceImpl

/*
OrderMapper和Order:
通过@Mapper注解将OrderMapper接口注入到OrderServiceImpl中,
用于实现对Order实体的CRUD操作。
 */
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {

    @Override
    public JsonResult saveOrder(Order order) {
        //生成订单
        log.info("order:{}", order);
        //生成订单详情(即订单中每本书的数量)
        String[] desc = order.getOrderDesc();
        //创建一个空的订单详情列表
        List<OrderDesc> descList = new ArrayList<>();
        //遍历订单详情
        for (int i = 0; i < desc.length; i++) {
            //获取每条订单详情
            String s = desc[i];
            //将每条订单详情按照分号分割成两个部分
            String[] liang = s.split("%");
            //创建一个新的订单详情对象
            OrderDesc orderDesc = new OrderDesc();
            //设置订单详情中书的ID
            orderDesc.setBookId(liang[0]);
            //设置订单详情中书的数量
            orderDesc.setNumber(Integer.parseInt(liang[1]));
            //将每个订单详情添加到订单详情列表中
            descList.add(orderDesc);
        }
        log.info("descList:{}", descList);
        //返回成功的JSON响应,其中ResultTool是一个工具类,success()方法返回一个表示成功的JSON对象。
        return ResultTool.success();
    }
}
测试结果

1、ApiPost结果

image-20230822200449285

2、后端控制台输出

image-20230822200426460

3、优化中

操作数据库的事务

bean包

Logistics

@Data
public class Logistics implements Serializable {
    private String id;
    private Integer state;
    private String recipientAddress;
    private String recipientPhone;
    private String recipientName;
    private String orderId;
    private String deliveryTime;
}
mapper包

OrderMapper

/*
@Mapper注解告诉编译器,该接口是一个Mapper接口,需要被MyBatis-Plus管理。
因此,这个Mapper接口可以在需要使用MyBatis-Plus的地方被引用,比如Service或者DAO接口中的方法。
 */
//添加订单
@Mapper
//继承了BaseMapper<Order>,意味着它可以使用BaseMapper提供的CRUD方法,以及一些其他的便利方法。
public interface OrderMapper extends BaseMapper<Order> {
    @Insert("insert into `order` values(#{id},now(),#{subject},#{uid},#{totalPrice},1,#{recipientAddress},#{recipientPhone},#{recipientName})")
    void saveOrder(Order order);
}

OrderDescMapper

//添加订单详情
@Mapper
public interface OrderDescMapper extends BaseMapper<OrderDesc> {
    @Insert("insert into order_desc values(#{orderId},#{bookId},#{number})")
    void saveOrderDesc(OrderDesc orderDesc);
}

LogisticsMapper

//往数据库中的录入数据
@Mapper
public interface LogisticsMapper {
    @Insert("insert into logistics values (#{id},#{recipientAddress},#{recipientPhone},#{recipientName},#{orderId},null,1)")
    void saveLogistics(Logistics logistics);
}

StockMapper

//更新库存
@Mapper
public interface StockMapper {
    @Update("update stock set stock=stock-${number} where bookId=#{bookId}")
    void updateStock(@Param("bookId") String bookId, @Param("number") int number);
}
listener包

LogisticsListener

//物流监控
@Component
@Slf4j
//标注该类是一个RocketMQ消息监听器,指定了消费者组名称、消息选择器、消息主题。
@RocketMQMessageListener(consumerGroup = "order_a_1", selectorExpression = "order", topic = "order")
//RocketMQListener:实现了RocketMQListener接口,用于实现消息监听器的具体逻辑。
public class LogisticsListener implements RocketMQListener<String> {
    // 注入LogisticsMapper对象
    @Resource
    private LogisticsMapper mapper;

    @Override
    //实现了RocketMQListener接口中的onMessage方法,用于处理接收到的消息。
    public void onMessage(String s) {
        //通过日志记录器输出日志信息,其中s是接收到的消息。
        log.info("获取到了物流信息:{}", s);
        // 将物流信息转换为Order对象
        Order order = JSONArray.parseObject(s, Order.class);
        // 创建Logistics对象
        Logistics logistics = new Logistics();
        // 设置Logistics的id、orderid、收件人姓名、收件人电话、收件人地址
        
        //为Logistics对象设置一个唯一的id
        logistics.setId(UUID.randomUUID().toString());
        //将Order对象的id设置为Logistics对象的orderid
        logistics.setOrderId(order.getId());
        //将Order对象的收件人姓名设置为Logistics对象的recipientName。
        logistics.setRecipientName(order.getRecipientName());
        //将Order对象的收件人电话设置为Logistics对象的recipientPhone。
        logistics.setRecipientPhone(order.getRecipientPhone());
        //将Order对象的收件人地址设置为Logistics对象的recipientAddress。
        logistics.setRecipientAddress(order.getRecipientAddress());
        
        // 将Logistics对象保存到数据库中。其中mapper是通过@Mapper注解注入的LogisticsMapper对象,
        // 用于实现对Logistics实体的CRUD操作。
        mapper.saveLogistics(logistics);
    }
}

StockListener

//监控库存信息
@Component
@Slf4j
//标注该类是一个RocketMQ消息监听器,指定了消费者组名称、消息选择器、消息主题。
@RocketMQMessageListener(consumerGroup = "order_a_2", selectorExpression = "desc", topic = "order")
//实现了RocketMQListener接口,用于实现消息监听器的具体逻辑
public class StockListener implements RocketMQListener<String> {
    @Resource
    private StockMapper mapper;

    //onMessage:实现了RocketMQListener接口中的onMessage方法,用于处理接收到的消息
    @Override
    public void onMessage(String s) {
        //将接收到的消息转换为OrderDesc对象列表
        List<OrderDesc> list = JSONArray.parseArray(s, OrderDesc.class);
        //遍历OrderDesc对象列表,执行指定的操作
        list.forEach(e -> {
            //更新Book的库存。其中mapper是通过@Mapper注解注入的StockMapper对象,用于实现对Stock实体的CRUD操作。
            mapper.updateStock(e.getBookId(), e.getNumber());
        });
    }
}
service包
/*
OrderMapper和Order:
通过@Mapper注解将OrderMapper接口注入到OrderServiceImpl中,
用于实现对Order实体的CRUD操作。
 */
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {

    @Resource
    private OrderDescMapper orderDescMapper;
    @Resource
    private RocketMQTemplate rocketMQTemplate;
    @Override
    public JsonResult saveOrder(Order order) {
        //生成订单
        log.info("order:{}", order);
        //生成订单详情(即订单中每本书的数量)
        String[] desc = order.getOrderDesc();
        //创建一个空的订单详情列表
        List<OrderDesc> descList = new ArrayList<>();
        //遍历订单详情
        for (int i = 0; i < desc.length; i++) {
            //获取每条订单详情
            String s = desc[i];
            //将每条订单详情按照分号分割成两个部分
            String[] liang = s.split("%");
            //创建一个新的订单详情对象
            OrderDesc orderDesc = new OrderDesc();
            //设置订单详情中书的ID
            orderDesc.setBookId(liang[0]);
            //设置订单详情中书的数量
            orderDesc.setNumber(Integer.parseInt(liang[1]));
            //将每个订单详情添加到订单详情列表中
            descList.add(orderDesc);
        }
        log.info("descList:{}", descList);
        // 生成一个orderId
        String orderId = UUID.randomUUID().toString();
        order.setId(orderId);
        // 对订单表进行操作
        getBaseMapper().saveOrder(order);
        // 对订单详情表进行操作
        descList.forEach(e -> {
            e.setOrderId(orderId);
            orderDescMapper.saveOrderDesc(e);
        });
        // 生产一笔订单消息 物流监控(同步消息)
        rocketMQTemplate.syncSend("order:order", JSONArray.toJSONString(order));
        // 生产一笔订单详情消息 库存监控(同步消息)
        rocketMQTemplate.syncSend("order:desc", JSONArray.toJSONString(descList));
        //返回成功的JSON响应,其中ResultTool是一个工具类,success()方法返回一个表示成功的JSON对象。
        return ResultTool.success();
    }
}
测试结果

1、ApiPost结果

image-20230822211713326

2、RocketMQ面板结果

image-20230822211657593image-20230822211851918
image-20230822211914501

image-20230822211930133

3、查看数据库

image-20230822212545031

4、优化中

问题:当购物车中点击下单,在购物车处已经验证过库存量了,但是当用户开始付款时,如果库存不足,不能购物成功,解决方法?

1、事务方式:

当用户点击付款的操作之后,让库存和订单进行事务绑定,改写为半消息机制,如果库存扣减失败,告知事务的生产者,把消息进行回滚,告诉其订单失败。

2、上锁+延迟消息方式:

当用户点击付款的一瞬间,对库存进行上锁操作,然后采用延迟订单来解决。因为用户在填写订单时,需要时间,所以将库存锁住。

本项目采用第一种解决

业务流程

一个生产者,两个消费者(库存消费者消费方式发生变化-——>事务监听的方式)

1、生产者生产一个半消息到broker,broker进行事务确认的操作——>一个监听器;

2、在事务确认中有三种状态,COMMIT、ROLLBACK、UNKNOW;

3、如果时COMMIT,说明确认了,半消息会变成普通消息,就可以被消费者所订阅了;如果是ROLLBACK,生产者生产消息就撤销了;如果是UNKNOW,不知道,不用填写,出现问题会自动返回给UNKNOW,比如宕机。

4、先生产库存消息——>半消息,生产至监听器监听到,在监听器里面进行对库存进行增减操作。

5、如果报错了,不管对几个商品的增减,先把本地事务进行回滚,然后对生产者ROLLBACK,当生产者接收到ROLLBACK请求之后,那么刚才的事务生产就结束了,后面的代码就不执行了,告诉用户下单失败,有商品库存不足。

6、如果事务执行完成后都成功了,就在监听器里面返回一个COMMIT,这边收到COMMIT之后,将消息转为全消息(普通消息),然后再去生产物流消息,再去监听物流消息,完成对应的操作。

前期准备

修改库存

image-20230823113822642

stock要大于等于0,因为小于0会报异常,事务执行会失败,所以需要加约束。

image-20230823114409443

或者手动添加

alter table stock
    add constraint stock_1
        check ( stock >= 0 );

测试一下

image-20230823120325201

image-20230823120321098

image-20230823120415960

后端部分
service包
/*
OrderMapper和Order:
通过@Mapper注解将OrderMapper接口注入到OrderServiceImpl中,
用于实现对Order实体的CRUD操作。
 */
@Service
@Slf4j
public class OrderServiceImpl extends ServiceImpl<OrderMapper, Order> implements OrderService {

    @Resource
    private OrderDescMapper orderDescMapper;
    @Resource
    private RocketMQTemplate rocketMQTemplate;

//    @Transactional(rollbackFor = SQLException.class)
//    public JsonResult saveOrder(Order order) {
//        //生成订单
//        log.info("order:{}", order);
//        //生成订单详情(即订单中每本书的数量)
//        String[] desc = order.getOrderDesc();
//        //创建一个空的订单详情列表
//        List<OrderDesc> descList = new ArrayList<>();
//        //遍历订单详情
//        for (int i = 0; i < desc.length; i++) {
//            //获取每条订单详情
//            String s = desc[i];
//            //将每条订单详情按照分号分割成两个部分
//            String[] liang = s.split("%");
//            //创建一个新的订单详情对象
//            OrderDesc orderDesc = new OrderDesc();
//            //设置订单详情中书的ID
//            orderDesc.setBookId(liang[0]);
//            //设置订单详情中书的数量
//            orderDesc.setNumber(Integer.parseInt(liang[1]));
//            //将每个订单详情添加到订单详情列表中
//            descList.add(orderDesc);
//        }
//        log.info("descList:{}", descList);
//        // 生成一个orderId
//        String orderId = UUID.randomUUID().toString();
//        order.setId(orderId);
//        // 对订单表进行操作
//        getBaseMapper().saveOrder(order);
//        // 对订单详情表进行操作
//        descList.forEach(e -> {
//            e.setOrderId(orderId);
//            orderDescMapper.saveOrderDesc(e);
//        });
//        // 生产一笔订单消息 物流监控(同步消息)
//        rocketMQTemplate.syncSend("order:order", JSONArray.toJSONString(order));
//        // 生产一笔订单详情消息 库存监控(同步消息)
//        rocketMQTemplate.syncSend("order:desc", JSONArray.toJSONString(descList));
//        //返回成功的JSON响应,其中ResultTool是一个工具类,success()方法返回一个表示成功的JSON对象。
//        return ResultTool.success();
//    }

    @Transactional(rollbackFor = SQLException.class)
    public JsonResult saveOrder(Order order) {
        //生成订单
        log.info("order:{}", order);
        //生成订单详情
        String[] desc = order.getOrderDesc();
        List<OrderDesc> descList = new ArrayList<>();
        for (int i = 0; i < desc.length; i++) {
            String s = desc[i];
            String[] liang = s.split("%");
            OrderDesc orderDesc = new OrderDesc();
            orderDesc.setBookId(liang[0]);
            orderDesc.setNumber(Integer.parseInt(liang[1]));
            descList.add(orderDesc);
        }
        log.info("descList:{}", descList);
        // 生成一个orderId
        String orderId = UUID.randomUUID().toString();
        order.setId(orderId);
        // 生产一笔订单详情消息 库存监控(事务消息)
        Message<String> build = MessageBuilder.withPayload("hello").setHeader("KEYS", "1").build();
        TransactionSendResult tx01 = rocketMQTemplate.sendMessageInTransaction("eex01", "order:desc", build, JSONArray.toJSONString(descList));
        if (tx01.getLocalTransactionState().equals(LocalTransactionState.COMMIT_MESSAGE)) {
            //对订单表进行操作
            getBaseMapper().saveOrder(order);
            //对订单详情进行操作
            descList.forEach(e -> {
                e.setOrderId(orderId);
                orderDescMapper.saveOrderDesc(e);
            });
            //生产一笔订单消息,物流监控
            rocketMQTemplate.syncSend("order:order", JSONArray.toJSONString(order));
            return ResultTool.success();
        }
        return ResultTool.fail("库存不足");

    }
}
listener包
@Component
@Slf4j
//表示该类是一个RocketMQ事务监听器,其中txProducerGroup属性指定了该监听器所属的事务生产者组。
@RocketMQTransactionListener(txProducerGroup = "tx01")
public class CheckStockListener implements RocketMQLocalTransactionListener {
    //将StockMapper对象注入到了该类中,用于执行数据库操作。
    @Resource
    private StockMapper mapper;

    @Transactional(rollbackFor = RuntimeException.class)
    //executeLocalTransaction方法:该方法是RocketMQ本地事务监听器的核心方法,用于执行本地事务
   // message参数表示发送的消息,o参数表示发送消息的目标地址。
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
        //将目标地址转换为一个List<OrderDesc>对象,其中OrderDesc是一个JavaBean,表示一条订单描述信息。
        List<OrderDesc> descList = JSONArray.parseArray(o.toString(), OrderDesc.class);
        try {
            //遍历该列表,对每一条订单描述信息执行数据库操作,即更新库存数量。
            descList.forEach(e -> {
                mapper.updateStock(e.getBookId(), e.getNumber());
            });
        } catch (Exception e) {
            //执行数据库操作过程中发生异常,则抛出一个RuntimeException异常
            throw new RuntimeException(e);
            //return RocketMQLocalTransactionState.ROLLBACK;
        }
        //本地事务提交
        return RocketMQLocalTransactionState.COMMIT;
    }

    //RocketMQ本地事务监听器的一个抽象方法,用于在发送消息前检查本地事务。在该方法中,我们返回null,表示不需要进行本地事务检查。
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
        return null;
    }
}
测试结果

1、ApiPost测试

image-20230823172611270

2、查看库存:库存不足,就会报异常

image-20230823172235064

3、控制台输出

image-20230823172726675

4、查看订单

image-20230823172954438

image-20230823173202294
image-20230823173103886

5、优化中

handler包
//全局异常处理

//声明为全局异常处理类
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
    //使用@ExceptionHandler注解指定该方法处理的异常类型是UnexpectedRollbackException.class
    @ExceptionHandler(UnexpectedRollbackException.class)
    public JsonResult unexpectedRollbackException(Throwable e) {
        //返回json格式的结果,代码中使用了ResultTool.fail方法,该方法返回指定的json格式的数据
        //e.getMessage()返回异常的消息内容
        return ResultTool.fail(e.getMessage());
    }
}

会出现一个问题,从订单页面不能获取所有的参数信息

下表是各个参数所对应的获取方式

image-20230823180304348

前端部分
引入框架

image-20230823180802187

shoppingCart.html

image-20230823182740072

<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8">
		<title>商品购物车</title>
		<link rel="shortcut icon" href="./../../favicon.ico">
		<link rel="stylesheet" href="../../style/cart/shoppingCart.css">
		<script type="text/javascript" src="./../../plugins/jQuery/jquery-3.6.0.js"></script>
		<script type="text/javascript" src="./../../plugins/vue/vue.js"></script>
		<script type="text/javascript" src="./../../plugins/axios/axios.js"></script>
		<!-- 引入样式 -->
		<link rel="stylesheet" href="./../../plugins/element-ui/index.css">
		<!-- 引入组件库 -->
		<script src="./../../plugins/element-ui/index.js"></script>
		<!-- 引入layer -->
		<script src="../../plugins/layer-master/dist/layer.js"></script>
		<style>
			.el-table {
				margin: 0 auto;
			}
		</style>
	</head>
	<body>
		<div id="app">
			<nav class="public-header">
				<a href="../../index.html">网站首页</a>
				<a href="">我的订单</a>
				<a href="">我的地址</a>
			</nav>
			<div class="cart">
				<template>
					<el-table ref="multipleTable" :data="shoppingCart" tooltip-effect="dark" style="width: 87%"
						@selection-change="handleSelectionChange">
						<el-table-column type="selection" width="55" align="center">
						</el-table-column>
						<el-table-column label="商品" width="220" align="center">
							<template slot-scope="scope">
								<img width="120px" :src="'../../'+scope.row.img">
							</template>
						</el-table-column>
						<el-table-column label="书名" align="center">
							<template slot-scope="scope">
								{{scope.row.name}}
							</template>
						</el-table-column>
						<el-table-column label="备注" align="center">
							<template slot-scope="scope">
								{{scope.row.desc}}
							</template>
						</el-table-column>
						<el-table-column prop="price" label="单价" width="120" align="center">
							<template slot-scope="scope">¥{{ scope.row.price }}元</template>
						</el-table-column>
						<el-table-column prop="number" label="数量" show-overflow-tooltip align="center" width="200px">
							<template slot-scope="scope">
								<el-button size="mini" type="danger"
									@click="cartQuantityMinus(scope.row)">-1</el-button>
								<input type="text" style="width: 50px; height: 23px" v-model.number="scope.row.number"
									@change="OutOfFocus(scope.row)" @keydown.enter="OutOfFocus(scope.row)">
								<el-button size="mini" type="success"
									@click="cartQuantityPlus(scope.row)">+1</el-button>
							</template>
						</el-table-column>
						<el-table-column prop="address" label="小计" show-overflow-tooltip align="center" width="120">
							<template
								slot-scope="scope">¥{{ (scope.row.price * scope.row.number).toFixed(2) }}元</template>
						</el-table-column>
						<el-table-column prop="address" label="操作" show-overflow-tooltip align="center" width="120">
							<template slot-scope="scope">
								<el-button size="mini" type="danger" @click="deleteItem(scope.row)" align="center">删除
								</el-button>
							</template>
						</el-table-column>
					</el-table>
					<el-button type="danger" class="dangers" v-if="selectedItemList.length > 0" id="batchDeleteBtn"
						@click="clearSelectedCart">批量删除
					</el-button>
					<el-button type="danger" class="dangers" id="batchDeleteBtn2" @click="clearAllCart">清空购物车
					</el-button>
					<div class="cart-btn"><span>总价:</span><span> ¥{{totalPrice}} </span>
						<button class="btn" @click="settleAccounts">去结算</button>
					</div>
				</template>
				<!-- 页脚部分 -->
				<div class="extra">
					<div class="wrapper">
						<!-- 版权信息 -->
						<div class="copyright">
							<p>
								<a href="javascript:;">关于我们</a>
								<a href="javascript:;">帮助中心</a>
								<a href="javascript:;">售后服务</a>
								<a href="javascript:;">配送与验收</a>
								<a href="javascript:;">商务合作</a>
								<a href="javascript:;">搜索推荐</a>
								<a href="javascript:;">友情链接</a>
							</p>
							<p>CopyRight &copy; 湖北理工学院</p>
						</div>
					</div>
				</div>
			</div>
		</div>

		<script>
			const vm = new Vue({
				el: "#app",
				data: {
					userId: "",
					cartItems: [], //原生
					// 选中了的购物项列表
					selectedItemList: [],
					shoppingCart: [] // 购物车列表
				},
				methods: {
					// 获取用户购物车
					findShoppingCartByUser() {
						let _this = this
						axios.get('http://localhost:8080/user/cart', {
							headers: {
								token: window.localStorage.getItem('token')
							}
						}).then((response) => {
							// 未登录
							if (response.data.code - 0 === 401) {
								location.href = '../login/login.html'
								return;
							}
							// 给购物车列表赋值
							_this.shoppingCart = response.data.data.books
						})
					},
					// 购物车数量减1
					cartQuantityMinus(row) {
						// 判断减完后和0的关系
						if (row.number - 1 <= 0) {
							// 提示用户,是否将该商品移除购物车
							// 调用删除该商品的函数
							this.deleteItem(row)
							return;
						}
						// 后台redis减一
						axios.get('http://localhost:8080/user/change_cart_number', {
							params: {
								bookId: row.id,
								number: row.number - 1
							},
							headers: {
								token: window.localStorage.getItem('token')
							}
						}).then((response) => {
							// 数字减一
							row.number = row.number - 1
						})
					},
					// 购物车数量加1
					cartQuantityPlus(row) {
						// 考虑库存信息
						let _this = this
						axios.get('http://localhost:8080/book/check_stock', {
							params: {
								bid: row.id,
								number: row.number + 1
							},
							headers: {
								token: window.localStorage.getItem('token')
							}
						}).then((response) => {
							console.log(response.data)
							if (response.data.success) {
								// 库存满足 +1
								axios.get('http://localhost:8080/user/change_cart_number', {
									params: {
										bookId: row.id,
										number: row.number + 1
									},
									headers: {
										token: window.localStorage.getItem('token')
									}
								}).then((response) => {
									// 数字减一
									row.number = row.number + 1
								})
							} else {
								this.$message({
									message: row.name + "库存不足",
									type: 'error',
									duration: 1000
								});
							}
						})

					},
					// input数量修改失焦事件
					OutOfFocus(row) {
						if (row.number >= 1) {
							axios({
								method: "put",
								url: "/cart",
								data: {
									id: row.id,
									number: row.number
								}
							}).then(res => {
								if (String(res.data.code) === "1") {
									this.$message({
										message: "数量修改成功",
										type: 'success',
										duration: 1000
									});
								} else {
									this.$message({
										message: "数量修改失败",
										type: 'error',
										duration: 1000
									});
								}
							})
						} else {
							this.$message({
								message: "购物车数量至少为1",
								type: 'error',
								duration: 1000
							});
							row.number = 1
						}
					},
					// 获取所有被选中的购物项
					handleSelectionChange(items) {
						this.selectedItemList = items;
					},

					// 删除图书
					deleteItem(row) {
						let _this = this
						// 1. 确定是否删除
						this.$confirm(`您确定要删除【${row.name}】图书吗?`, '提示', {
							confirmButtonText: '确定',
							cancelButtonText: '取消',
							type: 'warning'
						}).then(() => {
							// 2. 删除对应图书项
							axios.get('http://localhost:8080/user/delete_shopping_cart', {
								params: {
									bookId: row.id
								},
								headers: {
									token: window.localStorage.getItem('token')
								}
							}).then((response) => {
								if (response.data.success) {
									_this.findShoppingCartByUser()
								}
							})
						}).catch(() => {
							// 3. 点击取消按钮提示
							this.$message({
								type: 'info',
								message: '已取消删除'
							});
						});
					},
					// 批量删除 原生
					batchDelete() {
						if (this.selectedItemList.length <= 0) {
							return;
						}
						// 1. 确定是否删除
						this.$confirm('您确定要删除这' + this.selectedItemList.length + '个购物项吗?', '提示', {
							confirmButtonText: '确定',
							cancelButtonText: '取消',
							type: 'warning'
						}).then(() => {
							let ids = [];
							this.selectedItemList.forEach(item => {
								ids.push(item.id);
							});
							// 2. 删除对应图书项
							axios({
								method: "delete",
								url: "/cart?ids=" + ids.join(",")
							}).then(res => {
								if (String(res.data.code === "1")) {
									// 2.1 删除成功提示
									this.$message({
										type: 'success',
										message: '删除成功!'
									});
								} else {
									// 2.2 删除失败提示
									this.$message({
										type: 'error',
										message: '删除失败!'
									});
								}
								// 3. 重新发送请求获取购物车列表
								this.getAllCartItems();
							})
						}).catch(() => {
							// 3. 点击取消按钮提示
							this.$message({
								type: 'info',
								message: '已取消删除'
							});
						});
					},
					// 清空购物车
					clearAllCart() {
						let _this = this
						axios.get('http://localhost:8080/user/clear_all_shopping_cart', {
							headers: {
								token: window.localStorage.getItem('token')
							}
						}).then((response) => {
							if (response.data.success) {
								_this.findShoppingCartByUser()
							}
						})
					},
					// 批量删除
					clearSelectedCart() {
						let _this = this
						let data = new URLSearchParams()
						for (let i = 0; i < _this.selectedItemList.length; i++) {
							//console.log(_this.selectedItemList[i])
							//bookIds1[i] = _this.selectedItemList[i].id
							data.append('bookIds', _this.selectedItemList[i].id)
						}

						//console.log(bookIds1)
						/* axios.get('http://localhost:8080/user/clear_shopping_cart', {
							params: {
								bookIds: bookIds1
							},
							headers: {
								token: window.localStorage.getItem('token')
							}
						}).then((response) => {
							if (response.data.success) {
								_this.findShoppingCartByUser()
							}
						}) */
						axios({
							method: 'post',
							url: 'http://localhost:8080/user/clear_shopping_cart',
							headers: {
								token: window.localStorage.getItem('token')
							},
							data: data
						}).then((response) => {
							if (response.data.success) {
								_this.findShoppingCartByUser()
							}
						})

					},
					// 结账
					settleAccounts() {
						if (this.selectedItemList.length <= 0) {
							return this.$message({
								message: "请选择您要结算的商品",
								type: "warning",
								duration: 2000
							})
						}
						// 保存totalPrice
						sessionStorage.setItem('totalPrice', this.totalPrice)
						let arr1 = [];
						for (let i = 0; i < this.selectedItemList.length; i++) {
							let str = this.selectedItemList[i]
							arr1[i] = str.id + '%' + str.number
						}
						// 保存选中的图书
						sessionStorage.setItem('selectItemBook', arr1)
						location.href = 'order.html'
					}
				},
				computed: {
					// 总价格
					totalPrice() {
						return this.selectedItemList.reduce((sum, item) => {
							sum = parseFloat(sum);
							let price = parseFloat(item.price);
							let number = parseFloat(item.number);
							sum += (price * number);
							return sum.toFixed(2);
						}, 0);
					},
				},
				created() {
					// 获取用户的购物车列表
					// this.getAllCartItems();
					this.findShoppingCartByUser()
				}
			})
		</script>
	</body>
</html>

新建一个页面

order.htmml
<!DOCTYPE html>
<html>
	<head>
		<meta charset="utf-8">
		<title></title>
	</head>
	<body>
		<div id="app">
			收货人姓名:<input type='text' v-model="recipientName" /><br />
			收货人电话:<input type='text' v-model="recipientPhone" /><br />
			收货人地址:<input type='text' v-model="recipientAddress" /><br />
			<button @click="toPay">付款</button>
		</div>
	</body>
</html>
<script src="../../plugins/vue/vue.js"></script>
<script src="../../plugins/axios/axios.js"></script>
<script>
	new Vue({
		el: '#app',
		data() {
			return {
				recipientName: '',
				recipientPhone: '',
				recipientAddress: '',
				totalPrice: 0,
				selectItemBook: []
			}
		},
		methods: {
			toPay() {
				let data = new URLSearchParams()
				data.append('recipientName', this.recipientName)
				data.append('recipientPhone', this.recipientPhone)
				data.append('recipientAddress', this.recipientAddress)
				data.append('totalPrice', this.totalPrice)
				for (let i = 0; i < this.selectItemBook.length; i++) {
					data.append('orderDesc', this.selectItemBook[i])
				}
				axios({
					url: 'http://localhost:8081/order',
					data: data,
					method: 'post',
					headers: {
						token: window.localStorage.getItem('token')
					}
				}).then(response => {
					alert(response.data);
				})
			}
		},
		created() {
			this.totalPrice = window.sessionStorage.getItem('totalPrice')
			let a = window.sessionStorage.getItem('selectItemBook')
			this.selectItemBook = a.split(',')
			console.log(this.selectItemBook)
		}
	})
</script>
修改

1、开启跨域

image-20230823190748607

@CrossOrigin

2、需要开启两个项目,端口不能重复

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-oNiZPQps-1692792392311)(https://gitee.com/zgh-study-news/zgh_local-images/raw/master/images/202308231850057.png)]

spring:
  datasource:
    username: root
    password: root
    driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource
    url: jdbc:mysql://localhost:3306/shoppingcart
mybatis-plus:
  configuration:
    map-underscore-to-camel-case: false
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
rocketmq:
  name-server: 192.168.65.3:9876
  producer:
    group: zgh_group_a
    send-message-timeout: 3000
    compress-message-body-threshold: 4096
    max-message-size: 4194304
    retry-times-when-send-failed: 2
    retry-times-when-send-async-failed: 2
    retry-next-server: false
server:
  port: 8081

image-20230823185155847

3、其他参数都获取到了,没userid

OrderController
image-20230823185958380


@Slf4j
//用于处理HTTP请求
@RestController
//标注该控制器处理的请求路径为/order
@RequestMapping("/order")
public class OrderController {

    //@Resource注解可以自动找到名称为OrderService的Service实例,并将其注入到Mapper中。
    @Resource
    private OrderService service;

    @PostMapping
    public JsonResult save(Order order, HttpServletRequest request) {
        // 通过日志记录器输出日志信息
        log.info("order:{}", order);
        //返回成功的JSON响应
        return service.saveOrder(order,request.getHeader("token"));
    }
}

OrderService

image-20230823190116066

public interface OrderService extends IService<Order> {
    JsonResult saveOrder(Order order, String token);
}

OrderServiceImpl

image-20230823190403890

 @Transactional(rollbackFor = SQLException.class)
    public JsonResult saveOrder(Order order,String token) {
        // 验证用户是否登陆 没有验证
        Claims claims = JwtConfig.parseJWT(token);
        String userId = claims.get("id").toString();
        order.setUid(userId);
        //生成订单
        log.info("order:{}", order);
        //生成订单详情(即订单中每本书的数量)
        String[] desc = order.getOrderDesc();
        //创建一个空的订单详情列表
        List<OrderDesc> descList = new ArrayList<>();
        //遍历订单详情
        for (int i = 0; i < desc.length; i++) {
            //获取每条订单详情
            String s = desc[i];
            //将每条订单详情按照分号分割成两个部分
            String[] liang = s.split("%");
            //创建一个新的订单详情对象
            OrderDesc orderDesc = new OrderDesc();
            //设置订单详情中书的ID
            orderDesc.setBookId(liang[0]);
            //设置订单详情中书的数量
            orderDesc.setNumber(Integer.parseInt(liang[1]));
            //将每个订单详情添加到订单详情列表中
            descList.add(orderDesc);
        }
        log.info("descList:{}", descList);
        // 生成一个orderId
        String orderId = UUID.randomUUID().toString();
        order.setId(orderId);
        // 生产一笔订单详情消息 库存监控  事务消息
        Message<String> build = MessageBuilder.withPayload("hello").setHeader("KEYS", "1").build();
        TransactionSendResult tx01 = rocketMQTemplate.sendMessageInTransaction("tx01", "order:desc", build, JSONArray.toJSONString(descList));
        if (tx01.getLocalTransactionState().equals(LocalTransactionState.COMMIT_MESSAGE)) {
            // 对订单表进行操作
            getBaseMapper().saveOrder(order);
            // 对订单详情表进行操作
            descList.forEach(e -> {
                e.setOrderId(orderId);
                orderDescMapper.saveOrderDesc(e);
            });
            // 生产一笔订单消息 物流监控
            rocketMQTemplate.syncSend("order:order", JSONArray.toJSONString(order));
            return ResultTool.success();
        }
        return ResultTool.fail("库存不足");
    }
测试结果

开启两个项目

image-20230823193849730

1、

在这里插入图片描述

2、

image-20230823192327110
3、

image-20230823192315653

4、

image-20230823192305486

5、

image-20230823192253631

6、

image-20230823192240526

7、

image-20230823193329079

8、

image-20230823192457316

添加成功!!!

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值