前言
接着上章【SpringCloudAlibaba - Sentienl 限流 熔断】,继续学习 RocketMQ分布式消息队列
一、RocketMQ是什么?
RocketMQ是一个统一消息引擎、轻量级数据处理平台。
RocketMQ是⼀款阿⾥巴巴开源的消息中间件,双十一承载了万亿级消息的流转,2016年11⽉,阿⾥巴巴向 Apache 软件基⾦会捐赠 RocketMQ,成为 Apache 孵化项⽬,2017 年 9 ⽉ ,Apache 宣布 RocketMQ孵化成为 Apache 顶级项⽬(TLP )成为国内⾸个互联⽹中间件在 Apache 上的顶级项⽬。
二、RocketMQ的安装
1.RocketMQ安装
1.1 下载RocketMQ
下载地址:http://rocketmq.apache.org/release_notes/release-notes-4.2.0/
下载解压后的包目录:
- Bin : 可执行文件目录
- Conif:配置文件目录
- Lib : 依赖库,一堆Jar包
1.2 配置ROCKETMQ_HOME
配置 ROCKETMQ_HOME,点击我的电脑 -> 属性 -> 高级系统设置 -> 环境变量 -> 新建
配置ROCKETMQ_HOME,配置路径到bin目录上一个目录
1.3 启动MQ
- 启动NameServer
Cmd命令框执行进入至MQ文件夹\bin下,然后执行 start mqnamesrv.cmd,启动NameServer。
成功后会弹出提示框,勿关闭。 - 启动Broker
进入至MQ文件夹\bin下,修改Bean目录下的 runbroker.cmd 中JVM占用内存大小。
CMD执行start mqbroker.cmd -n 127.0.0.1:9876 autoCreateTopicEnable=true ,启动Broker。
成功后会弹出提示框,勿关闭 - RocketMQ存储结构
RabbitMQ安装好之后会在用户目录下产生一个store目录用来存储相关数据:
Commitlog : 消息是存储,在commitlog目录中,以mapperdFile文件顺序存储消息。
Config : 存放运行期间的配置文件
Consumerqueue : 该目录中存放的是队列,consume queue存放着commitlog中的消息的索引位置
Index :存放着消息索引文件 indexFile,用来实现根据key进行消息的快速查询
Abort : 该文件在broker启动后自动创建,正常关闭abort会消失
Checkpoint :记录 Commitlog ,Consumerqueue 和index 文件的最后刷盘时间戳
2. RocketMQ插件
2.1.下载插件
RocketMQ可视化管理插件下载地址:https://github.com/apache/rocketmq-externals/releases
2.2.修改配置
解压后,修改配置:src/main/resource/application.properties ,这里需要指向Name Server 的地址和端口 如下:
2.3.打包插件
回到安装目录(pom.xml所在目录),执行: mvn clean package -Dmaven.test.skip=true ,然后会在target目录生成打包后的jar文件
2.4.启动插件
进入 target 目录,执行 java -jar rocketmq-console-ng-1.0.0.jar , 访问 http://localhost:8080
三,RocketMQ的原理
1. RokcetMQ架构
RocketMQ的集群架构如下:
RocketMQ主要由 Producer、Broker、Consumer、NameServer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器。
为了实现高可用,Broker本身是主备架构,Master负责写请求和读请求,Slave负责数据备份和分担读请求。这样的好处是提高了吞吐量同时防止数据丢失。
1.1.Producer
消息发布的角色,支持分布式集群方式部署。Producer通过MQ的负载均衡模块选择相应的Broker集群队列进行消息投递,投递的过程支持快速失败并且低延迟。
1.2.Consumer
消息消费的角色,支持分布式集群方式部署。支持以push推,pull拉两种模式对消息进行消费。同时也支持集群方式和广播方式的消费,它提供实时消息订阅机制,可以满足大多数用户的需求。
1.3.Broker
Broker主要负责消息的存储、投递和查询以及服务高可用保证。
1.4.NameServer
NameServer是一个Broker与Topic路由的注册中心支持Broker的动态注册与发现主要包括两个功能
- Broker管理
NameServer接受Broker集群的注册信息并且保存下来作为路由信息的基本数据。然后提供心跳检测机制,检查Broker是否还存活。 - 路由信息管理
每个NameServer将保存关于Broker集群的整个路由信息和用于客户端查询的队列信息。然后Producer和Conumser通过NameServer就可以知道整个Broker集群的路由信息,从而进行消息的投递和消费
2. RocketMQ 核心概念
2.1.RabbitMQ工作原理
1.RocketMQ 网络部署特点
为了增强Broker性能与吞吐量,Broker一般都是以集群形式出现的。各集群节点中可能存放着相同Topic的不同Queue。
不过,这里有个问题,如果某Broker节点宕机,如何保证数据不丢失呢?其解决方案是,将每个Broker集群节点进行横向扩展,即将Broker节点再建为一个HA集群,解决单点问题。
Broker节点集群是一个主从集群,即集群中具有Master与Slave两种角色。Master负责处理读写操作请求,Slave负责对Master中的数据进行备份。当Master挂掉了,Slave则会自动切换为Master去工作。所以这个Broker集群是主备集群。Consumer既可以从Master订阅消息,也可以从Slave订阅消息
一个Master可以包含多个Slave,但一个Slave只能隶属于一个Master。 Master与Slave 的对应关系是通过指定相同的BrokerName、不同的BrokerId 来确定的。BrokerId为0表示Master非0表示Slave。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。
2.RabbitMQ工作流程(重要)
- 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
- Broker启动,跟所有的NameServer保持长连接,定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群中就有Topic跟Broker的映射关系。
- 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。
- Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从队列列表中选择一个队列,然后与队列所在的Broker建立长连接从而向Broker发消息。
- Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息
2.2.Producer 生产者
RocketMQ提供多种发送方式,同步发送、异步发送、顺序发送、单向发送。同步和异步方式均需要Broker返回确认信息,单向发送不需要。
RocketMQ中的消息生产者都是以生产者组(Producer Group)的形式出现的。生产者组是同一类生产者的集合,这类Producer发送相同Topic类型的消息。一个生产者组可以同时发送多个主题的消息。
Producer会使用一定的算法选择把消息发送到哪个master的某个queue中。
2.3.Consumer 消费者
Consumer 支持两种消费形式:拉取式消费、推动式消费。(主动,被动),RocketMQ中的消息消费者都是以消费者组(Consumer Group)的形式出现的。消费者组是同一类消费者的集合,这类Consumer消费的是同一个Topic类型的消息,不同的 Consumer Group可以消费同一个Topic。
一个Consumer Group内的Consumer可以消费多个Topic的消息。
[注意] 一个Queue是不能被同一个ConsumerGroup中的多个Consumer消费的,目的是减少资源竞争提升整体性能。
2.4.Topic 消息主题
Topic表示一类消息的集合,每个topic主题包含若干条message消息,每条message消息只能属于一个topic主题,Topic是RocketMQ进行消息订阅的基本单位。
2.5.Message
消息是指,消息系统所传输信息的物理载体,生产和消费数据的最小单位,每条消息必须属于一个主题。
2.6.Tag 标签
为消息设置的标志,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效地保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。Topic是消息的一级分类,Tag是消息的二级分类
2.7.MessageQueue队列
一个Topic中可以包含多个Queue,一 个Topic的Queue也被称为一个Topic中消息的分区(Partition)。 在一个Consumer Group内,一个Queue最多只能分配给一个Consumer,一个Cosumer可以分配得到多个Queue。这样的分配规则,每个Queue只有一个消费者,可以避免消费过程中的多线程处理和资源锁定,有效提高各Consumer消费的并行度和处理效率。
消费者组中Consumer的数量应该小于等于订阅Topic的Queue数量。如果超出Queue数量,则多出的 Consumer将不能消费消息。如果一个Consmer挂了,该Consumer Group中的其它Consumer可以接着消费原Consumer消费的Queue。
【注意】 一个Topic可以对应多个消费者 ,一个Queue只能对应一个组中的一个消费者。
【注意】为了防止消息紊乱,一个Consumer Group 中的Consumer都是订阅相同Topic下的Queue。
- 读写队列
Queue分为 写队列 和 读队列 ,默认创建数量是都是4 ,这个读写队列是从逻辑上进行划分在物理上读/写是一个队列,Producer发送的消息进入写队列 ,Consumer从读队列获取数据,一般情况下读写队列数量是一样的。
可以通过可视化界面修改Topic中的队列数量
perm用于设置对当前创建Topic的操作权限:2表示只写,4表示只读,6表示读写。
2.8.MessageId/Key[了解]
RocketMQ中每个消息拥有唯一的MessageId,且可以携带具有业务标识的Key,以方便对消息的查询。 不过需要注意的是,MessageId有两个:在生产者send()消息时会自动生成一个MessageId(msgId),
当消息到达Broker后,Broker也会自动生成一个MessageId(offsetMsgId)。msgId、offsetMsgId与key都称为消息标识。
2.9.Rebalance重新负载
当消费者数量或者Queue的数量修改,Rebalance是把⼀个Topic下的多个Queue重新分配给Consumer Group下的Consumer。目的是增加消费能力。
由于一个队列只分配给一个Consumer,那么当Consumer Group中的消费者数量大于队列数量,那么多出来的Consumer分配不到队列。
2.10.消息拉取模式
消息的消费分为:拉取式 pull ,和推送是 push
- Pull:拉取式,需要消费者间隔一定时间就去遍历关联的Queue,实时性差但是便于应用控制消息的拉取
- Push:默认推送式,封装了Queue的遍历,实时性强,但是对系统资源占用比较多。
2.11.消息消费模式
消息的消费模式有广播模式和集群模式
- 广播模式:同一个Consumer Group 下的所有Consumer都会受到同一个Topic的所有消息。同一个消息可能会被消费多次。
- 集群模式(默认):同一个Gonsumer Group 下的Consumer平分同一个Topic下的消息。同一个消息只是被消费一次。
2.12.Queue的分配算法
Queue是如何分配给Consumer的,这对应了四种算法:平均分配策略,环形平均策略,一致性Hash策略,同机房策略。
- 平均分配【默认】:根据 qeueuCount / consumerCount 作为每个消费者平均分配数量,如果多出来的queue就再依次逐个分配给Consumer。
- 环形平均策略:根据消费者的顺序,一个一个的分配Queue即可类似于发扑克牌。
- 一致性Hash策略 : 该算法将Consumer的Hash值作为节点放到Hash环上,然后将Queue的hash值也放入Hash环上,通过顺时针进行就近分配。
- 同机房策略:该算法会根据queue的部署机房位置和consumer的位置,过滤出当前consumer相同机房的queue。然后按照平均分配策略或环形平均策略对同机房queue进行分配。如果没有同机房queue,则按照平均分配策略或环形平均策略对所有queue进行分配。
平均分配性能比较高,一致性Hash性能不高,但是能减少Rebalance,如果Consumer数量变动频繁可以使用一致性Hash。
2.13.Offset管理
RockertMQ通过Offset来维护Consumer的消费进度,比如:消费者从哪个位置开始持续消费消息的?这里有三个枚举来指定从什么位置消费
- CONSUME_FROM_LAST_OFFSET:从queue的最后一条消息开始消费
- CONSUME_FROM_FIRST_OFFSET:从queue的第一条消息开始消费
- CONSUME_FROM_TIMESTAMP:从某个时间戳位置的消息开始消费。
消费者消费结束之后,会向Consumer会提交其消费进度offset给Broker。Offset信息的存储分为本地 Offset管理 和远程Offset管理
- 远程Offset管理:Brocker通过 store/config/consumerOffset.json 文件以JSON方式来存储offset相关数据以json的形式:适用于集群模式
- 本地Offset管理:offset相关数据以json的形式持久化到Consumer本地磁盘文件中,路径为当前用户主目录下的.rocketmq_offsets/{clientId}/{group}/Offsets.json :适用于广播模式
Offset的同步提交与异步提交: 集群消费模式下,Consumer消费完消息后会向Broker提交消费进度offset,其提交方式分为两种:
- 同步提交:消费者在消费完一批消息后会向broker提交这些消息的offset,等待broker的成功响应。若在等待超时之前收到了成功响应,则继续读取下一批消息进行消费(从ACK中获取 nextBeginOffset)。若没有收到响应,则会重新提交,直到获取到响应。而在这个等待过程中,消费 者是阻塞的。其严重影响了消费者的吞吐量。
- 异步提交:消费者在消费完一批消息后向broker提交offset,但无需等待Broker的成功响应,可以继续读取并消费下一批消息。这种方式增加了消费者的吞吐量。但需要注意,broker在收到提交的offset 后,还是会向消费者进行响应的。可能还没有收到ACK,此时Consumer会从Broker中直接获取 nextBeginOffset。
2.14.消息的清理
消息不会被单独清理,消息是顺序存储到commitlog的,消息是以commitlog为单位进行清理,RocketMQ有自己的清理规则,默认是72小时候后进行清理
- 到达时间清理点,自动清理过期的文件(凌晨4点)
- 磁盘空间使用率达到了过期清理阈值(75%),自动清理过期的文件。
- 磁盘占用率达到清理阈值(85%),开始按照设定的规则清理文件,从老的文件开始。
- 磁盘占用率达到系统危险阈值(90%),拒绝写入数据。
四,RocketMQ的使用
1.环境搭建
1.1.导入依赖
<parent>
<groupId> org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
</parent>
<dependencies>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<!-- <version>2.0.4</version> -->
<version>2.2.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
1.2.启动类
@SpringBootApplication
public class ApplicationStart {
public static void main(String[] args) {
SpringApplication.run(ApplicationStart.class);
}
}
1.3.配置文件
rocketmq:
name-server: 127.0.0.1:9876
#生产者配置
producer:
#生产者组名字
group: "service-producer"
# 消息最大长度 默认 1024 * 1024 * 4 (4M)
max-message-size: 4194304
# 发送消息超时时间,默认 3000
send-message-timeout: 3000
# 发送消息失败重试次数,默认2
retry-times-when-send-failed: 2
# 异步消息发送失败重试次数
retry-times-when-send-async-failed: 2
#达到 4096 ,进行消息压缩
compress-message-body-threshold: 4096
consumer:
#消费者名字
group: "service-consumer"
#批量拉取消息数量
pull-batch-size: 10
message-model: CLUSTERING
selector-expression: *
2.发送消息
我们使用的是SpringBoot和RocketMQ整合的方式进行消息发送,MQ提供了RocketMQTemplate来发送消息
2.1.同步发送
同步消息是发送者发送消息,需要等待结果的返回,才能继续发送第二条消息,这是一种阻塞式模型,虽然消息可靠性高,但是阻塞导致性能低。
- API : void send(D destination, Message<?> message) :发送普通消息,destination是目的地,在SpringBoot中使用“topic:tags”方式拼接。,send方法模式使用的是 syncSend 同步发送方式。
@RunWith(SpringRunner.class)
@SpringBootTest(classes = KillApp.class)
public class RocketMQTest {
@Autowired
private RocketMQTemplate mqTemplate;
public void sendMessage(){
Message<String> message = MessageBuilder.withPayload("我是一个字符串消息").build();
mqTemplate.send("topic-test:tags-test",message);
System.out.println("完成消息发送...");
}
}
除了使用send发送同步消息,还可以使用synSend,该方法更加灵活,API如下:
- SendResult syncSend(String destination, Message<?> message, long timeout) :可以指定超时时间
- SendResult syncSend(String destination, Object payload, long timeout) :MQ底层自动把payload对象转为Message,然后进行同步发送
public void syncSendMessage(){
Message<String> message = MessageBuilder.withPayload("我是一个字符串消息").build();
//发送同步消息,2s发送不成功就超时
SendResult sendResult = mqTemplate.syncSend("topic-test:tags-test", message, 2000);
System.out.println(sendResult);
//判断发送结果状态
if(sendResult.getSendStatus() == SendStatus.SEND_OK){
System.out.println("发送成功");
}else{
System.out.println("发送失败");
}
}
这个 syncSend方法是可以拿到发送结果SendResult,通过他我们可以判断消息是否发送成功SendResult 结果如下
SendResult [
sendStatus=SEND_OK, msgId=C0A8006516B018B4AAC270EF9D940000,offsetMsgId=C0A8006500002A9F0000000000008E1C,
messageQueue=MessageQueue [topic=topic-test, brokerName=LAPTOP-20VLGCRC, queueId=3], queueOffset=0]
- SendStatus : 发送的状态OK
- msgId: 发送者自动生成的ID
- OffsetMsgId : 由Broker生成的消息ID
- MessageQueue :队列信息
2.2.异步发送
异步消息是发送者发送消息,无需等待发送结果就可以再发送第二条消息,它是通过回调的方式来获取到消息的发送结果,消息可靠性高,性能也高。
- API : public void asyncSend(String destination, Message<?> message, SendCallback sendCallback)
public void asyncSendMessage(){
Message<String> message = MessageBuilder.withPayload("我是一个字符串消息").build();
mqTemplate.asyncSend("topic-test:tags-test", message, new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println(sendResult);
System.out.println("发送成功");
}
@Override
public void onException(Throwable e) {
System.out.println("发送失败");
e.printStackTrace();
}
});
}
SendCallback 是消息发送结果回调。如果:sendResult.getSendStatus() == SendStatus.SEND_OK 表示成功
2.3.单向发送
这种方式指的是发送者发送消息后无需等待Broker的结果返回,Broker也不会返回结果,该方式性能最高,但是消息可靠性低。
- API : public void sendOneWay(String destination, Message<?> message)
public void sendOneWayMessage(){
Message<String> message = MessageBuilder.withPayload("我是一个字符串消息").build();
mqTemplate.sendOneWay("topic-test:tags-test",message);
System.out.println("完成消息发送...");
}
sendOneway 单向发送是没有返回结果值的。
2.4.消费者```
MQ提供消息监听器:RocketMQListener,他会负责监听MQ中的消息从而进行消费
@Component
@RocketMQMessageListener(
//消费者的名字
consumerGroup = "consumer-group-xxx",
//主题
topic = "topic-test",
//tags
selectorExpression = "tags-test",
//消息消费模式:默认是CLUSTERING集群,还支持BROADCASTING广播
messageModel = MessageModel.CLUSTERING)
//MessageExt:Message对象的子类
public class TestConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt message) {
//这里拿到的消息 message.getBody 是byte[]格式。
if(message.getBody() == null ||message.getBody().length == 0)return;
//拿到消息:如果发送的消息是字符串,那么需要把byte[]转为字符串
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
}
}
解释
- RocketMQListener : MQ提供了的消费者监听器,MessageExt是消息对象Message的子类 。这里的泛型对应生产者的消息类型,可以直接是消息的对象类型。
- @RocketMQMessageListener: 消息监听的注解,提供了常用的四个属性
-
consumerGroup :消费者的组名
- topic : 主题,对应生产者发送消息指定的destination拼接的主题
- selectorExpression :消息选择表达式,其实就是制定消费什么tags ; 可以是固定一个值,如果是 * 是消费该topic下的所有消息 ;或者可以使用: tag1 | tag2 的方式 消费多个消息,除此之外还支持使用SQL进行消息过滤,这种方式可以实现对消息的复杂过滤。SQL过滤表达式中支持多种常量类型与运算符。比如:and ; or ; not ; IS NULL 或者 IS NOT NULL 等等。
- messageModel :消息的消费模式,默认是CLUSTERING集群,还支持BROADCASTING广播
2.5.总结
下面对三种发送方式做一个对比
- 可靠性最高: 同步发送 > 异步发送 > 单向发送
- 性能最高:单向发送 > 异步发送 > 同步发送
使用场景建议如下
- 如果是比较重要的不可丢失的消息,且对时效性要去不高建议使用同步发送,如转账消息
- 如果是不重要的可失败的消息,比如日志消息,建议使用单向发送
- 如果对时效性要求比较高,且消息不能丢失,可以尝试使用异步发送
3.延迟消息[重要]
3.1.延迟消息概述
我们通常使用定时任务比如Quartz来解决超时业务,比如:订单支付超时关单,VIP会员超时提醒。但是使用定时任务来处理这些业务场景在数据量大的时候并不是一个很好的选择,会造成大量的空扫描浪费性能。我们可以考虑使用延迟消息来解决。
延迟消息即:把消息写到Broker后需要延迟一定时间才能被消费 , 在RocketMQ中消息的延迟时间不能任意指定,而是由特定的等级(1 到 18)来指定,分别有:
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h
可以通过修改配置来增加级别,比如在mq安装目录的 broker.conf 文件中增加
messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 2d 这个时候总共就有19个level。
下面是延迟消息内部工作流程图
RocketMQ Broker端在存储生产者写入的消息时,首先都会将其写入到CommitLog中。之后根据消息中的Topic信息和队列信息,将其转发到目标Topic的指定队列(ConsumeQueue)中。不过,在分发之前,系统会先判断消息中是否带有延时等级。若没有,则直接正常分发;如果有就走下面的流程
- 修改消息Topic的名字为SCHEDULE_TOPIC_XXXX
- 根据延时等级,在consumequeue目录中SCHEDULE_TOPIC_XXXX主题下创建出相应的queueId
目录与consumequeue文件
- 修改消息索引单元,计算出的投递时间当做消息Tag的哈希值存储到CosumeQueue中,投递时间 = 消息存储时间 + 延时等级时间 。下面是CosumeQueue单个存储单元组成结构如下
- Commit Log Offset:记录在CommitLog中的位置。
- Size:记录消息的大小
- Message Tag HashCode:记录消息Tag的哈希值,用于消息过滤。特别的,对于延迟消息,这个字段记录的是消息的投递时间戳。
- 将消息索引写入到SCHEDULE_TOPIC_XXXX主题下相应的consumequeue中
- Broker内部有⼀个延迟消息服务类ScheuleMessageService,根据延迟级别数,创建对应数量的定时器Timer,定时消费SCHEDULE_TOPIC_XXXX中的消息,并投递到目标Topic中。
- 在将消息到期后,队列的Level等级改为0,作为一条普通消息,投递到目标Topic。
3.2.延迟消息实战
在SpringBoot中发送RocketMQ延迟消息只需要设置一个延迟等级即可
- api : public SendResult syncSend(String destination, Message<?> message, long timeout, int delayLevel) : delayLevel就是延迟等级
SendResult sendResult = mqTemplate.syncSend("topic-test:tags-test", message, 2000, 3);//延迟消息
log.info("发送延迟消息{}",sendResult);
这里设置的等级是 3,对应的是 10S,也就是10s之后,消费者就可以收到该消息了。
4.事务消息
4.1.事务消息概述
如果业务只涉及到一个数据库的写操作,我们只需要保证这一个事物的提交和回滚,这种事务管理叫传统事物或本地事务,如果业务涉及到多个数据库(多个服务)的写操作,我们需要保证多个数据库同时提交或回滚,这种夸多个数据库的事务操作叫分布式事务。
分布式事物的解决方案有很多,如:2PC,TCC,最终一致性,最大努力通知等等。这里要介绍的是基于RocketMQ事务消息的最终一致性方案,下面举个例子。
用户注册成功,向用户数据库保存用户信息,同时通过远程调用积分服务为用户赠送积分,模型如下:
我们需要使用分布式事务管理实现用户数据库和积分数据库的一致性。即:用户保存成功,用户的积分也要保存成功,或者都回滚不做任何存储。这种业务场景可以选择2PC强一致性方案,也可以选择最终一致性。我们选择最终一致性,因为用户注册成功,不要求马上赠送积分,延迟一定时间后再赠送成功也是允许的。所以有了如下模型
事务流程
- 用户服务(事务发起方)往MQ中发送一个事务消息,
- MQ返回结果是否发送成功
- 用户服务受到消息发送成功结果,保存用户数据,提交本地事务
- 积分服务拿到MQ中的事务消息
- 积分服务保存积分到数据库
4.2.事务消息原理
事务流程中的最大的难点就是如何保证事务消息发送和本地事务的原子性,即:第一步和第二步要么都成功,要么都失败,不能说消息发送成功了,结果用户保存失败了,那么积分服务可能会增加成功,就导致数据不一致。RocketMQ已经帮我们处理好这个问题。它的工作原理如下[理解]:
4.3.事务消息实战
我们需要做什么
- 编写本地事务检查监听TransactionListener ,一是执行本地事务逻辑,二是返回本地事务执行状态
- 发消息时生产者需要设置producer.setTransactionListener 事务监听
这里我们还是使用用户和积分来举例:
1.事务监听器
//:事务监听器
@Slf4j
@RocketMQTransactionListener(txProducerGroup = "tx-producer-group")
public class CourserOrderTransactionListener implements RocketMQLocalTransactionListener {
//执行本地事务的方法
//msg: 消息对象 :
//arg : 扩展参数
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
//执行本地事务方法,就是做数据库
return RocketMQLocalTransactionState.COMMIT;
}
//检查本地事务的方法
//msg:消息对象
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
//判断本地事务是否执行成功,就是去数据库查询是否有 executeLocalTransaction 方法执行之后的结果
return RocketMQLocalTransactionState.COMMIT;
}
}
解释:
- RocketMQLocalTransactionListener :MQ提供的事务消息监听器的接口
- @RocketMQTransactionListener(txProducerGroup = “”) : 为监听器指定组名
- executeLocalTransaction(Message msg, Object arg):执行本地事务方法,msg:消息的内容,arg扩展参数
- checkLocalTransaction :检查本地事务,也就是检查executeLocalTransaction方法是否执行成功
2.消息生产者
TransactionSendResult result = mqTemplate.sendMessageInTransaction(
"tx-producer-group",//事务监听器指定的名字
"topic-test:tags-test", //目的地:topic:tags
message, //发给MQ的消息内容 Message对象
arg);//扩展参数 Object; 该参数会传递给事务监听器的arg
log.info("事务消息发送结果 {} , {}",result.getLocalTransactionState() , result.getSendStatus() );
解释
- message:消息内容,该消息会发给MQ
- arg : 扩展参数,该参数会传递给事务监听器的arg
5.顺序消息
在某些业务场景下是需要消息按照顺序进行消费,比如一个账户的加钱,减钱的动作必须按照时间先后去执行,否则就会发生金额不够导致操作失败。
按照发送的顺序进行消费就是顺序消息,遵循(FIFO), 默认生产者以Round Robin轮询方式把消息发送到不同的Queue分区队列;消费者从多个队列中消费消息,这种情况没法保证顺序。
5.1.全局有序
全局有序是一个topic下的所有消息都要保证顺序,如果要保证消息全局顺序消费,就需要保证使用一个队列存放消息,一个消费者从这一个队列消费消息就能保证顺序,即:单线程执行。
1.发送者
可以通过MQ可视化界面,设置Topic创建1个队列即可
2.消费者
使用一个线程,一次只拉取一个消息 , 使用 consumeMode = ConsumeMode.ORDERLY 有序的消费消息。
@Component
@RocketMQMessageListener(
...省略...
//消费者模式,默认是CONCURRENTLY并发消费,还可以指定为 ORDERLY 单线程顺序消费
consumeMode = ConsumeMode.ORDERLY //单线程顺序消费
)
public class TestConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt message) {
if(message.getBody() == null ||message.getBody().length == 0)return;
//拿到消息
String msg = new String(message.getBody(), StandardCharsets.UTF_8);
}
}
5.2.部分有序
还有一种就是分区有序或者部分有序,部分顺序消息只要保证某一组消息被顺序消费,即:只需要保证一个队列中的消息有序消费即可。
比如:保证同一个订单ID的生成、付款、发货消息按照顺序消费即可实现原理:
- 把同一个订单ID的消息放入同一个MessageQueue
- 保证这个MessageQueue只有一个消费者不被并发处理 ,这个需要使用到 MessageQueueSelector 来保证同一个订单的消息在同一个队列
1.生产者
在MQ中使用:SendResult sendResult = mqTemplate.syncSendOrderly("destination,message,“hashKey”) : 发送有序消息
public void senOrderMessage(){
for (long i = 1 ; i < 100 ;i++){
MyOrder myOrder = new MyOrder();
myOrder.setId(i);
myOrder.setSubject("订单:"+i);
Message<String> message = MessageBuilder.withPayload(JSON.toJSONString(myOrder)).build();
SendResult sendResult =
mqTemplate.syncSendOrderly("topic-test:tags-test",message,myOrder.getId().toString());
System.out.println("消息发送状态:"+sendResult.getSendStatus());
}
}
2.消费者
@Component
@RocketMQMessageListener(
...省略...
//消费者模式,默认是CONCURRENTLY并发消费,还可以指定为 ORDERLY 单线程顺序消费
consumeMode = ConsumeMode.ORDERLY //单线程顺序消费
)
public class TestConsumer implements RocketMQListener<MessageExt> {
@Override
public void onMessage(MessageExt message) {
if(message.getBody() == null ||message.getBody().length == 0)return;
//拿到消息
String json = new String(message.getBody(), StandardCharsets.UTF_8);
MyOrder myOrder = JSON.parseObejct(json,MyOrder.class);
}
}
解释:消费者这边使用 consumeMode = ConsumeMode.ORDERLY 单线程顺序消费模式,即可保证消费顺序。
6.批量消息
6.1.批量消息概述
批量发送消息能显著提高传递小消息的性能。限制是这些批量消息应该有相同的topic,而且不能是延时消息。此外,这一批消息的总大小不应超过4MB,如果超过可以有2种处理方案:
- 将消息进行切割成多个小于4M的内容进行发送
- 修改4M的限制改成更大
- 修改配置文件中的maxMessageSize属性
对于消费者而言Consumer的MessageListenerConcurrently监听接口的consumeMessage()方法的第一个参数为消息列 表,但默认情况下每次只能消费一条消息,可以通过:Consumer的pullBatchSize属性设置消息拉取数量(默认32),可以通过设置consumeMessageBatchMaxSize属性设置消息一次消费数量(默认1)。
[注意]:pullBatchSize 和 consumeMessageBatchMaxSize并不是设置越大越好,一次拉取数据量太大会导致长时间等待,性能降低。而且消息处理失败同一批消息都会失败,然后进行重试,导致消费时长增加。增加没必要的重试次数。
6.2.批量消息实战
我们需要做什么
- 配置最大消息大小
- 定义消息切割器切割消息
- 发送消息把消息切割之后,进行多次批量发送
配置最大消息
rocketmq:
producer:
# 消息最大长度 默认 1024 * 4 (4M)
max-message-size: 4096
MQ提供了批量消息发送API:
public void senBatchMessage(){
List<Message> messages = new ArrayList<>();
messages.add(MessageBuilder.withPayload("我是消息1").build());
messages.add(MessageBuilder.withPayload("我是消息2").build());
SendResult sendResult = mqTemplate.syncSend("topic-test-batch:tags-test", messages, 2000);
}
SpringBoot中提供了批量发送消息的方法,但是最大消息不应该超过4M,所以我们需要自己对消息进行分批发送。
1.消息切割器-消息分批
把消息按照4M切成多份,支持可迭代
//消息切割器,按照4M大小写个
public class ListSplitter implements Iterator<List<Message>> {
private final int SIZE_LIMIT = 1024 * 1024 * 4;
private final List<Message> messages;
private int currIndex;
public ListSplitter(List<Message> messages) {
this.messages = messages;
}
@Override public boolean hasNext() {
return currIndex < messages.size();
}
@Override public List<Message> next() {
int startIndex = getStartIndex();
int nextIndex = startIndex;
int totalSize = 0;
for (; nextIndex < messages.size(); nextIndex++) {
Message message = messages.get(nextIndex);
int tmpSize = calcMessageSize(message);
if (tmpSize + totalSize > SIZE_LIMIT) {
break;
} else {
totalSize += tmpSize;
}
}
List<Message> subList = messages.subList(startIndex, nextIndex);
currIndex = nextIndex;
return subList;
}
private int getStartIndex() {
Message currMessage = messages.get(currIndex);
int tmpSize = calcMessageSize(currMessage);
while(tmpSize > SIZE_LIMIT) {
currIndex += 1;
Message message = messages.get(currIndex);
tmpSize = calcMessageSize(message);
}
return currIndex;
}
private int calcMessageSize(Message message) {
int tmpSize = message.getTopic().length() + message.getBody().length;
Map<String, String> properties = message.getProperties();
for (Map.Entry<String, String> entry : properties.entrySet()) {
tmpSize += entry.getKey().length() + entry.getValue().length();
}
tmpSize = tmpSize + 20; // 增加⽇日志的开销20字节
return tmpSize;
}
}
2.发送者
@Test
public void senBatchMessage(){
List<org.apache.rocketmq.common.message.Message> messageList = new ArrayList<>();
for(int i=0;i<100000;i++){
//添加内容
byte[] bytes = (("批量消息"+i).getBytes(CharsetUtil.UTF_8));
messageList.add(new org.apache.rocketmq.common.message.Message("topic-test-batch","tags-test",bytes));
}
//切割消息
//把大的消息分裂传给你若干个小的消息
ListSplitter splitter = new ListSplitter(messageList);
while(splitter.hasNext()){
List<org.apache.rocketmq.common.message.Message> listItem = splitter.next();
//发送消息
//参数一:topic 如果想添加tag,可以使用"topic:tag"的写法
//参数二:消息内容
System.out.println(listItem.size());
SendResult sendResult = mqTemplate.syncSend("topic-test-batch:tags-test",messageList,2000);
System.out.println(sendResult);
}
try {
Thread.sleep(5000000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}
3.消费者
批量消息可以设置多个线程消费,消费者可以通过实现 RocketMQPushConsumerLifecycleListener 来监听消费者对象的生命周期,从而修改 DefaultMQPushConsumer 的参数,比如消费线程数和消息拉取数量。
/**
* 消费批量消息
* ConsumeMode.ORDERLY:顺序消费
* RocketMQPushConsumerLifecycleListener:该接口可以对消费者做定制
*/
@Component
@RocketMQMessageListener(
consumerGroup = "message-batch",
topic = "topic-test-batch",
consumeMode = ConsumeMode.ORDERLY)
public class BatchConsumerListener implements RocketMQListener<List<MessageExt>> ,
RocketMQPushConsumerLifecycleListener {
/**
* 客户端收到的消息
*/
@Override
public void onMessage(List<MessageExt> s) {
try{
String string = new java.lang.String(s.get(0).getBody(), "utf-8");
System.out.println(string);
}catch (Exception e){
e.printStackTrace();
}
}
/**
* 对消费者客户端的一些配置
* 重写prepareStart方法
* @param defaultMQPushConsumer
*/
@Override
public void prepareStart(DefaultMQPushConsumer defaultMQPushConsumer) {
//设置每次消息拉取的时间间隔 单位 毫秒
defaultMQPushConsumer.setPullInterval(1000);
//最小消费线程池数
defaultMQPushConsumer.setConsumeThreadMin(1);
//最大消费线程池数
defaultMQPushConsumer.setConsumeThreadMax(10);
//设置消费者单次批量消费的消息数目上限 默认1
defaultMQPushConsumer.setConsumeMessageBatchMaxSize(3);
//设置每个队列每次拉取的最大消费数
defaultMQPushConsumer.setPullBatchSize(16);
}
}
7.消息重试机制
对发送失败的消息进行重新发送叫消息重试,producer和consumer都有消息重试机制。
7.1.生产者重试
RocketMQ默认支持消息重试机制,消息重试具有如下特点
对于同步和异步消息支持消息重试,对于oneway单向消息不支持重试
普通消息具有消息重试,顺序消息不支持消息重试
消息重试可能会造成消息重复,所以消费者一定要做好幂等处理
消息发送失败有三种情况:同步发送失败、异步发送失败、消息刷盘失败
- 同步发送失败策略:
对于普通消息,消息发送默认采用round-robin策略来选择所发送到的队列。如果发送失败,默认重试2 次。但在重试时是不会选择上次发送失败的Broker,而是选择其它Broker。当然,若只有一个Broker其也只能发送到该Broker,但其会尽量发送到该Broker上的其它Queue。相关设置如下:
rocketmq:
#生产者配置
producer:
# 发送消息超时时间,默认 3000
send-message-timeout: 3000
# 发送消息失败重试次数,默认2
retry-times-when-send-failed: 2
如果超过重试次数,则抛出异常,由Producer去保证消息不丢。当然当生产者出现 RemotingException、MQClientException和MQBrokerException时,Producer会自动重投消息。
同时,Broker还具有失败隔离功能,使Producer尽量选择未发生过发送失败的Broker作为目标 Broker。其可以保证其它消息尽量不发送到问题Broker,为了提升消息发送效率,降低消息发送耗时。
- 异步发送失败策略:
异步发送失败重试时,异步重试不会选择其他broker,仅在同一个broker上做重试,所以该策略无法保 证消息不丢。 相关设置如下:
rocketmq:
#生产者配置
producer:
# 发送消息超时时间,默认 3000
send-message-timeout: 3000
# 异步消息发送失败重试次数
retry-times-when-send-async-failed: 2
- 消息刷盘失败策略:
消息刷盘超时(Master或Slave)或slave不可用(slave在做数据同步时向master返回状态不是 SEND_OK)时,默认是不会将消息尝试发送到其他Broker的。不过,对于重要消息可以通过在Broker 的配置文件设置retryAnotherBrokerWhenNotStoreOK属性为true来开启。
7.2.消费者重试
- 顺序消息重试
对于顺序消息消费失败默认会进行每隔1000毫秒进行重试,由于要保证消息是顺序消费,所以重试会导致后面的消息阻塞。可以通过下面的设置来修改重试间隔时间:
[注意]顺序消息没有发送失败重试机制,但具有消费失败重试机制 ,顺序消息重试是无止境的,为了防止消息一直重试阻塞,务必要做好监控工作。
- 无顺消息重试
对于无序消息(普通消息、延时消息、事务消息),当Consumer消费消息失败时,可以通过设置返回 状态达到消息重试的效果。不过需要注意,无序消息的重试只对集群消费方式生效,广播消费方式不提供失败重试特性。即对于广播消费,消费失败后,失败消息不再重试,继续消费后续消息
- 重试时间间隔
对于无序消息集群消费下的重试消费,每条消息默认最多重试16次,但每次重试的间隔时间是不同的,会逐渐变长。每次重试的间隔时间如: 1s 5s 10s …2h ,如果16次都重试失败,消息进入死信队列
可在broker.conf文件中配置Consumer端的重试次数和重试时间间隔,如下:
// 修改消费重试次数
consumer.setMaxReconsumeTimes(10);
- 重试队列
对于需要重试消费的消息,并不是Consumer在等待了指定时长后再次去拉取原来的消息进行消费,而 是将这些需要重试消费的消息放入到了一个特殊Topic的队列中,而后进行再次消费的。这个特殊的队
列就是重试队列。 当出现需要进行重试消费的消息时,Broker会为每个消费组都设置一个Topic名称为%RETRY%consumerGroup@consumerGroup 的重试队列。
消费端重试实例:
//最大重试次数,默认16
defaultMQPushConsumer.setMaxReconsumeTimes(10);
defaultMQPushConsumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> list, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
for(MessageExt message : list){
System.out.println(message+" ; "+new String(message.getBody(), CharsetUtil.UTF_8));
if(message.getReconsumeTimes() > 3){
//如果重试次数大于3,可以把消息持久化到数据库,然后另外使用一个定时任务去定时重试。
//甚至进行人工干预
//消息持久化到数据库,这里就没必要重试了,返回success
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
//出现异常,进行重试
try {
throw new Exception("出现异常了...");
} catch (Exception e) {
e.printStackTrace();
//稍后重试
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
};
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
8.死信队列(扩展)
8.1.死信队列概述
消息多次消费失败,达到最大重试次数,消息不会被丢弃而是进入死信队列(Dead-Letter Queue,DLQ),死信队列中的消息被称为死信消息(Dead-Letter Message,DLM)。
死信队列具有如下特征
- 死信队列中的消息无法再消费,死信队列对应Topic的权限为2,只有写权限,所以死信队列没有办法读取。
- 3天之后死信队列分钟的消息被删除,和普通消息一样
- 死信队列就是一个特殊的Topic,名称为%DLQ%consumerGroup@consumerGroup,其中每个队列都是死信队列
- 如果⼀个消费者组未产生死信消息,则不会为其创建相应的死信队列
如果出现死信队列,说明程序除了问题,程序员应该及时的排除,进行BUG的处理。我们应该在消费者重试次数达到一定程度就对消息进行持久化,方便后续的处理。或额外定时重试。
总结
对 RocketMQ分布式消息队列 基础知识学习和了解。