简介
RocketMQ是最终一致分布式事务解决方案,目前来说只有RocketMQ是支持分布式事务的,其他MQ只能说能实现,但是是通过ACK的机制实现的
源码地址:https://gitee.com/nssnail/spring-boot-rocketmq-test/tree/master
1.RocketMQ搭建(基于linux)
接下来我们使用docker快速搭建单机rocketmq,不得不说,docker真香~
目前为单机环境,暂时不写搭建集群,如果需要可以留言,我会考虑的
环境准备
- centos7
- docker
1.1 安装NameServer
拉取镜像:
docker pull rocketmqinc/rocketmq
创建namesrv数据存储目录:
mkdir -p /docker/rocketmq/data/namesrv/logs /docker/rocketmq/data/namesrv/store
安装NameSrv:
docker run -d \
--restart=always \
--name rmqnamesrv \
--privileged=true \
-p 9876:9876 \
-v /docker/rocketmq/data/namesrv/logs:/root/logs \
-v /docker/rocketmq/data/namesrv/store:/root/store \
-e "MAX_POSSIBLE_HEAP=100000000" \
rocketmqinc/rocketmq \
sh mqnamesrv
相关参数说明:
参数 | 说明 |
---|---|
-d | 以守护进程的方式启动 |
- -restart=always | docker重启时候容器自动重启 |
- -name rmqnamesrv | 把容器的名字设置为rmqnamesrv |
-p 9876:9876 | 把容器内的端口9876挂载到宿主机9876上面 |
-v /docker/rocketmq/data/namesrv/logs:/root/logs | 把容器内的/root/logs日志目录挂载到宿主机的 /docker/rocketmq/data/namesrv/logs目录 |
-v /docker/rocketmq/data/namesrv/store:/root/store | 把容器内的/root/store数据存储目录挂载到宿主机的 /docker/rocketmq/data/namesrv目录 |
rmqnamesrv | 容器的名字 |
-e “MAX_POSSIBLE_HEAP=100000000” | 设置容器的最大堆内存为100000000 |
rocketmqinc/rocketmq | 使用的镜像名称 |
sh mqnamesrv | 启动namesrv服务 |
1.2 安装Broker
border配置:创建broker.conf
配置文件vi /docker/rocketmq/conf/broker.conf
,配置如下:
# 所属集群名称,如果节点较多可以配置多个
brokerClusterName = DefaultCluster
#broker名称,master和slave使用相同的名称,表明他们的主从关系
brokerName = broker-a
#0表示Master,大于0表示不同的slave
brokerId = 0
#表示几点做消息删除动作,默认是凌晨4点
deleteWhen = 04
#在磁盘上保留消息的时长,单位是小时
fileReservedTime = 48
#有三个值:SYNC_MASTER,ASYNC_MASTER,SLAVE;同步和异步表示Master和Slave之间同步数据的机制;
brokerRole = ASYNC_MASTER
#刷盘策略,取值为:ASYNC_FLUSH,SYNC_FLUSH表示同步刷盘和异步刷盘;SYNC_FLUSH消息写入磁盘后才返回成功状态,ASYNC_FLUSH不需要;
flushDiskType = ASYNC_FLUSH
# 设置broker节点所在服务器的ip地址,如果使用vmware虚拟机记得使用vm的ip地址
brokerIP1 = 172.0.0.1
#剩余磁盘比例
diskMaxUsedSpaceRatio=99
安装Broker:
docker run -d \
--restart=always \
--name rmqbroker \
--link rmqnamesrv:namesrv \
-p 10911:10911 \
-p 10909:10909 \
--privileged=true \
-v /docker/rocketmq/data/broker/logs:/root/logs \
-v /docker/rocketmq/data/broker/store:/root/store \
-v /docker/rocketmq/conf/broker.conf:/opt/rocketmq-4.4.0/conf/broker.conf \
-e "NAMESRV_ADDR=namesrv:9876" \
-e "MAX_POSSIBLE_HEAP=200000000" \
rocketmqinc/rocketmq \
sh mqbroker -c /opt/rocketmq-4.4.0/conf/broker.conf
相关参数说明:
参数 | 说明 |
---|---|
-d | 以守护进程的方式启动 |
–restart=always | docker重启时候镜像自动重启 |
- -name rmqbroker | 把容器的名字设置为rmqbroker |
- --link rmqnamesrv:namesrv | 和rmqnamesrv容器通信 |
-p 10911:10911 | 把容器的非vip通道端口挂载到宿主机 |
-p 10909:10909 | 把容器的vip通道端口挂载到宿主机 |
-e “NAMESRV_ADDR=namesrv:9876” | 指定namesrv的地址为本机namesrv的ip地址:9876 |
-e “MAX_POSSIBLE_HEAP=200000000” rocketmqinc/rocketmq sh mqbroker | 指定broker服务的最大堆内存 |
rocketmqinc/rocketmq | 使用的镜像名称 |
sh mqbroker -c /opt/rocketmq-4.4.0/conf/broker.conf | 指定配置文件启动broker节点 |
1.3 控制台安装
拉取镜像:
docker pull pangliang/rocketmq-console-ng
控制台安装:
如果使用vmware虚拟机记得使用vm的ip地址
docker run -d \
--restart=always \
--name rmqadmin \
-e "JAVA_OPTS=-Drocketmq.namesrv.addr=172.0.0.1:9876 \
-Dcom.rocketmq.sendMessageWithVIPChannel=false" \
-p 8080:8080 \
pangliang/rocketmq-console-ng
关闭防火墙(或者开放端口)
#关闭防火墙
systemctl stop firewalld.service
#禁止开机启动
systemctl disable firewalld.service
访问 http://ip:8080 (ip为rocketmq所在的ip地址)
2 .集成Spring Boot测试
新建maven项目
2.1添加依赖
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.7.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.sise</groupId>
<artifactId>springboot-rocketmq</artifactId>
<version>0.0.1-SNAPSHOT</version>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring-boot.version>2.3.0.RELEASE</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<version>2.0.2</version>
</dependency>
</dependencies>
</project>
2.2 添加yml配置
server:
port: 8900
rocketmq:
#nameServerIP地址
name-server: ip:9876
producer:
group: test-group
send-message-timeout: 300000
compress-message-body-threshold: 4096
max-message-size: 4194304
retry-times-when-send-async-failed: 0
retry-next-server: true
retry-times-when-send-failed: 2
2.3 添加生产者消费者代码
- 创建生产者
创建MessageSender.java
@Component
public class MessageSender {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public void syncSend(){
this.syncSend(1);
}
public void syncSend(Integer nums){
/**
* 发送可靠同步消息 ,可以拿到SendResult 返回数据
* 同步发送是指消息发送出去后,会在收到mq发出响应之后才会发送下一个数据包的通讯方式。
* 这种方式应用场景非常广泛,例如重要的右键通知、报名短信通知、营销短信等。
*
* 参数1: topic:tag
* 参数2: 消息体 可以为一个对象
* 参数3: 超时时间 毫秒
*/
for (int i = 0; i < nums; i++) {
SendResult result= rocketMQTemplate.syncSend("test-send","测试消息:"+i,10000);
System.out.println(result.getMessageQueue());
}
}
/**
* 发送 可靠异步消息
* 发送消息后,不等mq响应,接着发送下一个数据包。发送方通过设置回调接口接收服务器的响应,并可对响应结果进行处理。
* 异步发送一般用于链路耗时较长,对于RT响应较为敏感的业务场景,例如用户上传视频后通过启动转码服务,转码完成后通推送转码结果。
*
* 参数1: topic:tag
* 参数2: 消息体 可以为一个对象
* 参数3: 回调对象
*/
public void asyncSend() throws Exception{
rocketMQTemplate.asyncSend("test*send", "这是一条异步消息", new SendCallback() {
@Override
public void onSuccess(SendResult sendResult) {
System.out.println("回调sendResult:"+sendResult);
}
@Override
public void onException(Throwable e) {
System.out.println(e.getMessage());
}
});
TimeUnit.SECONDS.sleep(100000);
}
/**
* 发送单向消息
* 参数1: topic:tag
* 参数2: 消息体 可以为一个对象
*/
public void sendOneWay(){
rocketMQTemplate.sendOneWay("springboot-topic:tag1", "这是一条单向消息");
}
/**
* 发送单向的顺序消息
*/
public void sendOneWayOrderly(){
for(int i=0;i<10;i++){
rocketMQTemplate.sendOneWayOrderly("springboot-topic:tag1", "这是一条顺序消息"+i,"2123");
rocketMQTemplate.sendOneWayOrderly("springboot-topic:tag1", "这是一条顺序消息"+i,"2123");
}
}
}
- 创建消费者
创建MessageConsumer.java
/**
* MessageModel:集群模式;广播模式
* ConsumeMode:顺序消费;无序消费
*/
@Component
@RocketMQMessageListener(topic = "springboot-topic",consumerGroup = "test-group",
messageModel = MessageModel.CLUSTERING, consumeMode = ConsumeMode.CONCURRENTLY)
public class MessageConsumer implements RocketMQListener<String> {
@Override
public void onMessage(String message) {
try {
System.out.println("----------接收到rocketmq消息:" + message);
} catch (Exception e) {
e.printStackTrace();
}
}
}
3.写接口测试
创建TestController.java
@RestController
@RequestMapping("/test")
public class TestController {
@Autowired
private MessageSender messageSender;
@GetMapping("/syncSend/{nums}")
public String syncSend() {
messageSender.syncSend();
return "发送成功";
}
@GetMapping("/syncSendBatch/{nums}")
public String syncSendBatch(@PathVariable("nums") Integer nums) {
messageSender.syncSend(nums);
return "发送成功";
}
@GetMapping("/asyncSend")
public String asyncSend() throws Exception {
messageSender.asyncSend();
return "发送成功";
}
@GetMapping("/sendOneWay")
public String sendOneWay() {
messageSender.sendOneWay();
return "发送成功";
}
@GetMapping("/sendOneWayOrderly")
public String sendOneWayOrderly() {
messageSender.sendOneWayOrderly();
return "发送成功";
}
}
rocketmq的安装以及测试就到此结束啦,自己可以去试试
3.了解RocketMQ的组件
上面通过部署和测试,我们对rocketmq大致上应该都有一些了解了,接下来我们来更深入的去接触下rocketmq。
我们先来看下rocketmq的架构图
3.1 Broker
RocketMQ的服务,或者说一个进程,叫做Broker, Broker 的作用是存储和转发消息
RocketMQ的队列存在Broker里,所以Broker主要是用来存储数据的,上图已经很直观的展示了一个简单的流程
作为一名开发者,我们要考虑的东西需要很多,一个Broker等于一个节点,那么单节点的情况就会出现单节点故障不可用等问题,这时候我们就要建立broker集群,这时候是建立多个master节点,如下图
但是要是都是master节点,数据是负载均衡的,如果一条消息过来,是两个master节点都有数据还是只有一个master节点有数据呢,那当然是只有一个master节点有数据啦,不然怎么达到负载均衡的目的,而且也不符合分片思想。那么这就会遇到另一个问题,如果其中一个master宕机了咋办,那岂不是数据直接丢失了?所以Broker还要建立主从复制的节点,也就是每个master(从)节点要有多个slave(从)节点,从而达到高可用的目的(如下图)
默认情况下,读写都发生在master上。在slaveReadEnable=true
的情况下,slave也可以参与读负载。但是默认只有Brokerld=1的slave才会参与读负载,而且是在master消费慢的情况下,由whichBrokerWhenConsumeSlowly
这个参数决定。
- Brokerld在broker.conf中配置
在org.apache.rocketmq.common.subscription.SubscriptionGroupConfig
中能看到这个参数
private long whichBrokerWhenConsumeSlowly = 1L;
3.2 Topic
Topic用于将消息按主题做划分,比如订单消息、物流消息。注意,跟kafka不同的是,在RocketMQ中,Topic是一个逻辑概念,消息不是按Topic划分存储的。
可能这样听到topic还会有点懵,刚才在架构图中并没有看到,因为那个架构是整体的架构,而topic其实就是一个逻辑概念,就是用来区分消息类型的。我们来结合下面这幅图可能更好理解点
我把消息分为了三个大类,订单(order)、短信(phone),邮件(email)三个消息模块,而每个broker集群中都会存在他们的分片
比如当一条订单消息过来时,他就会与topic绑定,并且通过轮询的方式发送到broker-a,broker-b,broker-c中,就比如order-msg1会发送到broker-a中,order-msg2会发送到broker-b中,然后消费者订阅这个topic就能完成消费
但是有一点我们需要注意下
在org.apache.rocketmq.common.BrokerConfig中有个autoCreateTopicEnable属性,如下所示
private boolean autoCreateTopicEnable = true;
在他为true的时候代表当生产者生产消息的时候如果绑定topic不存在则会自动创建topic
但是这里会有个很大的坑,如果面试问到你们是自动创建topic还是手动创建topic记得回答是手动创建topic
因为如果是自动创建topic,那么这个topic只会存在于broker-a中,并不会在所有集群中都会有这个topic,这么就不会达到高可用的目的,在这生产线上也是个严重的问题来的,所以一般都会去手动创建topic
至于原因我就不详细说了,推荐一篇文章你们有兴趣可以看下
生产环境中,autoCreateTopicEnable为什么不能设置为true
3.2.1 Tags
说到topic也不得不说一下tags。很多人会把topic和tags分成一个组件说,但是其实放在一起说可能更容易让人理解。
tags其实是一个标签,这样可能很多人都会不会很理解,明明已经有topic分类了为什么还要tags。
其实这类似于二级分类,好比如订单tags,我可以把增删改拆分出来,order|create,order|update,,order|delete
是吧,又好比如我们把邮件类型拆分,通知邮件,验证邮件,订阅邮件等按类型拆分
这时候可能会有人问,为什么没有三级分类四级分类这样会更好吗。其实现在这样已经满足了觉大部分需求了,而且这是个消息中间件不是所有业务代码都会使用到他,再细分会提高中间件的复杂度
3.3 NameServer
当不同的消息存储在不同的Broker上,生产者和消费者对于Broker的选取,或者说路由选择是一个非常关键的问题。
-
(路由)生产者发一条消息,应该发到哪个Broker?消费者接收消息,从哪个Broker获取消息?
-
(服务端增减)如果Broker 增加或者减少了,客户端怎么知道?
-
(客户端增加)一个新的生产者或者消费者加入,怎么知道有哪些Broker?
所以,跟分布式的服务调用的场景需要一个注册中心一样,在RocketMQ中需要有一个角色来管理Broker的信息。
就像Kafka是使用Zookeepper来当注册中心来管理服务的
然而RocketMQ则自己实现了一个类似注册中心的服务,叫NameServer
我们可以把 NameServer理解为是RocketMQ的路由中心,每一个NameServer节点都保存着全量的路由信息,为了保证高可用,NameServer自身也可以做集群的部署。它的作用有点像Eureka或者Redis Sentinel。也就是说,Broker 会在NameServer上注册自己,Porducer和Consumer用NameServer来发现Broker。
NameServer作为路由中心到底怎么工作的呢?
每个Broker节点在启动时,都会根据配置遍历NameServer列表。
在broker.conf配置文件中,有个namesrvAddr属性是配置是配置NameServer的服务地址的,可配置多个
namesrvAddr=localhost:9876
Broker与每个NameServer建立TCP长连接,注册自己的信息,之后每隔30s发送心跳信息(服务主动注册)。
如果Broker挂掉了,不发送心跳了,NameServer怎么发现呢?
所以除了主从注册,还有定时探活。每个NameServer每隔10s检查一下各个Broker的最近一次心跳时间,如果发现某个Broker超过120s都没发送心跳,就认为这个Broker已经挂掉了,会将其从路由信息里移除。
为什么不用Zookeeper?
实际上不是不用,在RocketMQ的早期版本,即 MetaQ 1.x和2.x阶段,服务管理也是用Zookeeper实现的,跟kafka一样。但MetaQ 3.x(即RocketMQ)却去掉了ZooKeeper依赖,转而采用自己的NameServer。
RocketMQ的架构设计决定了只需要一个轻量级的元数据服务器就足够了,只需要保持最终一致,而不需要Zookeeper这样的强一致性解决方案,不需要再依赖另一个中间件,从而减少整体维护成本。
根据著名的CAP理论:一致性(Consistency)、可用性(Availability)、分区容错(Partiton Tolerance)。Zookeeper实现了CP,NameServer选择了AP,放弃了实时一致性。
一致性问题如何解决?
一个天大的问题来了,NameServer之间是互相不通信的,也没有主从之分,它们是怎么保持一致性的?
我们从一下三点分析下
-
服务注册
如果新增了Broker,怎么新增到所有的NameServer 中?
因为没有master,Broker每隔30秒
会向所有
的NameServer发送心跳信息,所以还是能保持一致的。 -
服务剔除
如果一个Broker挂了,怎么从所有的 NameServer 中移除它的信息?
(1)如果Broker正常关闭:连接就断开了,Netty的通道关闭监听器会监听到连接断开事件,然后会将这个Broker信息剔除掉。
(2)如果Broker异常关闭:NameServer的定时任务每10秒扫描Broker列表
,如果某个Broker的心跳包的最新时间戳超过当前时间120秒,就会被移除
。通过以上两点,不管Broker是挂了,还是恢复了,增加了还是减少了,NameServer都能够保持数据一致。
-
路由发现
如果Broker的信息更新了(增加或者减少节点),客户端怎么获取最新的Broker列表?
先说生产者。发送第一条消息的时候,根据Topic 从 NameServer获取路由信息。然后是消费者。消费者一般是订阅固定的Topic,在启动的时候就要获取 Broker信息。
这之后呢?如果说Broker信息动态变化了怎么办?
因为NameServer不会主动推送服务信息给客户端,客户端也不会发送心跳至Nameserver,所以在建立连接之后,需要生产者和消费者定期更新。
在MQClientlnstance类(生产者消费者通用)的start
方法中,启动了一个定时任务(237行)︰
startScheduledTask()是用来启动定时任务的
在startScheduledTask方法中updateTopicRoutelnfoFromNameServer()方法,是用来定期更新NameServer信息的,默认是30秒获取一次。
this.clientConfig.getPollNameServerInterval()
最后点击去看到的值为30000,则为30s
private int pollNameServerInterval = 30000;
消费者和生产者是以相同的时间间隔,更新NameServer信息的
所以各个NameServer的数据是能够保持一致的。而且生产者和消费者会定期更新路由信息,所以可以获取最新的信息。
问题:如果Broker刚挂,客户端30秒以后才更新路由信息,那是不是会出现最多30秒钟的数据延迟?比如说一个Broker刚挂了,客户端缓存的还是旧的路由信息,发消息和接收消息都会失败。
这个问题有几个解决思路:
-
重试;
-
把无法连接的Broker隔离掉,不再连接;
-
或者优先选择延迟小的节点,就能避免连接到容易挂的Broker 了
问题:如果作为路由中心的 NameServer 全部挂掉了,而且暂时没有恢复呢?
客户端会缓存Broker的信息,不能完全依赖于NameServer
3.4 Producer
生产者,用于生产消息,会定时从NameServer拉取路由信息,然后根据路由信息与指定的 Broker建立TCP长连接,从而将消息发送到Broker中。发送逻辑一致的Producer可以组成一个Group。
注:Producer '写数据只能操作master节点。
3.5 Consumer
消息的消费者,通过NameServer集群获得Topic的路由信息,连接到对应的Broker上消费消息。消费逻辑一致的Consumer可以组成一个Group,这时候消息会在Consumer之间负载。
由于Master和Slave都可以读取消息,因此Consumer 会与Master和Slave都建立连接。
注意:同一个consumer group内的消费者应该订阅同一个topic。或者反过来,消费不同topic的消费者不应该采用相同的consumer group名字。如果不一样后面的消费者的订阅,会覆盖前面的订阅。
消费者有两种消费方式:一种是集群消费(消息轮询)
,一种是广播消费(全部收到相同副本)
。
这个rabbitmq的工作模式和广播模式一样,工作模式只会发给一个队列,而广播模式会分发给所有符合规则的队列,相信这个大家都能理解
3.5.1 pull
从消费模型来说,RocketMQ支持pull
和push
两种模式。
Pull模式是consumer轮询从 broker拉取消息。实现类:DefaultMQPullConsumer(过时),替代类:DefaultLitePullConsumer。
Pull有两种实现方式:一种是普通的轮询(Polling)。不管服务端数据有无更新,,客户端每隔定长时间请求拉取一次数据,可能有更新数据返回,也可能什么都没有。
普通轮询的缺点:因为大部分时候没有数据,这些无效的请求会大大地浪费服务器的资源。而且定时请求的间隔过长的时候,会导致消息延迟。
RocketMQ的pull用长轮询
来实现。
客户端发起Long Polling,如果此时服务端没有相关数据,会hold 住请求,直到服务端有相关数据,或者等待一定时间超时才会返回。返回后,客户端又会立即再次发起下一次Long Polling (所谓的hold住请求指的服务端暂时不回复结果,保存相关请求,不关闭请求连接,等相关数据准备好,写回客户端)。
长轮询解决了轮询的问题,唯一的缺点是服务器在挂起的时候比较耗内存
。
3.5.2 push
Push模式是Broker推送消息给consumer,实现类:DefaultMQPushConsumer.
RocketMQ的 push 模式实际上是基于pul模式实现的,只不过是在pull模式上封装了一层,所以RocketMQ push模式并不是真正意义上的“推模式”。
在RocketMQ中,PushConsumer会注册MessageListener 监听器,取到消息后,唤醒MessageListener的consumeMessage()
来消费,对用户而言,感觉消息是被推送过来的。
3.6 Message Queue
对于每个topic都可以设置一定数量消息队列对数据进行读取
我们创建Topic的时候会指定队列的数量,一个叫 writeQueueNums(写队列数量),一个readQueueNums(读队列数量)。
写队列的数量决定了有几个Message Queue,读队列的数量决定了有几个线程来消费这些Message Queue(只是用来负载的)。
那不指定MQ的时候,默认有几个MQ呢?
服务端创建一个Topic默认8个队列(BrokerConfig.java) :
private int defaultTopicQueueNums = 8;
topic不存在,生产者发送消息时创建默认4个队列(DefaultMQProducer.java) :
38 private volatile int defaultTopicQueueNums; 54 defaultTopicQueueNums=4
MessageQueue在磁盘上是可以看到的,但是数量只跟写队列相关。
客户端封装了一个MessageQueue对象,里面其实就是三块内容:
我们在rocketmq控制台创建一个test-send的topic,分别有两个写队列和一个读队列,这里需要搭建集群才能测试
读队列分布情况
如果我们发送6条消息,给消息依次编号,会选择什么队列发送呢?
消息接收顺序如下图所示
a-q0, a-q1, b-q0,b-q1, a-q0, a-q1。
那么反过来读队列只有一个的话,就会出现这种情况
发6条就会有三条接收不到
这时候把topic修改成1个写队列和两个读队列
发送6条消息结果为
结论:结论:读写队列数量最好一致,否则会出现消费不了的情况。
思考:Queue的数量到底会产生什么影响?
答:Queue的数量要比Broker的数量多(倍数),才能实现尽量平均的负载,或者应对未来的扩容。
4.RocketMQ原理
4.1 生产者
Message Queue是用来实现横向扩展的,生产者利用队列可以实现消息的负载和平均分布。那什么时候消息会发到哪个队列呢?
4.1.1 消息发送规则
从Producer的send方法开始跟踪,在 DefaultMQProducerlmpl类会看到一个selectOneMessageQueue()的方法
这里是MQ负载均衡核心代码,在MQFaultStrategy.java
中
public MessageQueue selectOneMessageQueue(TopicPublishInfo tpInfo, String lastBrokerName) {
if (this.sendLatencyFaultEnable) {
try {
int index = tpInfo.getSendWhichQueue().getAndIncrement();
int i = 0;
//获取一个可用的并且brokerName=lastBrokerName的消息队列
while(true) {
int writeQueueNums;
MessageQueue mq;
if (i >= tpInfo.getMessageQueueList().size()) {
//选择一个相对好的broker,不考虑可用性的消息队列
String notBestBroker = (String)this.latencyFaultTolerance.pickOneAtLeast();
writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker);
if (writeQueueNums > 0) {
mq = tpInfo.selectOneMessageQueue();
if (notBestBroker != null) {
mq.setBrokerName(notBestBroker);
mq.setQueueId(tpInfo.getSendWhichQueue().getAndIncrement() % writeQueueNums);
}
return mq;
}
this.latencyFaultTolerance.remove(notBestBroker);
break;
}
writeQueueNums = Math.abs(index++) % tpInfo.getMessageQueueList().size();
if (writeQueueNums < 0) {
writeQueueNums = 0;
}
mq = (MessageQueue)tpInfo.getMessageQueueList().get(writeQueueNums);
if (this.latencyFaultTolerance.isAvailable(mq.getBrokerName()) && (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName))) {
return mq;
}
++i;
}
} catch (Exception var7) {
log.error("Error occurred when selecting message queue", var7);
}
//随机选择一个消息队列
return tpInfo.selectOneMessageQueue();
} else {
//获得 lastBrokerName 对应的一个消息队列,不考虑该队列的可用性
return tpInfo.selectOneMessageQueue(lastBrokerName);
}
}
MessageQueueSelector有三个实现类:
- SelectMessageQueueByHash(默认)︰它是一种不断自增、轮询的方式。
- SelectMessageQueueByRandom:随机选择一个队列。
- SelectMessageQueueByMachineRoom:返回空,没有实现。
除了上面自带的策略,也可以自定义MessageQueueSelector,作为参数传进去
rocketMQTemplate.setMessageQueueSelector(new MessageQueueSelector() {
@Override
public MessageQueue select(List<MessageQueue> list, Message message, Object o) {
Integer id=(Integer)(o);
int index=id%list.size();
return list.get(index);
}
});
4.1.2 顺序消息
顺序消息的场景:一个客户先提交了一笔订单,然后支付,后面又发起了退款,产生了三条消息:一条提交订单的消息,一条支付的消息,一条退款的消息。
这三笔消息到达消费者的顺序,肯定要跟生产者产生消息的顺序一致。不然,没有订单,不可能付款;没有付款,是不可能退款的。
要解决顺序消息的问题,根本要从两个地方解决,一是保证消息入队是顺序的,二是消费者消费是顺序的
(一) 生产者方面
正常情况因为负载均衡,会出现以下情况
先不说消费者,就说生产者,这种情况明显可能会导致出现支付订单->订单生成->订单退款这样的消费顺序,这样肯定是不合理的,那么我们该怎么做呢
其实只要保证入队是同一个队列,那么队列中的消息肯定是有序的
因为在业务代码中他肯定时有序的,只要保证入队在同一个队列,那么他在队列中就是有序的
那么怎么保证他入队是有序的呢
我们可以把需要顺序消费的消息入队的时候传入一个相同的hash值,用队列数%hash值
就可以保证他们是在同一个队列中
如下所示,第一章节的代码中有写
rocketMQTemplate.sendOneWayOrderly("springboot-topic:tag1", "这是一条顺序消息"+i,"2123");
rocketMQTemplate.sendOneWayOrderly("springboot-topic:tag1", "这是一条顺序消息"+i,"2123");
(二) 消费者方面
在上小节中,既然生产顺序保证了,但是消费顺序没保证
如上图所示,在多个消费者的模式下,就有可能出现以下情况
-
消费者1拿到订单生成订单
-
消费者2拿到支付订单消息
-
但是消费者1还没执行完的时候,消费者2直接消费
这样还是会有顺序消费的问题
那么消费者方我们应该如何做呢
如上图所示,当消费者1拿到订单生成这个消息事,把queue1队列锁住,只有消费完才能放开,那么消费者2就会去queue2去消费其他消息
这种方案只会锁住一个队列,并不会影响其他队列,牺牲小部分性能来达到顺序消费的目的
4.1.3 事务消息
事务消息
是RocketMQ最重要的一个特性,而且他经历过双11的洗礼,是非常稳定而且强大的,在所有MQ中,只有RocketMQ支持事务消息
我们先来说一下事务消息的应用场景。
随着应用的拆分,从单体架构变成分布式架构,每个服务或者模块也有了自己的数据库。一个业务流程的完成需要经过多次的接口调用或者多条MQ消息的发送。
举个例子,在一笔贷款流程中,提单系统登记了本地的数据库,资金系统和放款系统必须也要产生相应的记录。这个时候,作为消息生产者的提单系统,不仅要保证本地数据库记录是成功的,还要关心发出去的消息是否被成功Broker接收。也就是要么都成功要么都失败。
问题来了,如果是多个DML的操作,数据库的本地事务是可以保证原子性的(通过undo log)。但是一个本地数据库的操作,一个发送MQ的操作,怎么把他们两个放在一个逻辑单元里面执行呢?
我们来看下图,这是官方经过翻译后的流程图
参考文档: https://rocketmq.apache.org/rocketmq/the-design-of-transactional-message/
- 生产者向 MQ 服务器发送半条消息。
- MQ 服务器给生产者返回发送成功
- 发送半消息成功后,执行本地事务。
- 根据本地事务结果向 MQ Server 发送提交或回滚消息。
- 如果在本地事务执行过程中未提交/回滚消息或生产者挂起,MQ 服务器将向同一组中的每个生产者发送检查消息以获取事务状态。
- 生产者根据本地事务状态回复提交/回滚消息。
- 已提交的消息将传递给消费者,但回滚的消息将被 MQ 服务器丢弃。
我们再举个通俗一点的例子,我们日常所说的银行转账问题
案例:银行A转账1000元到银行B
流程如下:
- 银行A扣款1000元
- 扣款成功往mq发送消息
- 银行B收到消息增加1000元
条件:要么都成功要么都失败(分布式事务)
RocketMQ事务如下:
(1) 正常状态:
- A系统向MQ发送Half消息,发送Half消息在MQ上,也叫半消息,这时候这消息在MQ是不可消费的
- 如果返回成功,则开启并执行本地事务
- 如果成功则返回commit给MQ
此时A系统的事务已经处理完毕,已经提交给MQ,这时候B系统才可以去消费,所以后面已经完全跟A系统没有关系了,只需要等待B系统消费信息即可,如果B系统异常回滚只需要修复好后继续消费就可以了
(2) 异常状态
- A系统向MQ发送Half消息
- 如果返回成功,则开启并执行本地事务
- A系统异常,事务回滚
- 移除MQ中的消息,B系统不会去消费
此时A系统回滚了,移除MQ的半消息,既然消息都没了,那么B系统就肯定不会去消费了
(3) 网络延迟
如果网络延迟状态A系统一直不给MQ发送事务状态,那么MQ就会定时给系统A发送消息回查,直到接收到事务消息,得到事务消息后就是(1),(2)中的步骤了
Spring Boot 代码如下
生产者
@Component
@RocketMQTransactionListener(txProducerGroup = "test-topic")
public class TestTransactionListenerImpl implements RocketMQLocalTransactionListener {
@Override
public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object o) {
try {
//================本地事务操作开始=====================================
//修改本地状态
System.out.println("------------进入本地事务-----------");
//================本地事务操作结束=====================================
} catch (Exception e) {
//异常,消息回滚
e.printStackTrace();
return RocketMQLocalTransactionState.ROLLBACK;
}
return RocketMQLocalTransactionState.UNKNOWN;
}
/***
* 消息回查
* @param message
* @return
*/
@Override
public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
//这里是测试永远返回commit,正常业务应该判断状态并返回正确的状态
return RocketMQLocalTransactionState.COMMIT;
}
}
消费者
@Component
@RocketMQMessageListener(topic = "test-transaction", consumerGroup = "test-topic")
public class TestTransactionConsumer implements RocketMQListener, RocketMQPushConsumerLifecycleListener {
/***
* 监听消息
* 实现RocketMQPushConsumerLifecycleListener监听器之后,此方法不调用
* @param message
*/
@Override
public void onMessage(Object message) {
}
/***
* 消息监听
* @param consumer
*/
@Override
public void prepareStart(DefaultMQPushConsumer consumer) {
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) {
try {
for (MessageExt msg : msgs) {
String result = new String(msg.getBody(),"UTF-8");
System.out.println(result);
}
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
//消费状态
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
}
}
发送消息
Message message = MessageBuilder.withPayload("测试mq").build();
rocketMQTemplate.sendMessageInTransaction("test-topic", "test-transaction", message, null);
在上面的TestTransactionListenerImpl的executeLocalTransaction()
方法中,必须返回一个状态,状态有三种:
状态 | 描述 |
---|---|
COMMIT | 表示事务消息被提交,会被正确分发给消费者 |
ROLLBACK | 该状态表示该事务消息被回滚,因为本地事务逻辑执行失败导致 |
UNKNOW | 表示事务消息未确定,返回UNKNOW之后,因为不确定到底事务有没有成功,Broker 会主动发起对事务执行结果的查询(消息回查) |
4.1.4 延迟消息
延迟消息最常用的场景就是订单超时关闭订单
在以前关闭订单是通过定时任务扫描关闭的,这种做法的缺点就是
- 频繁扫描订单表
- 因为会有时间间隔,会产生误差
而现在基本都会用延迟队列实现,优势如下
- 避免频繁扫描表
- 在准确时间关闭订单
在RabbitMQ里面需要通过死信队列或者插件来实现。RocketMQ可以直接文持延迟消息。但是开源版本功能被阉割了,只能支持特定等级的消息。商业版本可以仕意指定时间。
比如level=3代表10秒。一共支持18个等级,延时级别配置代码在MessageStoreConfig#messageDelayLevel 中
this.messageDelayLevel = "1s 5s 10s 30s lm 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h";
spring boot中
rocketMQTemplate.syncSend(topic, message,1000,3);//表示延时10秒
实现原理
Broker端内置延迟消息处理能力,核心实现思路都是一样:将延迟消息通过一个临时存储进行暂存,到期后才投递到目标Topic 中。
步骤说明如下:
- producer要将一个延迟消息发送到某个Topic中;
- Broker判断这是一个延迟消息后,将其通过临时存储进行暂存;
- Broker内部通过一个延迟服务(delay service)检查消息是否到期,将到期拍消息投递到目标Topic 中;
- 消费者消费目标topic 中的延迟投递的消息。
事实上,RocketMQ的消息重试也是基于延迟消息来完成的。在消息消费失败的情况下,将其重新当做延迟消息投递回Broker。
4.2 Broker
4.2.1 消息存储
RocketMQ的消息存储与Kafka有所不同。既没有分区的概念,也没有按分区存储消息。
RocketMQ官方对这种设计进行了解释:
http://rocketmq.apache.org/rocketmq/how-to-support-more-queues-in-rocketmq/
- 所有消息数据都存储在提交日志文件中。所有写入都是完全顺序的,而读取是随机的。
- ConsumeQueue 存储了实际的用户消费位置信息,也按顺序刷入磁盘。
优点:
- 每个消费队列都是轻量级的,包含有限数量的元数据。
- 对磁盘的访问是完全顺序的,避免了磁盘锁争用,并且不会在创建大量队列时产生高磁盘IO等待。
缺点:
- 消息消费会先读取消费队列,然后提交日志。在最坏的情况下,此过程会带来一定的成本。
- 提交日志和消费队列需要在逻辑上保持一致,这给编程模型带来了额外的复杂性。
所以RocketMQ设计了一种新的文件存储方式,就是所有的Topic的所有的消息全部写在同一个文件中(这种存储方式叫集中型存储或者混合型存储
)
当然消费的时候就有点复杂了。
在kafka 中是一个topic下面的partition有独立的文件,只要在一个topic里面找消息就OK了, kafka把这个consumer group跟topic的offset的关系保存在一个特殊的toic中
现在变成了:要到一个统一的巨大的commitLog种去找消息,需要遍历全部的消息,效率太低了。
怎么办呢?
如果想要每个consumer group只查找自己的topic的 offset信息,可以为每一个consumer group把他们消费的topic的最后消费到的offset单独存储在一个地方
这个存储消息的偏移量的对象就叫做consume queue
。
也就是说,消息在Broker存储的时候,不仅写入commitlog
,同时也把在commitlog中的最新的offset(异步
)写入对应的consume queue。
消费者在消费消息的时候,先从consume queue读取持久化消息的起始物理位置偏移量offset、大小size和消息Tag的HashCode值,随后再从commit log中进行读取待拉取消费消息的真正实体内容部分。
consume queue可以理解为消息的索引,它里面没有存消息。
总结:
-
写虽然完全是顺序写,但是读却变成了完全的随机读(对于commit log)
-
读一条消息,会先读consume queue,再读commit log,增加了开销。
4.2.2 物理文件存储
4.2.2.1 存储文件分析
在打开broker.conf,部分配置如下
#删除文件时间点,默认凌晨 4点
deleteWhen=04
#文件保留时间,默认 48 小时
fileReservedTime=120
#commitLog每个文件的大小默认1G
mapedFileSizeCommitLog=1073741824
#ConsumeQueue每个文件默认存30W条,根据业务情况调整
mapedFileSizeConsumeQueue=300000
#destroyMapedFileIntervalForcibly=120000
#redeleteHangedFileInterval=120000
#检测物理文件磁盘空间
diskMaxUsedSpaceRatio=88
#存储路径
storePathRootDir=/data/rocketmq/store
#commitLog 存储路径
storePathCommitLog=/data/rocketmq/store/commitlog
#消费队列存储路径存储路径
storePathConsumeQueue=/data/rocketmq/store/consumequeue
#消息索引存储路径
storePathIndex=/data/rocketmq/store/index
#checkpoint 文件存储路径
storeCheckpoint=/data/rocketmq/store/checkpoint
#abort 文件存储路径
abortFile=/data/rocketmq/store/abort
重点在commitLog,consumequeue,index file
(一) commit log
commit log,一个文件集合,每个默认文件1G大小。当第一个文件写满了,第二个文件会以初始偏移量命名。比如起始偏移量1080802673,第二个文件名为00000000001080802673,以此类推。
跟kafka一样,commit log 的内容是在消费之后是不会删除的。有什么好处?
-
可以被多个consumer group重复消费。只要修改consumer group,就可以从头开始消费,每个consumer group维护自己的offset。
-
支持消息回溯,随时可以搜索。
(二) consume queue
consume queue:一个Topic可以有多个,每一个文件代表一个逻辑队列,这里存放消息在commit log的偏移值以及大小和Tag属性。
从实际物理存储来说,consume queue对应每个Topic和Queueld下面的文件。单个文件由30W条数据组成,大小600万个字节(约5.72M)。当一个ConsumeQueue类型的文件写满了,则写入下一个文件。
(三) index file
前面我们在使用API方法的时候,看到Message有一个keys参数,它是用来检索
消息的。所以,如果出现了keys,服务端就会创建索引文件,以空格分割的每个关键字都会产生一个索引。
单个IndexFile可以保存2000W个索引,文件固定大小约为40OM。
索引的目的是根据关键字快速定位消息。根据关键字定位消息?那这样的索引用什么样的数据结构合适?
HashMap,没错,RocketMQ的索引是一种哈希索引。由于是哈希索引,key尽量设置为唯一不重复。
4.2.2.2 持久化分析
RocketMQ消息存储在磁盘上,但是还是能做到这么低的延迟和这么高的吞吐,到底是怎么实现的呢?
(一) page cache
首先要介绍Page Cache
的概念。
CPU如果要读取或者操作磁盘上的数据,必须要把磁盘的数据加载到内存
,这个是由硬件结构和访问速度的差异决定的。
这个加载的大小有一个固定的单位,叫做 Page。x86的linux中一个标准页面大小是4KB。如果要提升磁盘访问速度,或者说尽量减少磁盘V/O,可以把访问过的Page在内存中缓存起来。这个内存的区域就叫做 Page Cache。
下次处理I/O请求的时候,先到 Page Cache查找,找到了就直接操作。没找到就到磁盘查找。
Page Cache本身也会对数据文件进行预读取,对于每个文件的第一个读请求操作,系统在读入所请求页面的同时会读入紧随其后的少数几个页面。
但这里还是有一个问题。我们知道,虚拟内存分为内核空间和用户空间。Page Cache属于内核空间,用户空间访问不了,因此读取数据还需要从内核空间
拷贝到用户空间缓冲区
。
普通I/O模型如下
如上图,我们执行一次read()操作时,数据会从本地磁盘copy到内核空间的缓冲区,然后再从内核空间复制到用户空间的缓冲区
那么进行一次的读写流程如下
如上图所示,程序在读写的时候,2,3过程经历了两次复制,但是其实这两次复制并不是必须的,那么socket缓冲区是否可以直接在page cache中拿到数据,直接不经过我用户空间这里呢
其实是有办法的,我们称之为MMAP模式
(二) MMAP
MMAP模型
MMAP直接映射了内核空间的数据,使用户空间和内核空间共享数据
那么在MMAP模型下进行一次的读写流程如下
这样我们就能很直观的看到我们现在减少了之前普通I/O那种复制过程,可以从socket缓冲区直接倒page cache拿到数据
这里顺便提一句
零拷贝是相对用户空间缓存和内核空间缓存来说的,并不是说DMA复制
MMAP的优点很明显
减少了CPU复制的次数
MMAP缺点
MMAP是以
页(PAGE_SIZE)
的方式存储的,如果大小不足一页则补足
(三) RocketMQ MMAP
首先,RocketMQ底层对CommitLog、ConsumeQueue
之类的磁盘文件的读写操作,基本上都会采用mmap技术来实现。
如果具体到代码层面,就是基于JDK NIO包下的MappedByteBuffer
的map()
函数,来先将一个磁盘文件(比如一个CommitLog文件,或者是一个ConsumeQueue文件)映射到内存里来
上小节也说到 MMAP是以页的方式存储的,而且大小一般限制在1.5GB~2GB之间
所以RocketMQ才让CommitLog单个文件在1GB,ConsumeQueue文件在5.72MB,不会太大。
这样限制了RocketMQ底层文件的大小,就可以在进行文件读写的时候,很方便的进行内存映射了。而且这也是为什么CommitLog满了后会继续创建CommitLog配置大小(默认1G)的文件,并且以大小偏移量命名的原因
预映射机制 + 文件预热机制
来自 https://github.com/apache/rocketmq/blob/master/docs/cn/design.md
页缓存(PageCache)是OS对文件的缓存,用于加速对文件的读写。一般来说,程序对文件进行顺序读写的速度几乎接近于内存的读写速度,主要原因就是由于OS使用PageCache机制对读写访问操作进行了性能优化,将一部分的内存用作PageCache。对于数据的写入,OS会先写入至Cache内,随后通过异步的方式由pdflush内核线程将Cache内的数据刷盘至物理磁盘上。对于数据的读取,如果一次读取文件时出现未命中PageCache的情况,OS从物理磁盘上访问读取文件的同时,会顺序对其他相邻块的数据文件进行预读取。
在RocketMQ中,ConsumeQueue逻辑消费队列存储的数据较少,并且是顺序读取,在page cache机制的预读取作用下,Consume Queue文件的读性能几乎接近读内存,即使在有消息堆积情况下也不会影响性能。而对于CommitLog消息存储的日志数据文件来说,读取消息内容时候会产生较多的随机访问读取,严重影响性能。如果选择合适的系统IO调度算法,比如设置调度算法为“Deadline”(此时块存储采用SSD的话),随机读的性能也会有所提升。
另外,RocketMQ主要通过MappedByteBuffer对文件进行读写操作。其中,利用了NIO中的FileChannel模型将磁盘上的物理文件直接映射到用户态的内存地址中(这种Mmap的方式减少了传统IO将磁盘文件数据在操作系统内核地址空间的缓冲区和用户应用程序地址空间的缓冲区之间来回进行拷贝的性能开销),将对文件的操作转化为直接对内存地址进行操作,从而极大地提高了文件的读写效率(正因为需要使用内存映射机制,故RocketMQ的文件存储都使用定长结构来存储,方便一次将整个文件映射至内存)。
4.2.3 文件清理
跟kafka一样,RocketMQ中被消费过的消息是不会删除的,所以保证了文件的顺序写入。如果不清理文件的话,文件数量不断地增加,最终会导致磁盘可用空间越来越少
。
首先,哪些文件需要清理?主要清除CommitLog、ConsumeQueue的过期文件
。
什么情况下这些文件变成过期文件?
默认是超过72个小时的文件。
过期文件什么时候删除呢
-
通过定时任务,每天凌晨4点,删除这些过期的文件。(broker.conf可配置)
-
如果说磁盘已经快写满了,还要等到凌晨4点嘛?第二种情况就是磁盘使用空间过了75%,开始删除过期文件。
如果磁盘空间使用率超过85%,会开始批量清理文件,不管有没有过期,直到空间充足。
如果磁盘空间使用率超过90%,会拒绝消息写入。
4.3 消费者
在集群消费模式下,如果我们要提高消费者的负载能力,必然要增加消费者的数量。消费者的数量增加了,怎么做到尽量平均的消费消息呢?队列怎么分配给相应的消费者?
首先,队列的数量是固定的。比如有4个队列,假设有3个消费者,或者5个消费者,这个时候队列应该怎么分配?
消费者挂了?消费者增加了?队列又怎么分配?
4.3.1 消费端的负载均衡
消费者增加的时候肯定会引起rebalance,所以先从消费者启动的代码入手,这里面有几行关键的代码:
DefaultMQPushConsumerImpl.java的start()
this.mQClientFactory = MQClientManager.getInstance().getAndCreateMQClientInstance(this.defaultMQPushConsumer, this.rpcHook);
/*此处忽略代码*/
this.mQClientFactory.start();
点进start方法,他实际调用的是RebalanceService的run方法
this.rebalanceService.start();
点进RebalanceService.java查看
//waitInterval设置等待时间
private static long waitInterval = Long.parseLong(System.getProperty("rocketmq.client.rebalance.waitInterval", "20000"));
/*此处忽略代码*/
public void run() {
this.log.info(this.getServiceName() + " service started");
while(!this.isStopped()) {
this.waitForRunning(waitInterval);
this.mqClientFactory.doRebalance();
}
this.log.info(this.getServiceName() + " service end");
}
也就是说,消费者启动的时候,或者有消费者挂掉的时候,默认最多20秒,就会做一次rebalance,让所有的消费者可以尽量均匀地消费队列的消息。
具体到底怎么rebalance的呢?
找到RebalanceImpl.java的rebalanceByTopic()
/*截取关键代码*/
List<MessageQueue> mqAll = new ArrayList();
AllocateMessageQueueStrategy strategy = this.allocateMessageQueueStrategy;
allocateResult = strategy.allocate(this.consumerGroup, this.mQClientFactory.getClientId(), mqAll, cidAll);
AllocateMessageQueueStrategy为负载的策略
策略名称 | 描述 |
---|---|
AllocateMessageQueueAveragely | 连续分配(默认) |
AllocateMessageQueueAveragelyByCircle | 环形分配,轮流分配 |
AllocateMessageQueueByConfig | 通过配置分配 |
AllocateMessageQueueByMachineRoom | 指定一个broker的topic中的queue消费 |
AllocateMessageQueueConsistentHash | 一致性hash |
AllocateMachineRoomNearby | 机房分配 |
详细参考 https://blog.csdn.net/yewandemty/article/details/81989695
4.3.2 消费者重试和死信队列
先看一个业务流程中RocketMQ的使用场景。订单系统是消息的生产者,物流系统是消息的消费者。物流系统收到消费消息后需要登记数据库,生成物流记录。
如果物流系统处理消息的过程发生异常,比如数据库不可用,或者网络出现问题,这时候返回给Broker的是 RECONSUME_LATER,表示稍后重试。
这个时候消息会发回给Broker,进入到RocketMQ的重试队列中。服务端会为consumer group 创建一个名字为%RETRY%开头的重试队列。
重试队列的消息过一段时间会再次发送给消费者,如果还是异常,会再次进入重试队列。重试的时间间隔会不断衰减,从10秒开始直到2个小时:10s 30s 1m 2m 3m 4m5m 6m 7m 8m 9m 10m 20m 30m 1h2h,最多重试16次。
这个时间间隔似乎之前见过?没错,这个就是延迟消息的时间等级,从Level=3开始。也就是说重试队列是用延迟队列的功能实现的,发到对应的SCHEDULE_TOPIC_XXXX,到时间后再替换成真实的Topic,实现重试。
重试消费16次都没有成功怎么处理呢?这个时候消息就会丢到死信队列了。
Broker 会创建一个死信队列,死信队列的名字是%DLQ%+ConsumerGroupName。
死信队列的消息最后需要人工处理,可以写一个线程,订阅%DLQ%+ConsumerGroupName消费消息。
4.3.3 消息幂等
场景
A系统消费宕机没有回应,B系统重试,导致重复消费
A系统消费完网络延迟没有回应,A系统重试,这时网络恢复,导致重复消费
当消息队列 RocketMQ 的 Broker 或客户端重启、扩容或缩容时,会触发 Rebalance,此时消费者可能会收到重复消息。
解决方案
给message设置一个全局ID,建议用业务ID进行绑定,而不是messageID
具体流程如下
- 给message设置全局ID,再此ID存入redis缓存中
- 消费者消费时判断redis中的messageId是否存在,如果存在则未消费,如果不存在则已消费,不继续执行消费操作
- 消费完成,移除redis的messageId
5. 高可用架构
5.1 主从复制意义
- 数据备份:保证了两/多台机器上的数据冗余,特别是在主从同步复制的情况下,一定程度上保证了master 出现不可恢复的故障以后,数据不丢失。
- 高可用性:即使 master掉线,consumer 会自动重连到对应的slave机器,不会出现消费停滞的情况。
- 提高性能:主要表现为可分担master读的压力,当从master拉取消息,拉取消息的最大物理偏移与本地存储的最大物理偏移的差值超过一定值,会转向slave(默认brokerld=1)进行读取,减轻了master压力。
- 消费实时: master节点挂掉之后,依然可以从slave节点读取消息,而且会选择一个副本作为新的master,保证正常消费。
5.2 数据同步
5.2.1 主从关联
主从服务器怎么联系在一起?比如A机器上的broker-a-master和B机器上的broker-a-slave。
-
集群的名字相同,brokerClusterName=qingshan-cluster。
-
连接到相同的NameServer。
-
在配置文件中: brokerld=0代表是master,brokerld =1代表是s1
5.2.2 数据同步和刷盘
属性 | 值 | 描述 | 含义 |
---|---|---|---|
BrokerRole(broker角色) | ASYNC_MASTER | 主从异步复制 | master写成功,返回客户端成功。拥有较低的延迟和较高的吞吐量,但是当master出现故障后,有可能造成数据丢失。 |
SYNC_MASTER | 主从同步复制 | master和 slave均写成功,才返回客户端成功。maste挂了以后可以保证数据不丢失,但是同步复制会增加数据写入延迟,降低吞吐量。 | |
flushDiskType(刷盘方式) | ASYNC_FLUSH | 异步刷盘(默认) | 生产者发送的每一条消息并不是立即保存到磁盘,而是暂时缓存起来,然后就返回生产者成功。随后再异步的将缓存数据保存到磁盘,有两种情况:1是定期将缓存中更新的数据进行刷盘,2是当缓存中更新的数据条数达到某一设定值后进行刷盘。这种方式会存在消息丢失(在还未来得及同步到磁盘的时候宕机),但是性能很好。默认是这种模式。 |
SYNC_FLUSH | 同步刷盘 | 生产者发送的每一条消息都在保存到磁盘成功后才返回告诉生产者成功。这种方式不会存在消息丢失的问题,但是有很大的磁盘IO开销,性能有一定影响。 |
#Broker 的角色
#- ASYNC_MASTER 异步复制Master
#- SYNC_MASTER 同步双写Master
#- SLAVE
brokerRole=SYNC_MASTER
#刷盘方式
#- ASYNC_FLUSH 异步刷盘
#- SYNC_FLUSH 同步刷盘
flushDiskType=SYNC_FLUSH
官网图片如下
通常情况下,会把 Master和Slave的 Broker均配置成ASYNC_FLUSH 异步刷盘方式,主从之间配置成SYNC_MASTER同步复制方式,即:异步刷盘+同步复制。
5.3 HA与故障转移
在之前的版本中,RocketMQ只有master/slave一种部署方式,一组Broker 中有一个master,有零到多个slave,slave通过同步复制或异步复制方式去同步master的数据。master/slave部署模式,提供了一定的高可用性。
但这样的部署模式有一定缺陷。比如故障转移方面,如果主节点挂了还需要人为手动的进行重启或者切换,无法自动将一个从节点转换为主节点。
如果要实现自动故障转移,根本上要解决的问题是自动选主的问题。
比如Kafka用Zookeeper选 Controller,用类PacificA算法选leader、Redis哨兵用Raft 协议选Leader。
用ZK的这种方式需要依赖额外的组件,部署和运维的负担都会增加,而且ZK故障的时候会影响RocketMQ集群。
RocketMQ 2019年3月发布的4.5.0版本中,利用Dledger技术解决了自动选主的问题。DLedger就是一个基于raft协议的commitlog存储库,也是RocketMQ实现新的高可用多副本架构的关键。它的优点是不需要引入外部组件,自动选主逻辑集成到各个节点的进程中,节点之间通过通信就可以完成选主。
dledger文档: https://github.com/apache/rocketmq/blob/master/docs/cn/dledger/quick_start.md
怎么开启Dledger的功能?
enableDLegerCommitLog 是否启用Dledger,即是否启用RocketMQ主从切换,默认值为false。如果需要开启主从切换,则该值需要设置为true。
broker.conf配置如下
#是否启用 DLedger,即是否启用 RocketMQ 主从切换,默认值为 false。如果需要开启主从切换,则该值需要设置为 true 。
enableDLegerCommitLog=true
#节点所属的 raft 组,建议与 brokerName 保持一致,例如 broker-a。
dLegerGroup=broker-a
#集群节点信息,示例配置如下:n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913,多个节点用英文冒号隔开,单个条目遵循 legerSlefId-ip:端口,这里的端口用作 dledger 内部通信。
dLegerPeers=n0-127.0.0.1:40911;n1-127.0.0.1:40912;n2-127.0.0.1:40913
#当前节点id。取自 legerPeers 中条目的开头,即上述示例中的 n0,并且特别需要强调,只能第一个字符为英文,其他字符需要配置成数字。
dLegerSelfId=n1