RocketMQ原理浅析

(阿里双十一使用的MQ中间件)

docker安装:mac使用docker安装rocketmq - Foinlovz - 博客园

1、什么是MQ?

MQ: MQ,Message Queue, 是一种提供消息队列服务的中间件,是一套提供了消息生产、存储、消费全过程的API软件系统。
消息就是数据

2、MQ用途

a、限流削峰

MQ可以将系统中的超量请求暂存其中,以便后期可以慢慢进行处理,从而避免了请求的丢失或系统被压垮

b、异步解耦

上下游系统之间若为同步调用则会大大降低系统的吞吐量与并发度,且系统耦合度太高。实现上下游系统之间的异步解耦最常用的方式就是上下游之间增加MQ层。

c、数据收集

业务日志、监控数据、用户行为记录等

常见MQ产品对比

 

RocketMQ

 

发展历史:

2007年,阿里五彩石项目,Notify作为项目中交易核心消息流转系统。Notify是RocketMQ的雏形。

2010年,由于B2B的大规模应用,阿里急需一个具有海量消息堆积能力的消息系统。

2011年,Kafka开源,淘宝中间件团队对Kafka的源码进行研究后开发了新的MQ,MetaQ
2012年,MetaQ迭代到3.0版本,在它的基础上进行了进一步的抽象,使其有了通用性,形成了RocketMQ,然后进行了开源。

2015年,阿里在RocketMQ的基础上针对阿里云上的用户消息系统推出了一款AliwareMQ

2016年,双十一之后,阿里向Apache基金会捐赠了RocketMQ,成为Apache孵化项目。(当年双十一承载了万亿级的消息流转)

2017年RocketMQ从apache社区毕业成为Apache顶级开源项目,同年阿里巴巴联合国内外知名企业提出了OpenMessaging标准,该标准想做一个与平台无关的接口标准,以降低用户的学习成本,提升整个中间件的效率。

2022 年 RocketMQ 5.0 正式发布。从消息服务到云原生事件流处理平台

。。。

一、基本概念

1、消息(Message)

消息是指,消息系统中所传输信息的物理载体,生产和消费数据的最小单位,每条消息都属于一个主题(Topic)

2、主题(Topic)

只有kafka和rocketMQ有主题的概念,主题其实就是对消息的一种分类,不同的topic中可以有若干条消息,每条消息只能有一个主题(topic),主题是rocketMQ进行消息订阅的基本单位。

一个生产者可以同时发送多种Topic的消息;而对于一个消费者只针对某种特定的Topic感兴趣,只能订阅和消费一种Topic的消息。

生产者->Topic: 1:N

Topic->消费者:1:1

 

3、标签(Tag)

为消息设置的标签,用于同一主题下区分不同类型的消息。来自同一业务单元的消息,可以根据不同业务目的在同一主题下设置不同标签。标签能够有效保持代码的清晰度和连贯性,并优化RocketMQ提供的查询系统。消费者可以根据Tag实现对不同子主题的不同消费逻辑,实现更好的扩展性。Topic是消息的一级分类,Tag是消息的二级分类。

4、队列(Queue)

存储消息的物理实体,一个Topic中可以包含多个Queue,每一个Queue中存放的就是该Topic的消息。一个Topic的Queue也被称为Topic中消息分区(partition)。

一个Topic中的Queue的消息只能被一个消费者组中的一个消费者消费。一个Queue中的消息不允许被同一个消费者组中的不同消费者消费。

5、消息标识(MessageId/Key)

RocketMQ中每个消息拥有唯一的messageId,且可以携带具有业务标识的key,以方便对消息的查询。

messageId有两个:

生产者send()消息时会自动生成一个MessageId(MsgId)
当消息到达Broker后,Broker也会自动生成一个MessageId(offsetMsgId)

Key由用户指定的业务唯一标识。

二、系统架构

 

1、Producer

生产者,负责生产消息。producer通过MQ的负载均衡模块选择相应的Broker集群队列进行消息投递,投递过程快失败低延时。

RocketMQ中生产者是以生产者组的形式出现,生产者组是同一类生产者的集合,这类Producer发送相同的Topic类型的消息。

2、Consumer

消费者,负责消费消息。一个消息消费者或从Broker服务器中获取到消息,并对消息进行相关业务处理。

RocketMQ中的消息消费者都是以组的形势出现的,消费者组是同一类消费者的集合,这类Consumer消费的是同一个Topic类型的消息。

3、NameServer

功能:

NameServer是一个Broker与Topic路由的注册中心,支持Broker的动态注册与发现。

Broker管理

接受Broker集群的注册信息并且保存下来作为路由信息的基本数据;提供心跳检测机制,检查Broker是否存活。

路由信息管理

每个NameServer中都保存着Broker集群的整个路由信息和用于客户端查询的队列信息。Producer和Consumer通过NameServer可以获取整个Broker集群的路由信息,从而进行消息的投递和消费。

路由注册

NameServer通常以集群部署,但是NameServer是无状态的,即NameServer集群中的各个节点间是无差异的,各个节点之间不相互通信。当Broker节点启动时会轮询NameServer列表,与每个NameServer节点建立长连接发起注册请求。在NameServer内部维护一个Broker列表,用来动态存储Broker的信息。

优缺点:

优:搭建简单、扩容简单

劣:对于Broker必须指明全部NameServer地址,否则未指定的将不会去注册,所以NameServer不能随意扩容。

Broker节点会在注册后进行心跳保活,每30s发送一次心跳信息,心跳包中包含BrokerId、Broker地址、Broker名称以及Broker所属集群名称等,NameServer在收到心跳包之后会更新存活时间。

路由剔除

NameServer中有个定时任务10s执行一次,定时任务是去扫描Broker表,当Broker心跳时间戳距离当前时间超过120s时判定Broker失效,然后将Broker从列表中剔除。

路由发现

RocketMQ的路由发现采用的是PUll模型。当Topic路由信息发生变化时,NameServer不会主动推送给客户端,而是客户端定时拉取主题的最新路由。客户端每30s会拉取一次最新的路由。

客户端NameServer选择策略

客户端在配置时必须要写上NameServer集群的地址,客户端首先会生成一个随机数,然后再与NameServer节点数量取模,此时得到的就是所要链接的节点索引,然后进行连接,如果连接失败,则会采用round-robin策略,逐个尝试去连接其他节点。

4、Broker

消息中转角色,负责存储消息、转发消息。Broker在RocketMQ系统中负责接收并存储从生产者发送来的消息,同时为消费者的拉取请求作准备。Broker同时也存储这消息相关的元数据,包括消费者组进度偏移offSet、主题、队列等。

Remoting module:broker实体,处理来自client的请求

Client Manager:接收解析客户端的请求,管理客户端

Store Service:处理消息存储到物理硬盘和消息查询功能

HA Service:高可用服务,负责主备节点之间的数据同步

Index Service:索引服务。根据特定的Message Key,对投递到Broker的消息进行索引服务,同时提供根据MessageKey对消息进行快速查询功能。

RocketMQ工作流程

1)启动NameServer,NameServer启动后开启监听端口,等待Broker、Producer、Consumer连接。

2)启动Broker时,Broker会与所有的NameServer建立并保持长连接,然后每30s向NameServer定时发送心跳包。

3)收发消息前,可以先创建topic。(可选)也可以发送消息时自动创建Topic

4)Producer发送消息,启动时先与NameServer集群中的一台建立长连接并从中获取路由信息,即当前发送的Topic的Queue与Broker的地址映射关系。然后根据负载均衡算法选择其中一个Queue,与所在队列的Broker建立长连接向Broker发消息。当然,在获取路由信息之后,Broker会先将路由信息缓存到本地,再每30s更新一次路由信息。

5)Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取其所订阅Topic的路由信息,然后根据算法获取到消费的Queue,然后建立与Broker的长连接开始消费Queue中的消息。同样每30s更新一次路由信息。

三、工作原理

1、消息生产过程

Producer将消息的写入某个Broker的某个Queue中经历了以下过程:

  • Producer发送消息之前会先向NameServer发出获取消息Topic的路由请求
  • NameServer返回该Topic的路由表以及Broker列表
    • 路由表:实际上是一个Map,Key存储的是Topic名称,Value是一个QueueData实例列表,QueueData就是一个Broker中的该Topic对应的所有Queue组成一个QueueData。QueueData中包含brokerName。
    • Broker列表:实际上也是一个Map,Key为brokerName,value为BrokerData。一套brokerName名称相同的Master-Slave小集群对应一个BrokerData。BrokerData中包含brokerName及一个map。该map的key为brokerId,value为broker对应的地址。
  • Producer根据代码中指定的Queue选择策略,从Queue列表中选出一个队列,用于后续存储消息
  • Producer对消息进行一些特殊处理,例如,消息超过4M会进行压缩处理
  • Producer向选择的Queue所在的Broker发出RPC请求,将消息发送到选择出的Queue。

Queue选择算法

无序消息

轮询算法:默认选择算法(可能会造成消息在producer侧积压)

最小投递延迟算法:该算法每次统计消息投递的延迟时间,然后根据统计出的结果将消息投递到时间延迟最小的Queue,如果延迟相同则会采用轮询算法。(可能会导致大量消息投递到一个Queue中导致Consumer消费的不均衡)

2、消息的存储

RocketMQ中的消息存储在本地文件系统中

(源码分析rocketMQ消息存储过程:RocketMQ笔记(十五)Broker源码分析-存储机制 - 知乎)


commitlog:消息写入的文件

consumequeue:存放队列文件

index:存放消息索引文件的indexFile

checkpoint:存储commitlog、consumequeue、index文件的最后刷盘时间戳

lock:运行期间使用的全局资源锁

1)commitlog文件(mappedFile)

commitlog中存放着很多的mappedFile文件,当前Broker中的所有消息都是落盘到mappedFile文件中。mappedFile文件大小为1G(小于或等于1G),文件名由20位十进制数构成,表示当前文件的第一条消息的起始位移偏移量。

 

一个Broker中所有的mappedFile文件的commitlog offset是连续的。

需要注意,一个Broker中仅包含一个commitlog目录,所有的mappedFile文件都是存放在该目录下,无论Broker中存放的多少Topic信息都是被顺序写入mappedFile中,并没有根据Topic进行分类存储。由于顺序写入所以访问效率较高。

消息单元

 

  • MsgLen: 每个消息单元中包含消息总长度
  • physicalOffset: 消息的物理位置
  • Body: 消息体内容
  • BodyLength: 消息体长度
  • Topic: 消息主题Topic
  • TopicLength: Topic长度
  • BornHost: 消息生产者
  • BornTimestamp: 消息发送时间戳
  • QueueId: 消息所在的队列
  • QueueOffset: 消息在Queue中存储的偏移量

mappedFile文件内容由消息单元构成。

2)consumequeue文件

上面说到, 每个broker 的所有消息全都按照顺序写在同一个地方(commitlog目录的mappedFile文件中),并且用偏移量标记每个信息的位置,那么 consumequeue 下的文件,就是用于记录每个Topic下的queue对应每条消息的偏移量地址信息。

命名

每个Topic在~/store/consumequeue中创建文件夹,目录名为Topic名称。在该每个Topic目录下,会再为每个该Topic的Queue建立一个目录,目录名为queueId。如下图

 

每个目录中存放着若干consumequeue文件,consumequeue文件是commitlog的索引文件,可以根据consumequeue定位到具体的消息。

文件格式:

consumequeue文件名也由20位数字构成,表示当前文件的第一个索引条目的起始位移偏移量。与mappedFile文件名不同的是,其后续文件名是固定的。

因为每个consumequeue文件大小是固定不变的。其每个记录单元大小是固定的20字节,如下格式:

 

每个consumequeue文件可以包含30w个索引条目,每个索引条目包含了三个消息重要属性:

  • 消息在mappedFile文件中的偏移量CommitLog Offset
  • 消息长度、
  • 消息Tag的hashcode值。

这三个属性占20个字节,所以每个文件的大小是固定的30w * 20字节。每个索引条目的起始偏移量,就是该消息在Queue中的 Queue Offset

consumequeue就是存储消息在commitlog中存储的位置信息,commitlog文件中消息的索引。

消息写入:

commitlog与consumequeue文件之间的关系

 

上图 commitlog依次存放了producer发送的五条消息, 而对应的每条消息所属的Queue都记录在其对应的consumequeue文件中,并使用commitlog offset 指向 commitlog,

写入过程:

一条消息进入到Broker后经历了以下几个过程才最终被持久化。

  • Broker根据queueId,获取到该消息对应索引条目要在consumequeue目录中的写入偏移量,即QueueOffset
    • 这里的queue Offset并非commitOffset,这里只是找到写入哪个queue的哪个位置。
  • 将queueId、queueOffset等数据,与消息一起封装为消息单元
  • 将消息单元写入到commitlog
  • 同时,形成消息索引条目,将消息索引条目分发到相应的consumequeue

消息拉取:

当Consumer来拉取消息时会经历以下几个步骤:

  • Consumer获取到其要消费消息所在Queue的消费偏移量offset,计算出其要消费消息的 消息offset
    • 消费offset即消费进度,consumer对某个Queue的消费offset,即消费到了该Queue的第几条消息 ,已经消费的个数消息offset= 消费offset + 1
  • Consumer向Broker发送拉取请求,其中会包含其要拉取消息的Queue、消息offset及消息 Tag。
  • Broker计算在该consumequeue中的queueOffset。
    • queueOffset = 消息offset * 20字节 (每个消息单元20字节)
  • 从该queueOffset处开始向后查找第一个指定Tag的索引条目。
  • 解析该索引条目的前8个字节(前8个字节就是消息在commitlog中的offset),即可定位到该消息在commitlog中的commitlog offset
  • 从对应commitlog文件中根据commitlog offset读取消息单元,并发送给Consumer

性能提升:

Q:既然消息都是存储在磁盘文件中的,那么对性能没有影响吗?

A:首先,RocketMQ对文件的读写操作是通过mmap零拷贝进行的,将对文件的操作直接转化为对内存地址的操作,从而极大提高了文件的读写效率。

(mmap零拷贝参考:Java 两种zero-copy零拷贝技术mmap和sendfile的介绍 - 掘金

其次,consumequeue中的数据是顺序存放的,还引入了PageChache的预读取机制,使得consumequeue对文件的读取几乎接近对内存的读取,即使在有消息堆积的情况下也不会影响性能。

  • PageChache机制OS对文件的缓存机制,用于文件的读写操作。
    • 写操作:OS会先将数据写入到PageCache页缓存中,随后会由异步的方式由内核线程将Cache中的数据刷到物理磁盘。
    • 读操作:若用户要读取数据,其首先会从PageCache中读取,若没有命中,则OS从物理磁盘上将数据加载到内存中,在加载数据时也会将其相邻的数据块进行预读取。

对性能影响:

对commitlog文件的读取可能会影响性能。因为对commitlog文件来说,读取消息时会产生大量的随机访问,而随机访问会严重影响性能。(这里的随机访问是只非顺序访问【遍历】的方式,这里是通过consumequeue中的索引去访问commitlog)可以通过不同的调度算法解决此问题。

3)indexFile

RocketMQ提供了根据key进行消息查询的功能,该查询是通过store目录中的index子目录中的indexFile进行索引实现的快速查询。这个indexFile中的索引数据是在如果包含了key的消息被生产者发送到Broker时写入的。如果消息中没有包含key,则不会写入。

3、消息的消费

消费者从Broker中获取消息的方式有两种:pull拉取方式和push推动方式

消费者组对于消息消费模式又分为两种:集群(Clustering)消费和广播(Broadcasting)消费

1)推拉消费类型

拉取式消费:Consumer主动从Broker中拉取消息,主动权由Consumer控制。一旦获取了批量消息,就会启动消费过程。不过,改方式的实时性较弱,即Broker中有了新的消息时消费者不能及时发现消费。

推送式消费:该模式下Broker收到消息后会主动推送给Consumer。该消费模式的实时性较高。

该消费类型时典型的发布-订阅模式,即Consumer向其关联的Queue注册了监听器,一旦发现有新的消息到来就会触发回调的执行,回调方法是Consumer去Queue中拉取消息消费,这种消费模式是基于Consumer与Broker之间的长连接,长连接的维护是需要消耗系统资源的。

2)消费模式

广播消费

广播消费模式下,相同Consumer Group的每个Consumer实例都接收同一个Topic的全量消息。即每条消息都会被发送到Consumer Group中的每个Consumer。

集群消费

 

集群消费模式下,相同Consumer Group的每个Consumer实例平均分摊同一个Topic的消息。即每条消 息只会被发送到Consumer Group中的某个Consumer。

消费进度存储:

广播模式:消费进度保存在consumer端。因为广播模式下consumer group中每个consumer都会消费所有消息,但它们的消费进度是不同。所以consumer各自保存各自的消费进度。

集群模式:消费进度保存在broker中。consumer group中的所有consumer共同消费同一个Topic 中的消息,同一条消息只会被消费一次。消费进度会参与到了消费的负载均衡中,故消费进度是 需要共享的。下图是broker中存放的各个Topic的各个Queue的消费进度。

3)Rebalance机制

指的是在集群消费模式下,将⼀个Topic下的多个Queue在同⼀个Consumer Group中的多个 Consumer间进行重新分配的过程。

 

Rebalance机制的本意是为了提升消息的并行消费能力。

例如,⼀个Topic下5个队列,在只有1个消费 者的情况下,这个消费者将负责消费这5个队列的消息。

如果此时我们增加⼀个消费者,那么就可以给 其中⼀个消费者分配2个队列,给另⼀个分配3个队列,从而提升消息的并行消费能力。

Rebalance限制

由于⼀个队列最多分配给⼀个消费者,因此当某个消费者组下的消费者实例数量大于队列的数量时, 多余的消费者实例将分配不到任何队列。

Rebalance可能导致的问题

  • 消费暂停:在只有一个Consumer时,其负责消费所有队列;在新增了一个Consumer后会触发 Rebalance的发生。此时原Consumer就让出部分队列的消费,导致这些队列的消费暂停,等到这些队列分配给新的Consumer 后,这些暂停消费的队列才能继续被消费。
  • 消费重复:Consumer 在消费新分配给自己的队列时,必须接着上一个 Consumer 提交的消费进度的offset 继续消费。然而默认情况下,offset是异步提交的,这个异步性导致上一个Consumer提交到Broker的offset与实际消费的消息并不一致。导致新的Consumer可能会接着已经在老Consumer中消费的消息进行消费, 重复消费消息。
  • 消费突刺:由于Rebalance可能导致重复消费,如果需要重复消费的消息过多,或者因为Rebalance暂停 时间过长从而导致积压了部分消息。那么有可能会导致在Rebalance结束之后瞬间需要消费很多消息。

4)Queue分配算法:

平均分配策略:

 

环形平均策略:

 

一致性hash策略:

 

同机房策略:


该算法会根据queue的部署机房位置和consumer的位置,过滤出当前consumer相同机房的queue。

然后按照平均分配策略或环形平均策略对同机房queue进行分配。

如果没有同机房queue,则按照平均分配策略或环形平均策略对所有queue进行分配。

上述Queue分配算法中两种平均分配算法效率较高,一致性hash算法由于较复杂且hash策略分配后也可能出现分配不均的情况。

一致性hash算法的优点在于,如果有消费者频繁的扩容缩容,对整个集群的影响范围较小。

5)最少一次原则

RoecktMQ中有一个原则:每条消息必须要被成功消费一次

成功消费:Consumer在消费完消息后会向其消息记录器提交其消费消息的offset,offset被成功记录到记录器中,那么这条消息就被成功消费了。

4、订阅关系的一致性

多个消费者组订阅了多个Topic,并且每个消费者组里的多个消费者实例的订阅关系应该保持一致。

就是同一个消费者组里的Consumer实例订阅的Topic应该相同。

如果没有遵守以上原则在运行时并不会报错,并且在少量的消息消费中,可能也看不出什么问题,但是当消费量很大,broker集群比较复杂时,将有可能出现不可预料的问题, 同时官网也不建议我们这样做.

5、offset管理

消费进度offset是用来记录每个Queue的不同消费组的消费进度的。根据消费进度记录器的不同,可以分为两种模式:本地模式和远程模式

本地管理:

当消费模式为广播消费时,offset使用本地模式存储。

因为每条消息会被所有的消费者消费,每个消费者管理自己的消费进度,各个消费者之间不存在消费进度的交集。 Consumer在广播消费模式下offset相关数据以json的形式持久化到Consumer本地磁盘文件中,默认文件路径为当前用户主目录下的.rocketmq_offsets/${clientId}/${group}/Offsets.json 。 其中${clientId}为当前消费者id,默认为ip@DEFAULT;${group}为消费者组名称。

远程管理:

当消费模式为集群消费时,offset使用远程模式管理。因为所有Cosnumer实例对消息采用的是均衡消费,所有Consumer共享Queue的消费进度。

Consumer在集群消费模式下offset相关数据以json的形式持久化到Broker磁盘文件中,文件路径为当前 用户主目录下的store/config/consumerOffset.json 。

Broker启动时会加载这个文件,并写入到一个双层Map(ConsumerOffsetManager)。

外层map的key 为topic@group,value为内层map。内层map的key为queueId,value为offset。当发生Rebalance时, 新的Consumer会从该Map中获取到相应的数据来继续消费。 集群模式下offset采用远程管理模式,主要是为了保证Rebalance机制。

offset用途:

消费者在初连broker时是如何从最开始持续消费消息的?消费者要消费的第一条消息的起始位置是用户自己通过consumer.setConsumeFromWhere()方法指定的。

在Consumer启动后,其要消费的第一条消息的起始位置常用的有三种,这三种位置可以通过枚举类型 常量设置。这个枚举类型为ConsumeFromWhere。

CONSUME_FROM_LAST_OFFSET:从queue的当前最后一条消息开始消费

CONSUME_FROM_FIRST_OFFSET:从queue的第一条消息开始消费

CONSUME_FROM_TIMESTAMP:从指定的具体时间戳位置的消息开始消费。这个具体时间戳 是通过另外一个语句指定的 。 consumer.setConsumeTimestamp(“20221201080000”) yyyyMMddHHmmss

当消费完一批消息后,Consumer会提交其消费进度offset给Broker,Broker在收到消费进度后会将其更新到那个双层Map(ConsumerOffsetManager)及consumerOffset.json文件中,然后向该Consumer进行ACK,而ACK内容中包含三项数据:当前消费队列的最小offset(minOffset)、最大 offset(maxOffset)、及下次消费的起始offset(nextBeginOffset)。

重试队列:

当rocketMQ对消息的消费出现异常时,会将发生异常的消息的offset提交到Broker中的重试队列。系统在发生消息消费异常时会为当前的topic@group创建一个重试队列,该队列以%RETRY%开头,到达重试时间后进行消费重试。

offset的同步提交和异步提交

同步提交:消费者在消费完一批消息后会向broker提交这些消息的offset,然后等待broker的成功响应。若在等待超时之前收到了成功响应,则继续读取下一批消息进行消费(从ACK中获取 nextBeginOffset)。若没有收到响应,则会重新提交,直到获取到响应。而在这个等待过程中,消费者是阻塞的。其严重影响了消费者的吞吐量。

异步提交:消费者在消费完一批消息后向broker提交offset,但无需等待Broker的成功响应,可以继续读取并消费下一批消息。这种方式增加了消费者的吞吐量。但需要注意,broker在收到提交的offset 后,还是会向消费者进行响应的。Consumer此时可能还没有收到ACK,Consumer会从Broker中直接获取 nextBeginOffset。

6、消费幂等

重复消费场景分析:

  • 发送消息时重复
    • 一条消息一经发送到Broker上,但是由于网络闪断导致Broker对Producer应答失败,Producer会重复发送此条消息,那么消费时一定会产生重复消费。
  • 消费时消息重复
    • 消息已经被投递到Consumer并完成业务处理,当Consumer给Broker应答时出现网络闪断,Broker没有收到消费成功相应,为了保证消息至少被消费一次的原则,Broker重新投递已经被消费过的消息,也会导致重复消费。
  • Rebalance时消息重复
    • 当consumer group中的Consumer实例数量发生变化或订阅的Topic中的Queue数量发生变化时,会触发Rebalance,此时Cosumer可能会消费已经被消费过的消息。

通用解决重复消费的幂等方案
两要素:

幂等令牌:生产者与消费者之间的既定协议,通常为具有唯一业务标识的字符串

唯一性处理:服务端通过一定的算法策略,保证同一个业务逻辑不会被重复执行成功多次

解决方案:

  • 缓存去重:缓存中存入幂等令牌,消费前先缓存匹配令牌,若命中缓存则说明重复消费
  • 唯一性处理:查询数据库中幂等令牌是否存在,若存在说明重复性消费
  • 在同一事务中完成三项操作:唯一性处理后将幂等令牌写入缓存,并将幂等令牌作为唯一索引存入DB中。

7、消费堆积与消费延迟

消息处理流程中,如果Consumer的消费速度跟不上Producer的发送速度,MQ中未处理的消息会越来越多(进的多出的少),这部分消息就被称为堆积消息。消息出现堆积进而会造成消息的消费延迟

消息堆积的主要瓶颈在于客户端的消费能力,而消费能力由消费耗时消费并发度决定。注意,消费耗时的优先级要高于消费并发度。即在保证了消费耗时的合理性前提下,再考虑消费并发度问题。

消费耗时:

通常消息堆积是由于下游系统出现了服务异常或达到 了DBMS容量限制,导致消费耗时增加(主要为代码内部逻辑及调用问题)。

消费并发度:

一般情况下,消费者端的消费并发度由单节点线程数和节点数量共同决定,其值为单节点线程数*节点数量

对于普通消息、延时消息及事务消息,并发度计算都是单节点线程数*节点数量

但对于顺序 消息则是不同的。

顺序消息的消费并发度等于Topic的Queue分区数量。

1)全局顺序消息:该类型消息的Topic只有一个Queue分区(一台机器)。其可以保证该Topic的所有消息被顺序消费。

为了保证这个全局顺序性,Consumer Group中的任何一个机器在同一时刻只能有一个Consumer消费者进行消费。所以其并发度为1。

2)分区顺序消息:该类型消息的Topic有多个Queue分区(一个topic下的queue分布在集群中的多个broker中)。

其仅可以保证该Topic的某台broker的消息被顺序消费,不能保证整个Topic中消息的顺序消费。

为了保证这个分区顺序性, 每个Queue分区中的消息在Consumer Group中的同一时刻只能有一个Consumer的一个线程进行 消费。即,在同一时刻最多会出现多个Queue分蘖有多个Consumer的多个线程并行消费。所以其并发度为Topic的分区数量(该topic的broker分区数量)。

线程数计算:

对于一台主机中线程池中线程数的设置需要谨慎,不能盲目直接调大线程数,设置过大的线程数反而会带来大量的线程切换的开销。理想环境下单节点的最优线程数计算模型为:C *(T1 + T2)/ T1。

  • C:CPU内核数
  • T1:CPU内部逻辑计算耗时
  • T2:外部IO操作耗时

最优线程数 = C *(T1 + T2/ T1 = C * T1/T1 + C * T2/T1 = C + C * T2/T1

注意,该计算出的数值是理想状态下的理论数据,在生产环境中,不建议直接使用。而是根据当前环境,先设置一个比该值小的数值然后观察其压测效果,然后再根据效果逐步调大线程数,直至找到在该环境中性能最佳时的值。

如何避免消息堆积和消费延迟:

为了避免在业务使用时出现非预期的消息堆积和消费延迟问题,需要在前期设计阶段对整个业务逻辑进行完善的排查和梳理。其中最重要的就是梳理消息的消费耗时和设置消息消费的并发度。

8、消息清理

消息被消费过后会被清理掉吗?不会的。

消息是被顺序存储在commitlog文件的,且消息大小不定长,所以消息的清理是不可能以消息为单位进 行清理的,而是以commitlog文件为单位进行清理的。否则会急剧下降清理效率,并且实现起来逻辑复杂。

commitlog文件存在一个过期时间,默认为72小时,即三天。除了用户手动清理外,在以下情况下也会被自动清理,无论文件中的消息是否被消费过:

  • 文件过期,且到达清理时间点(默认为凌晨4点)后,自动清理过期文件
  • 文件过期,且磁盘空间占用率已达过期清理警戒线(默认75%)后,无论是否达到清理时间点, 都会自动清理过期文件
  • 磁盘占用率达到清理警戒线(默认85%)后,开始按照设定好的规则清理文件,无论是否过期。 默认会从最老的文件开始清理
  • 磁盘占用率达到系统危险警戒线(默认90%)后,Broker将拒绝消息写入

四、应用

1、消息分类

1)普通消息

发送方式分类:

  • 同步发送:Producer发出⼀条消息后,会在收到MQ返回的ACK之后才发下⼀条消息。该方式 的消息可靠性最高,但消息发送效率太低。

 

  • 异步发送:Producer发出消息后无需等待MQ返回ACK,直接发送下⼀条消息。该方式的消息 可靠性可以得到保障,消息发送效率也可以。

 

  • 单向发送消息:Producer仅负责发送消息,不等待、不处理MQ的ACK。该发送方式时MQ也不返 回ACK。该方式的消息发送效率最高,但消息可靠性较差。

 

参考代码:

// 消息发送的状态
public enum SendStatus {
	SEND_OK, // 发送成功
    // 刷盘超时。当Broker设置的刷盘策略为同步刷盘时才可能出现这种异常状态。异步刷盘不会出现
	FLUSH_DISK_TIMEOUT, 
	// Slave同步超时。当Broker集群设置的Master-Slave的复制方式为同步复制时才可能出现这种异常状态。异步复制不会出现		
	FLUSH_SLAVE_TIMEOUT, 
	// 没有可用的Slave。当Broker集群设置为Master-Slave的复制方式为同步复制时才可能出现这种异常状态。异步复制不会出现
	SLAVE_NOT_AVAILABLE, 

}
public class SyncProducer {
    
public void syncProducerTest() throws Exception {
	// 创建一个producer,参数为Producer Group名称
	DefaultMQProducer producer = new DefaultMQProducer("pg");
	// 指定nameServer地址
	producer.setNamesrvAddr("localhost:9876");
    // 设置当发送失败时重试发送的次数,默认为2次
    producer.setRetryTimesWhenSendFailed(3);
    // 设置发送超时时限为5s,默认3s
    producer.setSendMsgTimeout(5000);
    // 开启生产者
    producer.start();
    // 生产并发送100条消息
    for (int i = 0; i < 100; i++) {
         byte[] body = ("Hi," + i).getBytes();
         Message msg = new Message("someTopic", "someTag", body);
         // 为消息指定key
         msg.setKeys("key-" + i);
         // 发送消息
         SendResult sendResult = producer.send(msg);
         System.out.println(sendResult);
	}
	// 关闭producer
	producer.shutdown();
	}
}
public class AsyncProducer {
public void asyncProducerTest() throws Exception {
    DefaultMQProducer producer = new DefaultMQProducer("pg");
    producer.setNamesrvAddr("localhost:9876");
    // 指定异步发送失败后不进行重试发送
    producer.setRetryTimesWhenSendAsyncFailed(0);
    // 指定新创建的Topic的Queue数量为2,默认为4
    producer.setDefaultTopicQueueNums(2);
    producer.start();
    for (int i = 0; i < 100; i++) {
    byte[] body = ("Hi," + i).getBytes();
    try {
        Message msg = new Message("myTopicA", "myTag", body);
        // 异步发送。指定回调
        producer.send(msg, new SendCallback() {
            // 当producer接收到MQ发送来的ACK后就会触发该回调方法的执行
            @Override
            public void onSuccess(SendResult sendResult) {
          	  System.out.println(sendResult);
            }
            @Override
            public void onException(Throwable e) {
            	e.printStackTrace();
       }
});
       } catch (Exception e) {
   		 e.printStackTrace();
    }
    } 
    // end-for
    // sleep一会儿
    // 由于采用的是异步发送,所以若这里不sleep,
    // 则消息还未发送就会将producer给关闭,报错
    TimeUnit.SECONDS.sleep(3);
    producer.shutdown();
}
}
public class OnewayProducer {
    public void OnewayProducerTest() throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("pg");
        producer.setNamesrvAddr("localhost:9876");
        producer.start();
        for (int i = 0; i < 10; i++) {
            byte[] body = ("Hi," + i).getBytes();
            Message msg = new Message("single", "someTag", body);
			// 单向发送
            producer.sendOneway(msg);
        }
        producer.shutdown();
        System.out.println("producer shutdown");
    }
}

2)顺序消息

顺序消息指的是,严格按照消息的发送顺序进行消费的消息(FIFO)。

默认情况下生产者会把消息以Round Robin轮询方式发送到不同的Queue分区队列;而消费消息时会从多个Queue上拉取消息,这种情况下的发送和消费是不能保证顺序的。

如果将消息仅发送到同一个Queue中,消费时也只从这个Queue上拉取消息,就严格保证了消息的顺序性。

顺序消息的必要性:

例如,现在有TOPIC ORDER_STATUS (订单状态),其下有4个Queue队列,该Topic中的不同消息用于 描述当前订单的不同状态。

假设订单有状态:未支付、已支付、发货中、发货成功、发货失败。

根据以上订单状态,生产者从时序上可以生成如下几个消息:

订单T0000001:未支付 --> 订单T0000001:已支付 --> 订单T0000001:发货中 --> 订单 T0000001:发货失败

消息发送到MQ中之后,Queue的选择如果采用轮询策略,消息在MQ的存储可能如下:

这种情况下,我们希望Consumer消费消息的顺序和我们发送是一致的,然而上述MQ的投递和消费方 式,我们无法保证顺序是正确的。对于顺序异常的消息,Consumer即使设置有一定的状态容错,也不能完全处理好这么多种随机出现组合情况。

基于上述的情况,可以设计如下方案:对于相同订单号的消息,通过一定的策略,将其放置在一个 Queue中,然后消费者再采用一定的策略(例如,一个线程独立处理一个queue,保证处理消息的顺序 性),能够保证消费的顺序性。

 

顺序消息又可以分为全局有序消息(Topic单个queue)和分区有序消息(Topic多个queue,在同一个queue上有序)。

参考代码:

public class OrderedProducer {
    public void OrderedProducerTest() throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("pg");
        producer.setNamesrvAddr("localhost:9876");
        producer.start();
        for (int i = 0; i < 100; i++) {
            Integer orderId = i;
            byte[] body = ("Hi," + i).getBytes();
            Message msg = new Message("TopicA", "TagA", body);
            SendResult sendResult = producer.send(msg, new
                    MessageQueueSelector() {
                        @Override
                        public MessageQueue select(List<MessageQueue> mqs,
                                                   Message msg, Object arg) {
                            Integer id = (Integer) arg;
                            int index = id % mqs.size();
                            return mqs.get(index);
                        }
                    }, orderId);
            System.out.println(sendResult);
        }
        producer.shutdown();
    }
}
/**
* 顺序消息消费,带事务方式(应用可控制Offset什么时候提交)
*/
public class ConsumerInOrder {

   public void ConsumerInOrderTest() throws Exception {
       DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("please_rename_unique_group_name_3");
       consumer.setNamesrvAddr("localhost:9876");
       /**
        * 设置Consumer第一次启动是从队列头部开始消费还是队列尾部开始消费<br>
        * 如果非第一次启动,那么按照上次消费的位置继续消费
        */
       consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET);

       consumer.subscribe("TopicTest", "TagA");
       //MQ消费模式,默认为集群消费
       //consumer.setMessageModel(MessageModel.CLUSTERING);
    	// consumer.setMessageModel(MessageModel.BROADCASTING);
       
       // 顺序消费监听器
       consumer.registerMessageListener(new MessageListenerOrderly() {

           Random random = new Random();

           @Override
           public ConsumeOrderlyStatus consumeMessage(List<MessageExt> msgs, ConsumeOrderlyContext context) {
               context.setAutoCommit(true);
               for (MessageExt msg : msgs) {
                   // 可以看到每个queue有唯一的consume线程来消费, 订单对每个queue(分区)有序
                   System.out.println("consumeThread=" + Thread.currentThread().getName() + "queueId=" + msg.getQueueId() + ", content:" + new String(msg.getBody()));
               }

               try {
                   //模拟业务逻辑处理中...
                   TimeUnit.SECONDS.sleep(random.nextInt(10));
               } catch (Exception e) {
                   e.printStackTrace();
               }
               return ConsumeOrderlyStatus.SUCCESS;
           }
       });

       consumer.start();

       System.out.println("Consumer Started.");
   }
}

3)延时消息

当消息写入到Broker后,在指定的时长后才可被消费处理的消息,称为延时消息。

典型应用场景:

超时支付关闭订单的场景。

延时等级:

延时消息的延迟时长不支持随意时长的延迟,是通过特定的延迟等级来指定的。延时等级定义在 RocketMQ服务端的MessageStoreConfig类中的如下变量中:

即,若指定的延时等级为3,则表示延迟时长为10s,即延迟等级是从1开始计数的。

当然,如果需要自定义的延时等级,可以通过在broker加载的配置中新增配置(例如下面增加了1 天这个等级1d)。

配置文件在RocketMQ安装目录下的conf目录中。设置后,将会替换默认的等级

messageDelayLevel = 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h 1d

参考代码:

public class DelayProducer {
    public void delayProducerTest() throws Exception {
        DefaultMQProducer producer = new DefaultMQProducer("pg");
        producer.setNamesrvAddr("localhost:9876");
        producer.start();
        for (int i = 0; i < 10; i++) {
            byte[] body = ("Hi," + i).getBytes();
            Message msg = new Message("TopicB", "someTag", body);
            // 指定消息延迟等级为3级,即延迟10s
            // msg.setDelayTimeLevel(3);
            SendResult sendResult = producer.send(msg);
            // 输出消息被发送的时间
            System.out.print(new SimpleDateFormat("mm:ss").format(new
                                                                  Date()));
            System.out.println(" ," + sendResult);
        }
        producer.shutdown();
    }
}
public class DelayConsumer {
    public void delayConsumerTest() throws MQClientException {
        DefaultMQPushConsumer consumer = new
                DefaultMQPushConsumer("cg");
        consumer.setNamesrvAddr("localhost:9876");
        consumer.setConsumeFromWhere(ConsumeFromWhere.CONSUME_FROM_FIRST_OFFSET
        );
        consumer.subscribe("TopicB", "*");
        consumer.registerMessageListener(new MessageListenerConcurrently() {
            @Override
            public ConsumeConcurrentlyStatus
            consumeMessage(List<MessageExt> msgs,
                           ConsumeConcurrentlyContext context) {
                for (MessageExt msg : msgs) {
                    // 输出消息被消费的时间
                    System.out.print(new
                            SimpleDateFormat("mm:ss").format(new Date()));
                    System.out.println(" ," + msg);
                }
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        consumer.start();
        System.out.println("Consumer Started");
    }
}

4)事务消息

参考分布式事务解决方案

分布式事务

对于分布式事务,通俗地说就是,一次操作由若干分支操作组成,这些分支操作分属不同应用,分布在不同服务器上。分布式事务需要保证这些分支操作要么全部成功,要么全部失败。分布式事务与普通事务一样,就是为了保证操作结果的一致性。

事务消息

RocketMQ提供了类似X/Open XA的分布式事务功能,通过事务消息能达到分布式事务的最终一致。XA 是一种分布式事务解决方案,一种分布式事务处理模式。

半事务消息

暂不能投递的消息,发送方已经成功地将消息发送到了Broker,但是Broker未收到最终确认指令,此时 该消息被标记成“暂不能投递”状态,即不能被消费者看到。处于该种状态下的消息即半事务消息。

本地事务状态

Producer回调操作执行的结果为本地事务状态,其会发送给TC,而TC会再发送给TM。TM会根据TC发 送来的本地事务状态来决定全局事务确认指令。

// 描述本地事务执行状态
public enum LocalTransactionState {
	COMMIT_MESSAGE, // 本地事务执行成功
	ROLLBACK_MESSAGE, // 本地事务执行失败
	UNKNOW, // 不确定,表示需要进行回查以确定本地事务的执行结果
}

消息回查

消息回查,即重新查询本地事务的执行状态。

注意,消息回查不是重新执行回调操作。如在银行付款操作中,回调操作是进行预扣款操作,而消息回查则是查看预扣款操作执行的结果。

引发消息回查的原因最常见的有两个:

  1. 回调操作返回UNKNWON
  2. TC没有接收到TM的最终全局事务确认指令

RocketMQ中的消息回查设置

关于消息回查,有三个常见的属性设置。它们都在broker加载的配置文件中设置,例如:

  • transactionTimeout=20,指定TM在20秒内应将最终确认状态发送给TC,否则引发消息回查。默 认为60秒
  • transactionCheckMax=5,指定最多回查5次,超过后将丢弃消息并记录错误日志。默认15次。
  • transactionCheckInterval=10,指定设置的多次消息回查的时间间隔为10秒。默认为60秒。

消息重试机制

  • 生产者在发送消息时,若采用同步或异步发送方式,发送失败会重试,但oneway消息发送方式 发送失败没有重试机制。
  • 只有普通消息有发送重试机制,顺序消息没有重试机制。

(1)同步消息发送失败策略:

消息发送默认采用round-robin策略来选择所发送到的队列。如果发送失败,默认重试2 次。但在重试时是不会选择上次发送失败的Broker,而是选择其它Broker。同时,Broker还具有失败隔离功能,使Producer尽量选择未发生过发送失败的Broker作为目标 Broker。其可以保证其它消息尽量不发送到问题Broker,可以降低发送失败的概率,提升消息发送效率,降低消息发送耗时。如果超过重试次数,则抛出异常,由Producer去保证消息不丢。

(2)异步消息发送失败策略:

异步发送失败重试时,异步重试不会选择其他broker,仅在同一个broker上做重试,所以该策略无法保证消息不丢。

(3)消息刷盘失败策略:

消息刷盘超时(Master或Slave)或slave不可用(slave在做数据同步时向master返回状态不是 SEND_OK)时,默认是不会将消息尝试发送到其他Broker的。不过,对于重要消息可以通过在Broker 的配置文件设置retryAnotherBrokerWhenNotStoreOK属性为true来开启。

消费重试机制

(1)顺序消息消费重试

对于顺序消息,需要严格保证拉取消费的顺序性,所以不会错过任何一个消息的消费,所以当Consumer消费消息失败后,为了保证消息的顺序性,其会自动不断地进行消息重 试,直到消费成功。消费重试默认间隔时间为1000毫秒。重试期间应用会出现消息消费被阻塞的情况。

由于对顺序消息的重试是无休止的,不间断的,直至消费成功,所以,对于顺序消息的消费, 务必要保证应用能够及时监控并处理消费失败的情况,避免消费被永久性阻塞。

顺序消息没有发送失败重试机制(避免被重复消费),但是有消费失败重试机制(保证消费消息的顺序性)

(2)无序消息的重试机制

对于无序消息(普通消息、延时消息、事务消息),当Consumer消费消息失败时,可以通过设置返回状态达到消息重试的效果。不过需要注意,无序消息的重试只对集群消费方式生效,广播消费方式不提供失败重试特性。即对于广播消费,消费失败后,失败消息不再重试,继续消费后续消息。

对于无序消息集群消费下的重试消费,每条消息默认最多重试16次,但每次重试的间隔时间是不同的,会逐渐变长。(和延时等级时间保持一致)

重试次数可以自定义当重试次数设置大于16次时,每次消费的间隔都为2h。

配置:

集群消费方式下,消息消费失败后若希望消费重试,则需要在消息监听器接口的实现中明确进行如下三 种方式之一的配置:

  • 方式1:返回ConsumeConcurrentlyStatus.RECONSUME_LATER(推荐)
  • 方式2:返回Null
  • 方式3:抛出异常

死信队列

当一条消息初次消费失败,消息队列会自动进行消费重试;达到最大重试次数后,若消费依然失败,则 表明消费者在正常情况下无法正确地消费该消息,此时,消息队列不会立刻将消息丢弃,而是将其发送 到该消费者对应的特殊队列中。这个队列就是死信队列(Dead-Letter Queue,DLQ),而其中的消息 则称为死信消息(Dead-Letter Message,DLM)。

死信队列中的消息是无法被正常消费的,因为死信队列对消费者不可见。死信队列的有效期和正常消息有效期一致都是3天,当出现死信队列时就说明系统有异常出现必须手动介入排查后再将死信队列中的消息重新投递。

本文为学习笔记,供借鉴。

(一个工作一段时间,在代码里跌跌撞撞的小学生,针对上文欢迎提出问题,笔者虚心受教)

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值