什么是中间件
消息中间件(Message Queue,MQ)是基于队列与消息传递技术,在网络环境中为应用系统提供同步或异步、可靠的消息传输的支撑性软件系统。
消息中间件是在分布式系统中完成消息的发送和接收的基础工具。消息中间件也可以称消息队列,是指用高效可靠的消息传递机制进行与平台无关的数据交流,并基于数据通信来进行分布式系统的集成。通过提供消息传递和消息队列模型,可以在分布式环境下扩展进程的通信。
RPC是远程过程调用(Remote Procedure Call)的缩写形式。SAP系统RPC调用的原理其实很简单,有一些类似于三层构架的C/S系统,第三方的客户程序通过接口调用SAP内部的标准或自定义函数,获得函数返回的数据进行处理后显示或打印。
消息中间件的应用场景
应用解耦
通常的写法是(左图),用户下单后,订单系统需要分别调用支付系统、物流系统和库存系统,假如物流系统无法访问,则订单出库将失败,从而导致订单失败。
那么如何解决呢?
我们可以引入消息队列机制,在用户下单后,订单系统完成持久化处理,将消息写入消息队列,直接返回用户订单下单成功,支付系统、物流系统和库存系统分别从消息队列中订阅订单信息,进行各自相应操作。
流量削峰
流量削锋也是消息队列中的常用场景,一般在秒杀或团抢活动中使用广泛。秒杀活动,一般会因为流量过大,导致流量暴增,应用挂掉。为解决这个问题,一般需要在应用前端加入消息队列。
例如过去的情况下,每秒可能会有5K个请求访问A系统,A系统需要将用户的订单写入到 MySQL 中进行持久化操作,但是 MySQL 最大只能处理200个请求,就会出现大量请求积攒,最后可能压垮我们的服务器。
引入消息队列后,用户的请求北服务器接收后,写入消息队列中,用户的订单请求就结束了,A系统在每秒从队列中拉取200个请求进行持久化操作。是一种时间换空间的操作。也就是说如果我们的高峰时间为10S,那么我们对应的处理时间就是250S。
异步处理
用户注册后,需要发注册邮件和注册短信。
传统的做法有两种 :
- 串行方式:将注册信息写入数据库成功后,发送注册邮件,再发送注册短信。以上三个任务全部完成后,返回给客户端。
- 并行方式:将注册信息写入数据库成功后,发送注册邮件的同时,发送注册短信。以上三个任务完成后,返回给客户端。与串行的差别是,并行的方式可以提高处理的时间。
假设三个业务节点每个使用50毫秒钟,不考虑网络等其他开销,则串行方式的时间是150毫秒,并行的时间可能是100毫秒。因为CPU在单位时间内处理的请求数是一定的,假设CPU1秒内吞吐量是100次。则串行方式1秒内CPU可处理的请求量是7次(1000/150)。并行方式处理的请求量是10次(1000/100)。
如果使用消息队列,用户的响应时间相当于是注册信息写入数据库的时间,也就是50毫秒。注册邮件,发送短信写入消息队列后,直接返回,因为写入消息队列的速度很快,基本可以忽略,因此用户的响应时间可能是50毫秒。因此架构改变后,系统的吞吐量提高到每秒20 QPS。比串行提高了3倍,比并行提高了两倍。
中间件中的各个角色
NameServer 角色
NameServer 负责维护 Producer 和 Consumer 的配置信息、状态信息,并且协调各个角色的协同执行,类似于服务的注册与发现中心。通过 NameServer 各个角色可以了解到集群的整体信息,并且他们会定期向 NameServer 上报状态。
Broker Cluster 角色
主要负责消息的存储、查询消费,支持主从部署,一个 Master 可以对应多个 Slave,Master 支持读写,Slave 只支持读。Broker 会向集群中的每一台 NameServer 注册自己的路由信息。是个容器的概念。
Producer 角色
消息的发送者,它负责产生消息,可以集群部署。它会先和 NameServer 集群中的随机一台建立长连接,得知当前要发送的 Topic 存在哪台 Broker Master上,然后再与其建立长连接,支持多种负载平衡模式发送消息。
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 建立连接,接收消息。
中间件中的基本概念
主题(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上了,就无法被消费。
RocketMQ 的安装
下来我们来安装 RocketMQ ,我们安装的环境是 CentOS 7,已安装 JDK8 环境。
Linux 下的安装
下载
在官网上下载:https://rocketmq.apache.org/dowloading/releases/
上传解压
没什么好说的,通过 ftp 上传到指定目录然后运行解压命令。
[root@dailyblue rocketmq]# unzip rocketmq-all-4.8.0-bin-release.zip
启动 NameServer
# nohup sh ... & 是后台运行 ... 代表需要启动的程序
[root@dailyblue bin]# nohup sh mqnamesrv &
检查日志
[root@dailyblue bin]# tail -f ~/logs/rocketmqlogs/namesrv.log
启动 Broker
启动 Broker 前需要注意两件事,因为 Broker启动时要加载配置文件。
1. 首先修改 conf 文件夹下的 broker.conf 文件,设置IP。
添加一个 IP ,其中地址是当前虚拟机地址。
2. 修改启动文件
修改文件 runbroker.sh 文件,修改堆空间的初始值。因为这个文件默认启动所需内存较大,我们当前虚拟机没有这么大的内存容量,按照实际配置即可。
修改指定位置内容分别是:512m 512m 256m 就行。
3. 启动
#-c 是启动时加载配置文件
#-n 是启动IP和端口号
#autoCreateTopicEnable=true 是自动创建主题(可以不写)
[root@dailyblue bin]# nohup sh mqbroker -c ../conf/broker.conf -n 192.168.147.88:9876 autoCreateTopicEnable=true &
## 需要注意的是,5.x 版本下建议使用 Local 模式部署,即 Broker 和 Proxy 同进程部署。
nohup sh mqbroker -c ../conf/broker.conf -n 192.168.147.88:9876 autoCreateTopicEnable=true --enable-proxy &
4. 查看日志
[root@dailyblue bin]# tail -f ~/logs/rocketmqlogs/broker.log
如何停止 Namesrv 和 Broker 呢?
- 首先停止 Broker:mqshutdown broker
- 在停止 Namesrv:mqshutdown namesrv
源码的安装
1. 下载和解压
还是刚才 RocketMQ 的官网,这次下载 Source 源代码。
2. IDEA 中导入
3. mvn 引入依赖
等待 idea 项目进度完成后执行
mvn install -Dmaven.test.skip=true
如果出现如图
配置 maven 的环境变量
以控制台打开继续执行命令
4. 创建配置文件夹
创建 conf 文件夹,将 distribution 项目中 conf 目录下的一下三个文件复制过来。
创建空文件夹 logs,存放日志。
创建空文件夹 store,存放消息。
5. 启动 Namesrv
5.1 启动前编辑启动类,设置环境变量
5.2 运行启动类
6. 启动 Broker
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
6.2 同样要编辑启动类,设置启动参数和环境变量
6.3 运行启动类
控制台的安装
1. 下载
老官网地址:https://github.com/apache/rocketmq-externals
新官网地址:https://github.com/apache/rocketmq-dashboard
它是一个 Java Maven项目,你可以把这个项目引入到 Idea 中。 新项目对于较早的电脑可能不是很合适😊!
建议在 Linux 下安装,安装前请自行安装 jdk8 ,maven 工具。
2. 编辑
进入项目文件夹并修改 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
3. 打包
打开 cmd ,切换到项目路径下,执行
mvn clean package -Dmaven.test.skip=true
4. 运行
在生产的 jar 包目录下执行
java -jar rocketmq-console-ng-1.0.0.jar
普通消息的发送
在 Linux 中前后启动 NameSrv、Broker 和 控制台。
引入依赖
<!-- rocketMQ 客户端依赖 -->
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-client</artifactId>
<version>4.9.4</version>
</dependency>
消息发送步骤
- 创建消息生产者 producer,并指定生产者组名
- 指定 NameSrv 地址
- 启动 producer
- 创建消息对象,指定 Topic、Tag 和消息体
- 发送消息
- 关闭生产者 producer
同步发送
每发送一次消息,MQ 都会同步进行响应。
public class 普通同步消息发送 {
public static void main(String[] args) throws Exception {
// 创建消息生产者 producer,并指定生产者组名
DefaultMQProducer producer = new DefaultMQProducer("dailyblue_group_a");
// 指定 NameSrv 地址
producer.setNamesrvAddr("192.168.147.88:9876");
// 启动 producer
producer.start();
// 我们通过循环的方式发送10条消息到MQ中
for (int i = 1; i < 11; i++) {
// 创建消息对象,指定 Topic、Tag 和消息体
String topic = "GuanWei";
String tags = "shuaiguo";
String str = "Hello,RocketMQ!The index:" + i;
// 这里的消息需要转换成 byte 数组发送
Message message = new Message(topic, tags, str.getBytes());
// 发送消息 因为是同步发送 所以服务器会返回结果
SendResult result = producer.send(message);
System.out.println(i + ":" + result);
}
// 关闭生产者 producer
producer.shutdown();
}
}
异步发送
同样能得到响应,但是却是通过异步监听方式来获取的。所以在异步发送时,并不会阻塞当前线程。
public class 普通异步消息发送 {
public static void main(String[] args) throws Exception {
// 创建消息生产者 producer,并指定生产者组名
DefaultMQProducer producer = new DefaultMQProducer("dailyblue_group_a");
// 指定 NameSrv 地址
producer.setNamesrvAddr("192.168.147.88:9876");
// 启动 producer
producer.start();
// 我们通过循环的方式发送10条消息到MQ中
for (int i = 1; i < 11; i++) {
final int index = i;
// 创建消息对象,指定 Topic、Tag 和消息体
String topic = "GuanWei";
String tags = "liangzai";
String str = "Hello RocketMQ ,The Send is Async,index:" + i;
Message message = new Message(topic, tags, str.getBytes(RemotingHelper.DEFAULT_CHARSET));
// 唯一的区别是这里,这里在发送时通过异步回调回去返回结果 SendCallback 接受异步返回结果的回调
producer.send(message, new SendCallback() {
// 成功后返回的消息
public void onSuccess(SendResult sendResult) {
System.out.println(index + ":" + sendResult);
}
// 失败后返回的消息
public void onException(Throwable e) {
System.out.println(index + ":" + e.getMessage());
}
});
}
// 因为是异步回调,有可能并不是实时返回消息,所以这里人为阻塞一会
Thread.sleep(5000);
// 关闭生产者
producer.shutdown();
}
}
异步发送的好处是:消息发送者不需要长时间等待消息的响应,是通过异步监听获取到响应结果。
单向发送
消息的发送者只关系发送,并不关心响应结果,有没有收到了。
public class 普通单向消息发送 {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("dailyblue_group_a");
producer.setNamesrvAddr("192.168.147.88:9876");
producer.start();
for (int i = 1; i < 11; i++) {
String topic = "GuanWei";
String tags = "Sha";
String str = "Sha?Wo Shi ShuaiGe?i:" + i;
Message message = new Message(topic, tags, str.getBytes(StandardCharsets.UTF_8));
// 唯一的区别,这里调用 sendOneway
producer.sendOneway(message);
}
producer.shutdown();
}
}
单向发送不关心是否收到,但是发送速度最快,比较适合发送耗时短,对于可靠性要求较低的数据。
三种发送方式的对比
三者的特点和主要区别如下:
发送方式 | 发送 TPS | 发送结果反馈 | 可靠性 | 适用场景 |
---|---|---|---|---|
同步发送 | 快 | 有 | 不丢失 | 此种方式应用场景非常广泛,例如重要通知邮件、报名短信通知、营销短信系统等。 |
异步发送 | 快 | 有 | 不丢失 | 异步发送一般用于链路耗时较长,对响应时间较为敏感的业务场景,例如用户视频上传后通知启动转码服务,转码完成后通知推送转码结果等。 |
单向发送 | 最快 | 无 | 可能丢失 | 适用于某些耗时非常短,但对可靠性要求并不高的场景,例如日志收集。 |
普通消息的消费
消息队列是基于发布/订阅模型的消息系统。消费者,即消息的订阅方订阅关注的 Topic,以获取并消费消息。由于消费者应用一般是分布式系统,以集群方式部署,因此消息队列约定以下概念:
- 集群:使用相同 Group ID 的消费者属于同一个集群。同一个集群下的消费者消费逻辑必须完全一致(包括 Tag 的使用)。
- 集群消费:当使用集群消费模式时,消息队列认为任意一条消息只需要被集群内(单 Zone 内)的任意一个消费者处理即可。
- 广播消费:当使用广播消费模式时,消息队列会将每条消息推送给集群内(单 Zone 内)所有注册过的消费者,保证消息至少被每个消费者消费一次。
消息消费步骤
- 创建消费者 Consumer,指定消费者组名
- 指定 NameSrv 地址
- 订阅主题 Topic 和 Tag
- 设置回调函数,处理消息
- 启动消费者 Consumer
集群消费
适用于消费端集群化部署,每条消息只需要被处理一次的场景。此外,由于消费进度在服务端维护,可靠性更高。具体消费示例如下图所示。
// 可以书写多个消费者,代码都一致,分别启动,当发送消息后,会被这些集群分担消费
public class 集群消费1 {
public static void main(String[] args) throws Exception {
// 创建消费者 Consumer,指定消费者组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_b");
// 指定 NameSrv 地址
consumer.setNamesrvAddr("192.168.147.88:9876");
// 订阅主题 Topic 和 Tag
consumer.subscribe("GuanWei", "*");
// 集群消费模式 可以不设置,默认就是集群消费模式
consumer.setMessageModel(MessageModel.CLUSTERING);
// 设置回调函数,处理消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
// 循环接受所有消息
for (MessageExt message : msgs) {
// 分别获取 Topic、Tags 和 消息内容
String topic = message.getTopic();
String tags = message.getTags();
String msg = new String(message.getBody(), "UTF-8");
System.out.println("topic:" + topic + ",tags:" + tags + ",msg:" + msg);
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
// 代表消息消费失败
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
// 代表消息被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
}
}
注意事项
- 集群消费模式下,每一条消息都只会被分发到一台机器上处理。如果需要被集群下的每一台机器都处理,请使用广播模式。
- 集群消费模式下,不保证每一次失败重投的消息路由到同一台机器上。
广播消费
适用于消费端集群化部署,每条消息需要被集群下的每个消费者处理的场景。具体消费示例如下图所示。
// 和集群消费基本一致
public class 广播消费1 {
public static void main(String[] args) throws Exception {
// 创建消费者 Consumer,指定消费者组名
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_b");
// 指定 NameSrv 地址
consumer.setNamesrvAddr("192.168.147.88:9876");
// 订阅主题 Topic 和 Tag *代表所有Tag
consumer.subscribe("GuanWei", "*");
// 广播消费模式 唯一的区别
consumer.setMessageModel(MessageModel.BROADCASTING);
// 设置回调函数,处理消息
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
// 循环接受所有消息
for (MessageExt message : msgs) {
// 分别获取 Topic、Tags 和 消息内容
String topic = message.getTopic();
String tags = message.getTags();
String msg = new String(message.getBody(), "UTF-8");
System.out.println("topic:" + topic + ",tags:" + tags + ",msg:" + msg);
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
// 代表消息消费失败
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
// 代表消息被成功消费
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
// 启动消费者
consumer.start();
}
}
注意事项
- 广播消费模式下不支持顺序消息。
- 广播消费模式下不支持重置消费位点。
- 广播模式下不支持线下联调分组消息。
- 每条消息都需要被相同订阅逻辑的多台机器处理。
- 消费进度在客户端维护,出现重复消费的概率稍大于集群模式。
- 广播模式下,消息队列保证每条消息至少被每台客户端消费一次,但是并不会重投消费失败的消息,因此业务方需要关注消费失败的情况。
- 广播模式下,客户端每一次重启都会从最新消息消费。客户端在被停止期间发送至服务端的消息将会被自动跳过,请谨慎选择。
- 广播模式下,每条消息都会被大量的客户端重复处理,因此推荐尽可能使用集群模式。
- 广播模式下服务端不维护消费进度,所以消息队列控制台不支持消息堆积查询、消息堆积报警和订阅关系查询功能。
收发顺序消息
顺序消息(FIFO 消息)是消息队列提供的一种严格按照顺序来发布和消费的消息类型。可以被分为:
- 全局顺序消息
- 部分顺序消息
顺序消费的原理解析:
- 默认的情况下消息发送会采取 Round Robbin 轮询方式把消息发送到不同的 queue (分区队列);而消费消息的时候从多个 queue 上拉取消息,这种情况发送和消费是不能保证顺序。
- 但是如果控制发送的顺序消息只依次发送到同一个queue中,消费的时候只从这个queue上依次拉取,则就保证了顺序。
- 当发送和消费参与的queue只有一个,则是全局有序;如果多个queue参与,则为分区有序,即相对每个queue,消息都是有序的。
顺序消息的生产
// 全局和部分只在 queueId 有所区别
public class 顺序消息发送 {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("dailyblue_group_a");
producer.setNamesrvAddr("192.168.147.88:9876");
producer.start();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
// 发送10条消息
for (int i = 1; i < 11; i++) {
final int index = i;
String topic = "GuanWei";
String tags = "shunxu";
String str = "发送顺序全局消息,index:" + i + ",时间是:" + sdf.format(new Date());
Message message = new Message(topic, tags, str.getBytes("UTF-8"));
/*
* 发送消息 这里和普通消息发送有所区别
* 参数一:消息对象
* 参数二:消息队列的选择器
* 参数三:选择队列的业务标识
*/
int queueId = (index) % 4; // 0 1 2 3
SendResult result = producer.send(message, new MessageQueueSelector() {
/*
* mqs:topic下的队列集合
* msg:消息对象 message ==> Message msg
* arg:选择队列的业务标识 orderSteps.get(i).getOrderId() ==> Object arg
*/
public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) {
/*
这里代表将消息发送给那个队列,我们默认就四个队列
如果是全局发送这里可以写成mqs.get(0) 代表只发送到一个队列中
*/
return mqs.get(queueId);
}
}, queueId);
System.out.println("结果是:" + result);
// 每一秒发送一次
TimeUnit.SECONDS.sleep(1);
}
producer.shutdown();
}
}
顺序消息的消费
// 全局和部分这里没有区别
public class 顺序消息消费 {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_b");
consumer.setNamesrvAddr("192.168.147.88:9876");
/*
设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费
如果非第一次启动,那么按照上次消费的位置继续消费
*/
consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);
consumer.subscribe("GuanWei", "shunxu");
// MessageListenerOrderly 对于一个队列的消息用一个线程去处理
consumer.registerMessageListener(new MessageListenerOrderly() {
@Override
public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
try {
for (MessageExt msg : msgs) {
String topic = msg.getTopic();
String tags = msg.getTags();
String body = new String(msg.getBody(), "utf-8");
System.out.println("线程:" + Thread.currentThread().getName() + "消费了消息【topic:" + topic + ",tags:" + tags + ",body:" + body + "】");
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
// 需要注意:这里使用这个标注失败,意思是先等一会,一会在处理这批消息,而不是放到重试队列中
return ConsumeOrderlyStatus.SUSPEND_CURRENT_QUEUE_A_MOMENT;
}
return ConsumeOrderlyStatus.SUCCESS;
}
});
consumer.start();
}
}
收发延时消息
在电商里,提交了一个订单就可以发送一个延时消息,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 开始 。
延时消息的生产
public class 延时消息发送 {
public static void main(String[] args) throws Exception {
DefaultMQProducer producer = new DefaultMQProducer("dailyblue_group_a");
producer.setNamesrvAddr("192.168.147.88:9876");
producer.start();
SimpleDateFormat sdf = new SimpleDateFormat("yyyy年MM月dd日 HH:mm:ss");
for (int i = 1; i < 11; i++) {
String topic = "GuanWei";
String tags = "yanshi";
String str = "Hello,YanShi,index:" + i + ",Time:" + sdf.format(new Date());
Message message = new Message(topic, tags, str.getBytes("UTF-8"));
// 设置延时等级3,这个消息将在10s之后发送(messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h")
message.setDelayTimeLevel(2);
SendResult result = producer.send(message);
System.out.println("返回结果是:" + result);
}
producer.shutdown();
}
}
延时消息的消费
// 这里没有什么特别之处
public class 延时消息消费 {
public static void main(String[] args) throws Exception {
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_b");
consumer.setNamesrvAddr("192.168.147.88:9876");
consumer.subscribe("GuanWei", "yanshi");
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
for (MessageExt msg : msgs) {
String topic = msg.getTopic();
String tags = msg.getTags();
String body = new String(msg.getBody(), "UTF-8");
System.out.println("消费了消息【topic:" + topic + ",tags:" + tags + ",body:" + body + "】延迟时间:" + (System.currentTimeMillis() - msg.getStoreTimestamp()));
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
}
收发批量消息
- 批量发送消息能显著提高传递小消息的性能。
- 但是同一批次的消息应该有相同的topic,相同的waitStoreMsgOK,而且不能是延时消息。
- 此外,这一批消息的总大小不应超过1MB。
waitStoreMsgOK:表示发送消息后,是否需要等待消息同步刷新到磁盘上。如果broker配置为ASYNC_MASTER,那么只需要消息在master上刷新到磁盘即可;如果配置为SYNC_MASTER,那么还需要等待slave也刷新到磁盘。需要注意的是,waitStoreMsgOK默认为false,只有将设置为true的情况下,才会等待刷盘成功再返回。
批量消息的生产
List<Message> list = new ArrayList<>();
for (int i = 1; i < 11; i++) {
String topic = "GuanWei";
String tags = "piliang";
String str = "Hello,PiLiang,index:" + i;
Message message = new Message(topic, tags, str.getBytes());
list.add(message);
}
// 发送批量消息
SendResult result = producer.send(list);
System.out.println("返回结果是:" + result);
批量消息的消费
这里没有什么特别的代码,就用普通消息的消费就行。
收发过滤消息
在大多数情况下,TAG是一个简单而有用的设计,可以来选择您想要的消息。例如:
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_a");
consumer.subscribe("GuanWei", "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 方法来设置消息的属性。
int age = (int) (Math.random() * 100 + 1);
String topic = "GuanWei";
String tags = "guolv";
String str = "Hello,GuoLv,index:" + i + ",age:" + age;
Message message = new Message(topic, tags, str.getBytes());
// 设置属性 age 值是随机数
message.putUserProperty("age", String.valueOf(age));
SendResult result = producer.send(message);
System.out.println("发送结果是:" + result);
过滤消息的消费
用 MessageSelector.bySql 方法来使用 sql 筛选消息。
DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("dailyblue_group_b");
consumer.setNamesrvAddr("192.168.147.88:9876");
consumer.subscribe("GuanWei", MessageSelector.bySql("age>=30 and age<=50"));
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
for (MessageExt msg : msgs) {
String topic = msg.getTopic();
String tags = msg.getTags();
String body = new String(msg.getBody());
System.out.println("消费了消息:【 topic:" + topic + " ,tags:" + tags + " ,body:" + body + " 】");
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
一个常见错误
如果你在消费过滤消息时,出现如下错误:
要使用基于标准的 sql92 模式过滤消息,必须找到 RocketMQ 的安装目录的 conf 下的对应配置文件去修改配置,然后停止broker服务,再重启broker服务,我这里修改的是 broker.conf 文件:
#开启消息过滤。 enablePropertyFilter = true
事务消息
在一些对数据一致性有强需求的场景,可以用 Apache RocketMQ 事务消息来解决,从而保证上下游数据的一致性。
以电商交易场景为例,用户支付订单这一核心操作的同时会涉及到下游物流发货、积分变更、购物车状态清空等多个子系统的变更。当前业务的处理分支包括:
- 主分支订单系统状态更新:由未支付变更为支付成功。
- 物流系统状态新增:新增待发货物流记录,创建订单物流记录。
- 积分系统状态变更:变更用户积分,更新用户积分表。
- 购物车系统状态变更:清空购物车,更新用户购物车记录。
使用普通消息和订单事务无法保证一致的原因,本质上是由于普通消息无法像单机数据库事务一样,具备提交、回滚和统一协调的能力。 而基于 RocketMQ 的分布式事务消息功能,在普通消息基础上,支持二阶段的提交能力。将二阶段提交和本地事务绑定,实现全局提交结果的一致性。
事务消息发送分为两个阶段。第一阶段会发送一个半事务消息,半事务消息是指暂不能投递的消息,生产者已经成功地将消息发送到了 Broker,但是Broker 未收到生产者对该消息的二次确认,此时该消息被标记成“暂不能投递”状态,如果发送成功则执行本地事务,并根据本地事务执行成功与否,向 Broker 半事务消息状态(commit或者rollback),半事务消息只有 commit 状态才会真正向下游投递。如果由于网络闪断、生产者应用重启等原因,导致某条事务消息的二次确认丢失,Broker 端会通过扫描发现某条消息长期处于“半事务消息”时,需要主动向消息生产者询问该消息的最终状态(Commit或是Rollback)。这样最终保证了本地事务执行成功,下游就能收到消息,本地事务执行失败,下游就收不到消息。总而保证了上下游数据的一致性。
整个事务消息的详细交互流程如下图所示:
代码实现
事务监听器,重写执行本地事务方法以及事务回查方法:
/**
* 事务监听器,重写执行本地事务方法以及事务回查方法
*/
@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 ProviderSend {
@SneakyThrows
public static void main(String[] args) {
// 创建事务类型的生产者
TransactionMQProducer producer = new TransactionMQProducer("transaction-producer-group");
// 设置NameServer的地址
producer.setNamesrvAddr("81.68.205.226: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();
}
}
事务消息步骤
事务消息发送步骤如下:
- 生产者将半事务消息发送至 RocketMQ Broker。
- RocketMQ Broker 将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息暂不能投递,为半事务消息。
- 生产者开始执行本地事务逻辑。
- 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit或是Rollback),服务端收到确认结果后处理逻辑如下:
- 二次确认结果为Commit:服务端将半事务消息标记为可投递,并投递给消费者。
- 二次确认结果为Rollback:服务端将回滚事务,不会将半事务消息投递给消费者。
- 在断网或者是生产者应用重启的特殊情况下,若服务端未收到发送者提交的二次确认结果,或服务端收到的二次确认结果为Unknown未知状态,经过固定时间后,服务端将对消息生产者即生产者集群中任一生产者实例发起消息回查。
需要注意的是,服务端仅仅会按照参数尝试指定次数,超过次数后事务会强制回滚,因此未决事务的回查时效性非常关键,需要按照业务的实际风险来设置。
事务消息回查步骤如下:
- 生产者收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 生产者根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤4对半事务消息进行处理。
常见属性和方法
这里罗列了一些在消息生产和消费中常见的一些属性设置和方法。
消息生产的属性和方法
属性
方法
单向发送方法
同步发送方法
异步发送方法
消息消费的属性和方法
属性
方法
订阅相关方法
注册监听器相关方法
并发事件监听器
顺序事件监听器
RocketMQ 的 ACK(Acknowledgement) 机制
ACK,即一种消息确认字符,在数据通信中,消息接受站给消息发送站的一种传输类控制符,表示传输过来的数据已经接受无误。
PushConsumer 为了保证消息肯定消费成功,只有使用方明确表示消费成功,RocketMQ 才会认为消息消费成功。中途断电,抛出异常等都不会认为成功——即都会重新投递。
业务实现消费回调的时候,当且仅当此回调函数返回 ConsumeConcurrentlyStatus.CONSUME_SUCCESS,RocketMQ 才会认为这批消息(默认是1条)是消费完成的。
如果这时候消息消费失败,例如数据库异常,余额不足扣款失败等一切业务认为消息需要重试的场景,只要返回 ConsumeConcurrentlyStatus.RECONSUME_LATER ,RocketMQ 就会认为这批消息消费失败了。
为了保证消息是肯定被至少消费成功一次,RocketMQ 会把这批消息重发回 Broker,在延迟的某个时间点(默认是10秒,业务可设置)后,再次投递到这个 ConsumerGroup。而如果一直这样重复消费都持续失败到一定次数(默认16次),就会投递到DLQ死信队列。
RocketMQ 的重复消费问题
RocketMQ 无法避免消息重复,所以如果业务对消费重复非常敏感,务必要在业务局面去重。
将消息的唯一键,可以是 msgId,也可以是消息内容中的唯一标识字段,例如订单 Id 等,消费前判断是否在 Db 或 Tair(全局 KV 存储)中存在,如果不存在则插入,幵消费,否则跳过。(实际过程要考虑原子性问题,判是否存在可以尝试插入,如果报主键冲突,则插入失败,直接跳过)msgId 一定是全局唯一标识符,但是可能会存在同样的消息有两个不同 msgId 的情况(有多种原因),返种情况可能会使业务上重复消费,建议最好使用消息内容中的唯一标识字段去重。
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 来区分不同 Broker,Master 和 对应的 Slave 名称相同
brokerName=broker-a
# 当为0 时 代表 master 非0时 代表slave
brokerId=0
deleteWhen=04
fileReservedTime=48
brokerRole=ASYNC_MASTER
flushDiskType=ASYNC_FLUSH
消息消费高可用
在 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 文件
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个索引。
消息存储整体架构
上图即为 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.2.3</version>
</dependency>
配置文件
# RocketMQ 相关配置
rocketmq:
# 指定 nameServer
name-server: 127.0.0.1:9876
# Producer 生产者
producer:
group: guanwei_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
consumer: 消费者组名
消息的生产
private RocketMQTemplate rocketMQTemplate;
DefaultMQProducer producer = rocketMQTemplate.getProducer();
producer.setProducerGroup("dailyblue_group_a");
producer.setNamesrvAddr("127.0.0.1:9876");
Message message1 = new Message();
message1.setTopic("GUANWEI");
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", "单向消息");
}
配置类配置
由于使用的 SpringBoot 版本是3.0.0以上,与 RocketMq 不是很兼容,对于 rocketMqTemplate 的自动注入存在差异,如果不采用这种方式注入则会报出缺少 bean 的信息。
@Configuration
public class RocketMQConfig {
@Value("${rocketmq.producer.group}")
private String producerGroup;
@Value("${rocketmq.name-server}")
private String nameServer;
@Bean("rocketMQTemplate")
public RocketMQTemplate rocketMqTemplate() {
RocketMQTemplate rocketMqTemplate = new RocketMQTemplate();
DefaultMQProducer defaultMqProducer = new DefaultMQProducer();
defaultMqProducer.setProducerGroup(producerGroup);
defaultMqProducer.setNamesrvAddr(nameServer);
rocketMqTemplate.setProducer(defaultMqProducer);
return rocketMqTemplate;
}
}
消费者配置
在 resources 文件夹下配置消费者管理。文件名为:org.springframework.boot.autoconfigure.AutoConfiguration.imports
org.apache.rocketmq.spring.autoconfigure.RocketMQAutoConfiguration
@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:}
事务消息的发送
使用 RocketMQTemplate 的 sendMessageInTransaction 方法来发送事务消息:
Message<String> sendMessage = MessageBuilder.withPayload("Hello,GuanWei!")
// 想发送带key的消息,请求头的键必须写成KEYS
.setHeader("KEYS", "Num1")
.build();
/**
* Send Spring Message in Transaction
*
* @param txProducerGroup 事务组名称
* @param destination 主题和标签: `topic:tags`
* @param message 消息体message {@link org.springframework.messaging.Message}
* @param arg 参数 一般我们都把消息体写在这里
* @return TransactionSendResult
* @throws MessagingException
*/
TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction("tx01", "topicA:tagA", sendMessage, "Body");
使用监听器监听半消息:
@Slf4j
@Component
@RocketMQTransactionListener(txProducerGroup = "tx01")
public class Transaction2ListenerImpl implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
log.info("message:{}", message);
log.info("key:{},body:{}", message.getHeaders().get("rocketmq_KEYS"), message.getPayload());
log.info("o:{}", o);
return RocketMQLocalTransactionState.COMMIT;
}
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
return null;
}
}