本文深入探讨了一款分布式、队列模型的消息中间件。RocketMQ设计用于高度可扩展的分布式系统,旨在解决应用解耦、异步调用、流量削峰及确保分布式环境下的最终一致性等关键问题。这不仅有助于开发者理解RocketMQ的工作原理,也为想要深入优化或定制消息中间件的高级用户提供了宝贵的参考资料。
简介
RocketMQ是一款分布式、队列模型的消息中间件,消息生产分为Topic 与 Queue 两种模式,Push 和 Pull 两种方式消费,支持严格的消息顺序,亿级别的堆积能力,支持消息回溯和多个维度的消息查询。
作用:应用解耦、异步调用、流量削峰、分布式最终一致性
源码核心模块:namesrv、client、broker、store、remoting
突出优势:能保证严格的消息顺序;提供丰富的消息获取模式;消费者水平扩展能力;亿级消息堆积能力等。
go
名词解释
▐ NameServer(NameService)
用于服务发现,提供名称服务。NameServer是一个几乎无状态节点,可集群部署,节点之间无任何信息同步。
内部维护Topic和Broker之间的对应关系,并且和所有Broker保持心跳链接,在Producer和Consumer需要发布或者消费消息的时候,向NameServer发出请求来获取连接的Broker的信息。
NameServer可以部署多个,每个之间相互独立。其他角色同时向多个NameServer机器上报状态信息,从而达到热备份的目的。
NameServer类似Kafka中Zookeeper的角色。不使用Zookeeper作为注册中心是因为Zk有自动选举Master的功能,rocketMQ的架构设计上决定了它不需要进行Master选举,而只需要使用一个轻量级的元数据服务就行了。
▐ Broker
RocketMQ的服务器,负责消息中转,主要职责是存储、转发消息。
部署相对复杂,Broker分为Master和Slave,一个Master可以对应多个Slave,但是一个Slave只能对应一个Master,Master与Slave的对应关系通过指定相同的BrokerName,不同的BrokerId来定义。BrokerId为0表示Master,非0表示Slave。Master可以部署多个。
每个Broker和NameServer集群中的所有节点建立长连接,定时(30s)注册Topic消息到所有NameServer。NameServer定时(10s)扫描所有存活Broker的连接,如果NameServer超过2分钟没有收到心跳,则NameServer断开与该Broker的连接。
消息会发送到Master上,一旦Master上面记录成功,不用等待Slave上是否记录成功,Slave会定时的去获取消息记录,所以Slave会和Master会存在时间差。Slave可以作为Consumer的服务提供者,如果写入必须通过Master,消费的时候则可以直接从Slave上获取。
消息发送到Broker后需要进行持久化。
刷盘策略指的是消息发送到Broker内存后持久化到磁盘的方式,分为同步刷盘和异步刷盘。
复制策略是Broker的Master与Slave间的数据同步方式,分为同步复制与异步复制。
由于异步复制、异步刷盘可能会丢失少量信息,因此Broker默认采用的是同步双写的方式。消息写入Master成功后,Master会等待Slave同步数据成功后才向Producer返回成功ACK,即Master与Slave都要写入成功后才会返回成功ACK。这样可以保证消息发送时消息不丢失。
▐ Producer
消息生产者,负责产生消息,一般是业务系统。Producer与NameServer集群中的一个节点(随机选择)建立长连接,定期从NameServer取Topic路由消息,并向提供Topic服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可以集群部署。Producer会发布消息到Master上面,然后由Master同步给所有的Slave。
Producer每隔30s从NameServer获取所有Topic队列的最新情况,这意味着如果Broker不可用,Producer最多30s能够感知,在此期间内发放Broker的所有消息都会失败。
消息发送时如果出现失败,默认会重试2次。在重试时会尽量避开刚刚接收失败的Broker,而是选择其他Broker上的队列进行发送,从而提高消息发送的成功率。
▐ Consumer
消息消费者,负责消费消息,一般是后台系统负责异步消费。Consumer与NameServer集群中的一个其中一个节点(随机选择)建立长连接,定期从NameServer取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,订阅规则由Broker配置决定。
rocketMQ使用长轮询的方式,Consumer和Broker保持长连接,Broker获取到消息后,会通知Consumer来拉取消息,Consumer可以选择 立即拉取 或 等待一段时间 再拉取消息。
▐ 消费重试
当出现消费失败的消息时,Broker会为每个消费者组设置一个重试队列。当一条消息初次消费失败,消息队列会自动进行消费重试。达到最大次数(默认16次),若消费仍然失败,此时会将该消息发送到死信队列。对于 死信消息,通常需要开发人员进行特殊处理。
▐ Topic
发布或者订阅的主题。Topic一般由多个队列组成,队列会平均地散列到多个Broker上面。Producer的发送机制会保证消息尽量平均地散列到所有队列上去。Tag属于子Topic,主要的作用是给业务提供更大的灵活性。
▐ Offset
消息在Broker上的每个分区都会组织成一个文件列表,消费者拉取数据的时候,需要知道数据在文件中的偏移量,这个偏移量就是Offset。Offset是一个绝对的偏移量,Broker会讲将Offset转为具体文件的相对偏移量。
go
核心流程
RocketMQ的核心流程包含三大部分,分别是启动、发送消息、消费消息。
启动:核心是NameService启动和broker启动,NameService是mq的注册中心,broker启动后将自己的地址注册到NameService;生产者发送消息时,从NameService中读取broker地址;消费者消费消息时,从NameService读取broker地址。
发送消息:Producer将消息写入到RocketMQ集群中Broker中具体的Queue。
消费消息:Consumer从RocketMQ集群中Broker中拉取具体的消息。
最后Producer、Consumer、NameService、Broker都是通过Netty的网络协议进行通信的,下面介绍各个组件。
NameServer
消息中间件的设计思路一般是基于主题订阅发布的机制,消息生产者(Producer)发送某一个主题到消息服务器,消息服务器负责将消息持久化存储,消息消费者(Consumer)订阅该兴趣的主题,消息服务器根据订阅信息(路由信息)将消息推送到消费者(Push模式)或者消费者主动向消息服务器拉去(Pull模式),从而实现消息生产者与消息消费者解耦。
▐ 作用
Broker消息服务器在启动的时向所有NameServer注册,消息生产者(Producer)在发送消息时之前先从NameServer获取Broker服务器地址列表,然后根据负载均衡算法从列表中选择一台服务器进行发送。NameServer与每台Broker保持长连接,并间隔30S检测Broker是否存活,如果检测到Broker宕机,则从路由注册表中删除,但是路由变化不会马上通知消息生产者。这样设计的目的是为了降低NameServer实现的复杂度,在消息发送端提供容错机制保证消息发送的可用性。
▐ NameServer启动流程
NameServerStartup 是 NameServer 的启动类,负责解析配置文件、加载运行时参数信息和初始化并启动 NameServerController,NameServerController是NameServer 的核心控制器。
RouteInfoManager 管理路由信息 RemotingServer 与 rocketMQ 其他组件(Broker、Producer 和 Consumer)通信。
▐ 路由管理
NameServer的主要作用是为消息的生产者和消息消费者提供关于主题Topic的路由信息。那么NameServer需要存储路由的基础信息,还要管理Broker节点,包括路由注册、路由删除等。
- NameServer处理请求
以Broker进行路由注册&心跳为例,阐述请求从被Broker 发出到被 NameServer 处理的流程。
Broker启动时向集群中所有的NameServer发送心跳信息,每隔30s向集群中所有NameServer发送心跳包,NameServer收到心跳包时会更新brokerLiveTable缓存中BrokerLiveInfo的lastUpdataTimeStamp信息,然后NameServer每隔10s扫描brokerLiveTable,如果连续120S没有收到心跳包,NameServer将移除Broker的路由信息同时关闭Socket连接。
- 路由删除
如果Broker宕机,NameServer无法收到心跳包,此时NameServer如何来剔除这些失效的Broker呢?
Broker启动后每间隔 30s 向 NameServer 集群所有节点广播发送心跳消息。NameServer 启动后每间隔 5s 扫描自身维护的 Broker 活跃信息。如果BrokerLive的上一次的心跳更新时间 + 超时时间(和broker绑定,不是常量)< 现在时间,则认为Broker失效,NameServer 将对应的 Broker 从路由信息中移除。
NameService对broker的心跳检查
利用java中的定时任务类ScheduledExecutorService来检测broker的心跳,每隔5秒扫描一次。
遍历broker列表,若一个broker满足上一次的心跳更新时间 + 超时时间(和broker绑定,不是常量)< 现在时间,那么就认为这个broker宕机,将这个broker关闭。
Broker
Broker启动的核心类是:BrokerStartup,BrokerController
BrokerStartup负责加载配置信息,构建BrokerController,BrokerController负责启动服务以及注册请求的Processor,processor的作用是根据请求的协议得到请求code,broker维持一张code和处理方法的映射关系,获取到请求信息后调用相应的processor进行处理。
Producer
▐ 消息发送
Producer发送消息
消息发送的时候,大致流程是:通过mqFaultStrategy会从topicPublishInfoTable中选择一个消息队列,然后通过mQClientFactory类将消息发送到队列中。而这些对象的初始化在DefaultMQProducerImpl内部start的方法中:
-
验证消息
-
查找路由(根据消息的topic,查找消息要发送到哪个broker)
-
选择队列
-
发送消息
调用start()初始化后,producer便得到了nameServer的地址,并和所有的broker维持了心跳信息,接下来就可以发送消息了。通过上面的分析,DefaultMQProducerImpl是发送的核心类,send最终调用的也是DefaultMQProducerImpl的方法。
选择队列,两种机制:
-
默认不启用故障延迟机制
-
启动Broker故障延迟机制
go
Consumer消费消息
▐ 概述
-
消息队列负载与重新分布
-
消息消费模式
-
消息拉取方式
-
消息进度反馈
-
消息过滤
-
顺序消息
消息消费以组的模式展开,一个消费组内可以包含多个消费者(同一个JVM实例内只允许不允许存在消费组相同的消费者),消费组之间要保持统一的订阅关系,这一点很重要。
消费组之间有两种消费模式:
-
广播模式:主题下的同一条消息将被集群内的所有消费者消费一次。
-
集群模式:主题下的同一条消息只允许被其中一个消费者消费。
消息服务器与消费者之间的消息传送也有两种方式:
-
拉模式:消费端主动发起拉请求
-
推模式:消息达到服务器后,推送给消息消费者(实际上推模式也是基于拉模式实现的,在拉模式上封装了一层)
▐ 消息拉取
消息消费模式有两种模式:广播模式与集群模式。广播模式比较简单,每一个消费者需要拉取订阅主题下所有队列的消息在集群模式下,同一个消费者组内有多个消息消费者,同一个主题存在多个消费队列,消费者通过负载均衡的方式消费消息。
消息队列负载均衡,通常的作法是一个消息队列在同一个时间只允许被一个消费消费者消费,一个消息消费者可以同时消费多个消息队列。
- PullMessageService实现机制
RocketMQ使用一个单独的线程PullMessageService来负责消息的拉取。PullMessageService循环不断阻塞的从pullRequestQueue中取出pullRequest。
- ProcessQueue实现机制
ProcessQueue是MessageQueue在消息端的快照。PullMessageService从消息服务器默认每次拉取32条消息,按消息的队列偏移量顺序存放在ProcessQueue中,PullMessagService然后将消息提交到消费者消费线程池,消息成功消费后从ProcessQueue中移除。
- 消息拉取基本流程
-
客户端发起拉取请求
-
消息服务端Broker组装消息
-
客户端响应请求
- 消息拉取总结
一个consumer客户端会分配一个拉取消息线程(PullMessageService),不停地从存放了messageQuene的阻塞队列中take需要拉取消息的messagequene,最后通过调用通知网络层发起拉取消息拉取的网络请求(实际就是交给netty的worker线程拉消息),netty的worker线程拉取到消息后调用处理PullCallback处理拉取的结果。
▐ 消息消费过程分析
PullMessageService负责对消息队列进行消息拉取,从远端服务器拉取消息后将消息存储ProcessQueue消息队列处理队列中,然后调用ConsumeMessageService#submitConsumeRequest方法进行消息消费。
ConsumeMessageService支持顺序消息和并发消息。
- 消费消息
调用业务实现的消费消息逻辑,得到消费消息结果(即使消费超时了,也最终会根据messageListner执行返回的结果来决定是否重新消费消息)。
消费完消息之后会将消费之后的messageQuene对应的offset存放在缓存map中。在消费者启动后,会调用定时任务persistAllConsumerOffset将缓存的offsetTable提交给broker。
由于offset是先存在内存中,定时器间隔几秒提交给broker,消费之后的offset是完全存在可能丢失的风险(例如consumer端突然宕机),从而会导致没有提交offset到broker,再次启动consumer客户端时,会重复消费。
▐ 消息拉取长轮询机制分析
RocketMQ未真正实现消息推模式,而是消费者主动向消息服务器拉取消息,RocketMQ推模式是循环向消息服务端发起消息拉取请求,如果消息消费者向RocketMQ拉取消息时,消息未到达消费队列时,如果不启用长轮询机制,则会在服务端等待shortPollingTimeMills时间后(挂起)再去判断消息是否已经到达指定消息队列,如果消息仍未到达则提示拉取消息客户端PULL---NOT---FOUND(消息不存在);如果开启长轮询模式,RocketMQ一方面会每隔5s轮询检查一次消息是否可达,同时一有消息达到后立马通知挂起线程再次验证消息是否是自己感兴趣的消息,如果是则从CommitLog文件中提取消息返回给消息拉取客户端。
长轮询在broker是如何实现的?
构造PullRequest,然后放入到PullRequestHoldService中,在PullRequestHoldService会定期判断pullRequest是否可以唤醒;
将需要hold处理的PullRequest放入到一个ConcurrentHashMap中,等待被检查;
checkHoldRequest会对每个在pullRequestTable的pullRequest进行检查,检查逻辑在notifyMessageArriving方法。
方法中两个重要的判定就是:比较当前的offset和maxoffset,看是否有新的消息到来,有新的消息返回客户端;另外一个就是比较当前的时间和阻塞的时间,看是否超过了最大的阻塞时间,超过也同样返回。
▐ 顺序消息消费
消息拉取服务将消息提交到消费者消费线程池后,ConsumeMessageOrderlyService#run进行顺序消息消费,consumer在拉取消息之前对要拉取的MessageQueue加锁,成功后才拉取消息。
存储结构
RocketMQ的主要存储结构分成三大部分,分别是commitLog、ConsumeQueue、indexFile:
CommitLog:消息存储文件,所有消息主题的消息都存储在 CommitLog 文件中,并且是顺序存储。
ConsumeQueue:消息消费队列,消息到达 CommitLog 文件后,将异步转发到消息消费队列,供消息消费者消费。
IndexFile:消息索引文件,主要存储了消息的Key和索引的位置的对应关系。
RocketMQ消息存储是由CommitLog和ConsumeQueue配合完成的。CommitLog真正存储消息,ConsumeQueue是消息的逻辑队列,类似数据库的索引文件,存储消息在实际CommitLog物理存储中的偏移地址。每个Topic下的每个Queue都有一个对应的ConsumeQueue文件。
CommitLog以物理文件存储,每个Broker上的CommitLog被该机器所有的Queue共享。
对所有数据单独存储到一个CommitLog,完全顺序写,随机读,对最终用户展现的队列实际上只存储消息在CommitLog中的位置信息,并且串行方式刷盘。
这样做的好处:
队列轻量化,单个队列数据量非常少。对磁盘的访问串行化,避免磁盘竞争,不会因为队列增加导致IOWAIT增高。
缺点如下:
写虽然是顺序写,但是读却变成了完全的随机读。读一条消息,会先读ConsumeQueue,再读CommitLog,增加了开销。要保证CommitLog与ConsumeQueue完全的一致,增加了复杂度。
rocketMQ如何克服:
随机读,尽可能让读命中PAGECACHE,减少 IO 读操作,所以内存越大越好。如果系统中堆积的消息过多,读数据要访问磁盘会不会由于随机读导致系统性能急剧下降,答案是否定的。
访问PAGECACHE 时,即使只访问 1k 的消息,系统也会提前预读出更多数据,在下次读时,就可能命中内存。
随机访问Commit Log 磁盘数据,系统 IO 调度算法设置为 NOOP 方式,会在一定程度上将完全的随机读变成顺序跳跃方式,而顺序跳跃方式读较完全的随机读性能会高 5 倍以上。
由于Consume Queue 存储数据量极少,而且是顺序读,在 PAGECACHE 预读作用下,Consume Queue 的读性能几乎与内存一致,即使堆积情况下。所以可认为 Consume Queue 完全不会阻碍读性能。
CommitLog 中存储了所有的元信息,包含消息体,类似于 Mysql、Oracle 的 redolog,所以只要有 CommitLog 在,Consume Queue 即使数据丢失,仍然可以恢复出来。
▐ Broker消息存储做了什么事
Broker主要就是将Producer发送过来的消息做持久化存储(CommitLog),之后对保存的物理位置做文件索引(IndexFile)和实际消费排序(ConsumerQueue)。
Broker主要负责消息的存储、投递和查询以及服务高可用保证,为了实现这些功能,Broker包含了以下几个重要子模块:
-
RemotingModule:整个Broker的实体,负责处理来自clients端的请求。
-
ClientManager:负责管理客户端(Producer/Consumer)和维护Consumer的Topic订阅信息
-
StoreService:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
-
HAService:高可用服务,提供Master Broker 和 Slave Broker之间的数据同步功能。
-
IndexService:根据特定的Message key对投递到Broker的消息进行索引服务,以提供消息的快速查询。
▐ 消息存储整体架构
RocketMQ首先将消息的写入转化为顺序写,即所有 Topic 的消息均写入同一个文件(CommitLog)。同时,由于消息仍需要以 Topic 为维度进行消费,因此 rocketMQ 基于 CommitLog 为每个 Topic 异步构建多个逻辑队列(ConsumeQueue)和索引信息(Index):ConsumeQueue 记录了消息在 CommitLog 中的位置信息;给定 Topic 和消息 Key,索引文件(Index)提供消息检索的能力。
RocketMQ采用了单一的日志文件,即把同1台机器上面所有topic的所有queue的消息,存放在一个文件里面,从而避免了随机的磁盘写入。
不同Topic 的消息最终均被顺序持久化至共享的 CommitLog,CommitLog 由固定大小的文件队列组成,文件队列被定义为 MappedFileQueue,MappedFileQueue 中每个文件被定义为 MappedFile,每个MappedFile 对应一个具体的文件用于将消息持久化至磁盘。
- CommitLog如何写入?
MapedFileQueue存储队列,数据定时删除,无限增长。
队列有多个文件(MapedFile)组成,当消息到达broker时,需要获取最新的MapedFile写入数据,调用MapedFileQueue的getLastMapedFile获取,此函数如果集合中一个也没有创建一个,如果最后一个写满了也创建一个新的。
MapedFileQueue在获取getLastMapedFile时,如果需要创建新的MapedFile会计算出下一个MapedFile文件地址,通过预分配服务AllocateMapedFileService异步预创建下一个MapedFile文件,这样下次创建新文件请求就不要等待,因为创建文件特别是一个1G的文件还是有点耗时的,后续如果是异步刷盘还需要将mapedFile中的消息序列化到commitLog物理文件。
- Index文件存储结构
Index 的整体设计思想类似持久化在磁盘的 HashMap,同样使用链式地址法解决哈希冲突:每个 Hash Slot 关联一个 Message Index 链表,多个 Message Index 通过 preIndexOffset 连接。
▐ 消息存储实现
Producer端:Producer发送消息至Broker端,然后Broker端使用同步或者异步的方式对消息刷盘持久化,保存至CommitLog中。只要消息被刷盘持久化至磁盘文件CommitLog中,那么Producer发送的消息就不会丢失。
Consumer端:Consumer也就肯定有机会去消费这条消息,至于消费的时间可以稍微滞后一些也没有太大的关系。退一步地讲,即使Consumer端第一次没法拉取到待消费的消息,Broker服务端也能够通过长轮询机制等待一定时间延迟后再次发起拉取消息的请求。这里,rocketMQ的具体做法是,使用Broker端的后台服务线程---ReputMessageService不停地分发请求并异步构建ConsumeQueue(逻辑消费队列)和IndexFile(索引文件)数据。然后,Consumer即可根据ConsumerQueue来查找待消费的消息了。其中,ConsumeQueue(逻辑消费队列)作为消费消息的索引,保存了指定Topic下的队列消息在CommitLog中的起始物理偏移量offset,消息大小size和消息Tag的HashCode值。而IndexFile(索引文件)则只是为了消息查询提供了一种通过key或时间区间来查询消息的方法。
▐ 消息存储总结
消息生产与消息消费相互分离,Producer端发送消息最终写入的是CommitLog(消息存储的日志数据文件),Consumer端先从ConsumeQueue(消息逻辑队列)读取持久化消息的起始物理位置偏移量offset、大小size和消息Tag的HashCode值,随后再从CommitLog中进行读取待拉取消费消息的真正实体内容部分。
RocketMQ的CommitLog文件采用混合型存储(所有的Topic下的消息队列共用同一个CommitLog的日志数据文件),并通过建立类似索引文件---ConsumeQueue的方式来区分不同Topic下面的不同MessageQueue的消息,同时为消费消息起到一定的缓冲作用(只有ReputMessageService异步服务线程通过doDispatch异步生成了ConsumeQueue队列的元素后,Consumer端才能进行消费)。这样,只要消息写入并刷盘至CommitLog文件后,消息就不会丢失,即使ConsumeQueue中的数据丢失,也可以通过CommitLog来恢复。
生产者端的消息确实是顺序写入CommitLog;订阅消息时,消费者端也是顺序读取ConsumeQueue
结语
总之,本文是一篇全面介绍RocketMQ的指南,不仅适合想要深入了解技术架构的开发者阅读,也为那些计划在项目中应用消息队列技术的团队提供了宝贵的参考资料。通过掌握RocketMQ的工作原理,开发者可以更好地利用其特性解决实际业务中的消息传递需求,提升系统的弹性和可扩展性。
go
参考资料
-
RocketMQ官网:https://rocketmq.apache.org/
-
RocketMQ入门到入土:
团队介绍
我们是淘天集团-SRE团队,一支聚焦域内的高可用、可靠性设计及平台建设的技术团队,致力于识别通用/全局风险,提供低成本风险解决方案。我们定义可靠性目标,确保落地机制,确保系统在大促场景下的稳定。
¤ 拓展阅读 ¤