万字长文讲透 RocketMQ 的消费逻辑 (上篇)

卡瓦格博之巅
发布于 2023-6-25 12:00
浏览
0收藏

RocketMQ 是笔者非常喜欢的消息队列,4.9.X 版本是目前使用最广泛的版本,但它的消费逻辑相对较重,很多同学学习起来没有头绪。

这篇文章,笔者梳理了 RocketMQ 的消费逻辑,希望对大家有所启发。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

1 架构概览

在展开集群消费逻辑细节前,我们先对 RocketMQ 4.9.X 架构做一个概览。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

整体架构中包含四种角色 :

1、NameServer

名字服务是是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。它是一个非常简单的 Topic 路由注册中心,其角色类似 Dubbo 中的 zookeeper ,支持 Broker 的动态注册与发现。

2、BrokerServer

Broker 主要负责消息的存储、投递和查询以及服务高可用保证 。

3、Producer

消息发布的角色,Producer 通过 MQ 的负载均衡模块选择相应的 Broker 集群队列进行消息投递,投递的过程支持快速失败并且低延迟。

4、Consumer

消息消费的角色,支持以 push 推,pull 拉两种模式对消息进行消费。

RocketMQ 集群工作流程:

1、启动 NameServer,NameServer 起来后监听端口,等待 Broker、Producer 、Consumer 连上来,相当于一个路由控制中心。

2、Broker 启动,跟所有的 NameServer 保持长连接,定时发送心跳包。心跳包中包含当前 Broker信息( IP+端口等 )以及存储所有 Topic 信息。注册成功后,NameServer 集群中就有 Topic 跟 Broker 的映射关系。

3、收发消息前,先创建 Topic,创建 Topic 时需要指定该 Topic 要存储在哪些 Broker 上,也可以在发送消息时自动创建 Topic。

4、Producer 发送消息,启动时先跟 NameServer 集群中的其中一台建立长连接,并从 NameServer 中获取当前发送的 Topic 存在哪些 Broker 上,轮询从队列列表中选择一个队列,然后与队列所在的 Broker 建立长连接从而向 Broker 发消息。

5、Consumer 跟 Producer 类似,跟其中一台 NameServer 建立长连接,获取当前订阅 Topic 存在哪些 Broker 上,然后直接跟 Broker 建立连接通道,开始消费消息

2 发布订阅

RocketMQ 的传输模型是:发布订阅模型 。

发布订阅模型具有如下特点:

  • 消费独立相比队列模型的匿名消费方式,发布订阅模型中消费方都会具备的身份,一般叫做订阅组(订阅关系),不同订阅组之间相互独立不会相互影响。
  • 一对多通信基于独立身份的设计,同一个主题内的消息可以被多个订阅组处理,每个订阅组都可以拿到全量消息。因此发布订阅模型可以实现一对多通信。

RocketMQ 支持两种消息模式:集群消费( Clustering )和广播消费( Broadcasting )。

集群消费同一 Topic 下的一条消息只会被同一消费组中的一个消费者消费。也就是说,消息被负载均衡到了同一个消费组的多个消费者实例上。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

广播消费:当使用广播消费模式时,每条消息推送给集群内所有的消费者,保证消息至少被每个消费者消费一次。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

为了实现这种发布订阅模型 , RocketMQ 精心设计了它的存储模型。先进入 Broker 的文件存储目录。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

RocketMQ 采用的是混合型的存储结构。

1、Broker 单个实例下所有的队列共用一个数据文件(commitlog)来存储

生产者发送消息至 Broker 端,然后 Broker 端使用同步或者异步的方式对消息刷盘持久化,保存至 commitlog 文件中。只要消息被刷盘持久化至磁盘文件 commitlog 中,那么生产者发送的消息就不会丢失。

单个文件大小默认 1G , 文件名长度为 20 位,左边补零,剩余为起始偏移量,比如 00000000000000000000 代表了第一个文件,起始偏移量为 0 ,文件大小为1 G = 1073741824 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

commitlog 目录

这种设计有两个优点:

  • 充分利用顺序写,大大提升写入数据的吞吐量;
  • 快读定位消息。因为消息是一条一条写入到 commitlog 文件 ,写入完成后,我们可以得到这条消息的物理偏移量。每条消息的物理偏移量是唯一的, commitlog 文件名是递增的,可以根据消息的物理偏移量通过二分查找,定位消息位于那个文件中,并获取到消息实体数据。

2、Broker 端的后台服务线程会不停地分发请求并异步构建 consumequeue(消费文件)和 indexfile(索引文件)

进入索引文件存储目录 :

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

1、消费文件按照主题存储,每个主题下有不同的队列,图中主题 my-mac-topic 有 16 个队列 (0 到 15) ;

2、每个队列目录下 ,存储 consumequeue 文件,每个 consumequeue 文件也是顺序写入,数据格式见下图。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

每个 consumequeue 文件包含 30 万个条目,每个条目大小是 20 个字节,每个文件的大小是 30 万 * 20 = 60万字节,每个文件大小约 5.72M 。

和 commitlog 文件类似,consumequeue 文件的名称也是以偏移量来命名的,可以通过消息的逻辑偏移量定位消息位于哪一个文件里。

消费文件按照主题-队列来保存 ,这种方式特别适配发布订阅模型

消费者从 Broker 获取订阅消息数据时,不用遍历整个 commitlog 文件,只需要根据逻辑偏移量从 consumequeue 文件查询消息偏移量 ,  最后通过定位到 commitlog 文件, 获取真正的消息数据。

要实现发布订阅模型,还需要一个重要文件:消费进度文件。原因有两点:

  • 不同消费组之间相互独立,不会相互影响 ;
  • 消费者下次拉取数据时,需要知道从哪个进度开始拉取 ,就像我们小时候玩单机游戏存盘一样。

因此消费进度文件需要保存消费组所订阅主题的消费进度。

我们浏览下集群消费场景下的 Broker 端的消费进度文件 consumerOffset.json 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

每个 consumequeue 文件包含 30 万个条目,每个条目大小是 20 个字节,每个文件的大小是 30 万 * 20 = 60万字节,每个文件大小约 5.72M 。

和 commitlog 文件类似,consumequeue 文件的名称也是以偏移量来命名的,可以通过消息的逻辑偏移量定位消息位于哪一个文件里。

消费文件按照主题-队列来保存 ,这种方式特别适配发布订阅模型

消费者从 Broker 获取订阅消息数据时,不用遍历整个 commitlog 文件,只需要根据逻辑偏移量从 consumequeue 文件查询消息偏移量 ,  最后通过定位到 commitlog 文件, 获取真正的消息数据。

要实现发布订阅模型,还需要一个重要文件:消费进度文件。原因有两点:

  • 不同消费组之间相互独立,不会相互影响 ;
  • 消费者下次拉取数据时,需要知道从哪个进度开始拉取 ,就像我们小时候玩单机游戏存盘一样。

因此消费进度文件需要保存消费组所订阅主题的消费进度。

我们浏览下集群消费场景下的 Broker 端的消费进度文件 consumerOffset.json 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

在进度文件 consumerOffset.json 里,数据以 key-value 的结构存储,key 表示:主题@消费者组 , value 是 consumequeue 中每个队列对应的逻辑偏移量 。

写到这里,我们粗糙模拟下 RocketMQ 存储模型如何满足发布订阅模型(集群模式) 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

1、发送消息:生产者发送消息到 Broker ;

2、保存消息:Broker 将消息存储到 commitlog 文件 ,异步线程会构建消费文件 consumequeue ;

3、消费流程:消费者启动后,会通过负载均衡分配对应的队列,然后向 Broker 发送拉取消息请求。Broker 收到消费者拉取请求之后,根据订阅组,消费者编号,主题,队列名,逻辑偏移量等参数 ,从该主题下的 consumequeue 文件查询消息消费条目,然后从 commitlog 文件中获取消息实体。消费者在收到消息数据之后,执行消费监听器,消费完消息;

4、保存进度:消费者将消费进度提交到 Broker ,Broker 会将该消费组的消费进度存储在进度文件里。

3 消费流程

我们重点讲解下集群消费的消费流程 ,因为集群消费是使用最普遍的消费模式,理解了集群消费,广播消费也就能顺理成章的掌握了。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

集群消费示例代码里,启动消费者,我们需要配置三个核心属性:消费组名订阅主题消息监听器,最后调用 start 方法启动。

消费者启动后,我们可以将整个流程简化成:

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

4 负载均衡

消费端的负载均衡是指将 Broker 端中多个队列按照某种算法分配给同一个消费组中的不同消费者,负载均衡是客户端开始消费的起点

RocketMQ 负载均衡的核心设计理念

  • 消费队列在同一时间只允许被同一消费组内的一个消费者消费
  • 一个消费者能同时消费多个消息队列

负载均衡是每个客户端独立进行计算,那么何时触发呢 ?

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

  • 消费端启动时,立即进行负载均衡;
  • 消费端定时任务每隔 20 秒触发负载均衡;
  • 消费者上下线,Broker 端通知消费者触发负载均衡。

负载均衡流程如下:

1、发送心跳

消费者启动后,它就会通过定时任务不断地向 RocketMQ 集群中的所有 Broker 实例发送心跳包(消息消费分组名称订阅关系集合消息通信模式客户端实例编号等信息)。

Broker 端在收到消费者的心跳消息后,会将它维护在 ConsumerManager 的本地缓存变量 consumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量 channelInfoTable 中,为之后做消费端的负载均衡提供可以依据的元数据信息。

2、启动负载均衡服务

负载均衡服务会根据消费模式为”广播模式”还是“集群模式”做不同的逻辑处理,这里主要来看下集群模式下的主要处理流程:

(1) 获取该主题下的消息消费队列集合;

(2) 查询 Broker 端获取该消费组下消费者 Id 列表;

(3) 先对 Topic 下的消息消费队列、消费者 Id 排序,然后用消息队列分配策略算法(默认为:消息队列的平均分配算法),计算出待拉取的消息队列;

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

平均分配算法

这里的平均分配算法,类似于分页的算法,将所有 MessageQueue 排好序类似于记录,将所有消费端排好序类似页数,并求出每一页需要包含的平均 size 和每个页面记录的范围 range ,最后遍历整个 range 而计算出当前消费端应该分配到的记录。

(4) 分配到的消息队列集合与 processQueueTable 做一个过滤比对操作。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

消费者实例内 ,processQueueTable 对象存储着当前负载均衡的队列 ,以及该队列的处理队列 processQueue (消费快照)。

  1. 标红的 Entry 部分表示与分配到的消息队列集合互不包含,则需要将这些红色队列 Dropped 属性为 true , 然后从 processQueueTable 对象中移除。
  2. 绿色的 Entry 部分表示与分配到的消息队列集合的交集,processQueueTable 对象中已经存在该队列。
  3. 黄色的 Entry 部分表示这些队列需要添加到 processQueueTable 对象中,为每个分配的新队列创建一个消息拉取请求​​pullRequest​​​  ,  在消息拉取请求中保存一个处理队列 ​​processQueue​​​ (队列消费快照),内部是红黑树(​​TreeMap​​),用来保存拉取到的消息。

最后创建拉取消息请求列表,并将请求分发到消息拉取服务,进入拉取消息环节

5 长轮询

在负载均衡这一小节,我们已经知道负载均衡触发了拉取消息的流程

消费者启动的时候,会创建一个拉取消息服务 PullMessageService ,它是一个单线程的服务。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

核心流程如下:

1、负载均衡服务将消息拉取请求放入到拉取请求队列 pullRequestQueue , 拉取消息服务从队列中获取拉取消息请求 ;

2、拉取消息服务向 Brorker 服务发送拉取请求 ,拉取请求的通讯模式是异步回调模式 ;

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

消费者的拉取消息服务本身就是一个单线程,使用异步回调模式,发送拉取消息请求到 Broker 后,拉取消息线程并不会阻塞 ,可以继续处理队列 pullRequestQueue 中的其他拉取任务。

3、Broker 收到消费者拉取消息请求后,从存储中查询出消息数据,然后返回给消费者;

4、消费者的网络通讯层会执行拉取回调函数相关逻辑,首先会将消息数据存储在队列消费快照 processQueue 里;

消费快照使用红黑树 msgTreeMap 存储拉取服务拉取到的消息 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

5、回调函数将消费请求提交到消息消费服务 ,而消息消费服务会异步的消费这些消息;

6、回调函数会将处理中队列的拉取请放入到定时任务中;

7、定时任务再次将消息拉取请求放入到队列 pullRequestQueue 中,形成了闭环:负载均衡后的队列总会有任务执行拉取消息请求,不会中断。

细心的同学肯定有疑问:既然消费端是拉取消息,为什么是长轮询呢 ?

虽然拉模式的主动权在消费者这一侧,但是缺点很明显。

因为消费者并不知晓 Broker 端什么时候有新的消息 ,所以会不停地去 Broker 端拉取消息,但拉取频率过高, Broker 端压力就会很大,频率过低则会导致消息延迟。

所以要想消费消息的延迟低,服务端的推送必不可少

下图展示了 RocketMQ 如何通过长轮询减小拉取消息的延迟。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

核心流程如下:

1、Broker 端接收到消费者的拉取消息请求后,拉取消息处理器开始处理请求,根据拉取请求查询消息存储 ;

2、从消息存储中获取消息数据 ,若存在新消息 ,则将消息数据通过网络返回给消费者。若无新消息,则将拉取请求放入到拉取请求表 pullRequestTable 。

3、长轮询请求管理服务 pullRequestHoldService 每隔 5 秒从拉取请求表中判断拉取消息请求的队列是否有新的消息。

判定标准是:拉取消息请求的偏移量是否小于当前消费队列最大偏移量,如果条件成立则说明有新消息了。

若存在新的消息 ,  长轮询请求管理服务会触发拉取消息处理器重新处理该拉取消息请求。

4、当 commitlog 中新增了新的消息,消息分发服务会构建消费文件和索引文件,并且会通知长轮询请求管理服务,触发拉取消息处理器重新处理该拉取消息请求

6 消费消息

在拉取消息的流程里, Broker 端返回消息数据,消费者的通讯框架层会执行回调函数。

回调线程会将数据存储在队列消费快照 processQueue(内部使用红黑树 msgTreeMap)里,然后将消息提交到消费消息服务,消费消息服务会异步消费这些消息。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

消息消费服务有两种类型:并发消费服务顺序消费服务 。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

6.1 并发消费

并发消费是指消费者将并发消费消息,消费的时候可能是无序的

消费消息并发服务启动后,会初始化三个组件:消费线程池清理过期消息定时任务处理失败消息定时任务

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

核心流程如下:

0、通讯框架回调线程会将数据存储在消费快照里,然后将消息列表 msgList 提交到消费消息服务

1、 消息列表 msgList 组装成消费对象

2、将消费对象提交到消费线程池

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

我们看到10 条消息被组装成三个消费请求对象,不同的消费线程会执行不同的消费请求对象。

3、消费线程执行消息监听器

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

执行完消费监听器,会返回消费结果。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

4、处理异常消息

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

当消费异常时,异常消息将重新发回 Broker 端的重试队列( RocketMQ 会为每个 topic 创建一个重试队列,以 %RETRY% 开头),达到重试时间后将消息投递到重试队列中进行消费重试。

我们将在重试机制这一节重点讲解 RocketMQ 如何实现延迟消费功能 。

假如异常的消息发送到 Broker 端失败,则重新将这些失败消息通过处理失败消息定时任务重新提交到消息消费服务。

5、更新本地消费进度

消费者消费一批消息完成之后,需要保存消费进度到进度管理器的本地内存。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

首先我们会从队列消费快照 processQueue 中移除消息,返回消费快照 msgTreeMap 第一个偏移量 ,然后调用消费消息进度管理器 offsetStore 更新消费进度。

待更新的偏移量是如何计算的呢?

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

  • 场景1:快照中1001(消息1)到1010(消息10)消费了,快照中没有了消息,返回已消费的消息最大偏移量 + 1 也就是1011。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

  • 场景2:快照中1001(消息1)到1008(消息8)消费了,快照中只剩下两条消息了,返回最小的偏移量 1009。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

  • 场景3:1001(消息1)在消费对象中因为某种原因一直没有被消费,即使后面的消息1005-1010都消费完成了,返回的最小偏移量是1001。

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

在场景3,RocketMQ 为了保证消息肯定被消费成功,消费进度只能维持在1001(消息1),直到1001也被消费完,本地的消费进度才会一下子更新到1011。

假设1001(消息1)还没有消费完成,消费者实例突然退出(机器断电,或者被 kill ),就存在重复消费的风险。

因为队列的消费进度还是维持在1001,当队列重新被分配给新的消费者实例的时候,新的实例从 Broker 上拿到的消费进度还是维持在1001,这时候就会又从1001开始消费,1001-1010这批消息实际上已经被消费过还是会投递一次。

所以业务必须要保证消息消费的幂等性

写到这里,我们会有一个疑问:假设1001(消息1)因为加锁或者消费监听器逻辑非常耗时,导致极长时间没有消费完成,那么消费进度就会一直卡住 ,怎么解决呢 ?

RocketMQ 提供两种方式一起配合解决:

  • 拉取服务根据并发消费间隔配置限流

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

  • 拉取消息服务在拉取消息时候,会判断当前队列的 processQueue 消费快照里消息的最大偏移量 - 消息的最小偏移量大于消费并发间隔(2000)的时候 , 就会触发流控 ,  这样就可以避免消费者无限循环的拉取新的消息。
  • 清理过期消息

万字长文讲透 RocketMQ 的消费逻辑 (上篇)-鸿蒙开发者社区

消费消息并发服务启动后,会定期扫描所有消费的消息,若当前时间减去开始消费的时间大于消费超时时间,首先会将过期消息发送 sendMessageBack 命令发送到 Broker ,然后从快照中删除该消息。




文章转载自公众号:勇哥java实战分享

标签
已于2023-6-25 12:00:07修改
收藏
回复
举报
回复
    相关推荐