rocketmq 重复消费_消息队列 RocketMQ

引言

本文整理了RocketMQ的相关知识,方便以后查阅。

功能介绍

简单来说,消息队列就是基础数据结构课程里“先进先出”的一种数据结构,但是如果要消除单点故障,保证消息传输的可靠性,并且还能应对大流量的冲击,对消息队列的要求就很高了。现在互联网“微架构”模式兴起,原有大型集中式的IT服务因为各种弊端,通常被分拆成细粒度的多个“微服务”,这些微服务可以在一个局域网内,也可能跨机房部署。一方面对服务之间松耦合的要求越来越高,另一方面,服务之间的联系却越来越紧密,对通信质量的要求也越来越高。分布式消息队列可以提供应用解耦、流量削峰、消息分发等功能,已经成为大型互联网服务架构里标配的中间件。

应用解耦

复杂的应用里会存在多个子系统,比如在电商应用中有订单系统、库存系统、物流系统、支付系统等。这个时候如果各个子系统之间的耦合性太高,整体系统的可用性就会大幅降低。多个低错误率的子系统强耦合在一起,得到的是一个高错误率的整体系统。

以电商应用为例,用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障或者因为升级等原因暂时不可用,都会造成下单操作异常,影响用户使用体验。

如下图所示,当转变成基于消息队列的方式后,系统可用性就高多了,比如物流系统因为发生故障,需要几分钟的时间来修复,在这几分钟的时间里,物流系统要处理的内容被缓存在消息队列里,用户的下单操作可以正常完成。当物流系统恢复后,补充处理存储在消息队列里的订单信息即可,终端用户感知不到物流系统发生过几分钟的故障。

861239b99bea17a03ea47c2060ed8f7d.png

流量削峰

电商服务中一般都会有大促活动,在大促时,大部分应用系统流量会在瞬间猛增,这个时候如果没有缓冲机制,不可能承受住短时大流量的冲击。通过利用消息队列,把大量的请求暂存起来,分散到相对长的一段时间内处理,能大大提高系统的稳定性和用户体验。

举个例子,如果订单系统每秒最多能处理一万次下单,这个处理能力应对正常时段的下单是绰绰有余的,正常时段我们下单后一秒内就能返回结果。在大促活动时,如果没有消息队列这种缓冲机制,为了保证系统稳定,只能在订单超过一万次后就不允许用户下单了。如果有消息队列做缓冲,我们可以取消这个限制,把一秒内下的订单分散成一段时间来处理,这时有些用户可能在下单后十几秒才能收到下单成功的状态,但是也比不能下单的体验要好。

使用消息队列进行流量削峰,很多时候不是因为能力不够,而是出于经济性的考量。

消息分发

在大数据时代,数据对很多公司来说就像金矿,公司需要依赖对数据的分析,进行用户画像、精准推送、流程优化等各种操作,并且对处理的实时性要求越来越高。数据是不断产生的,各个分析团队、算法团队都要依赖这些数据来进行工作,这个时候有个可持久化的消息队列就非常重要。数据的产生方只需要把各自的数据写入一个消息队列即可,数据使用方根据各自需求订阅感兴趣的数据,不同数据团队所订阅的数据可以重复也可以不重复,互不干扰,也不必和数据产生方关联。

如下图所示,各个子系统将日志数据不停地写入消息队列,不同的数据处理系统有各自的Offset,互不影响。甚至某个团队处理完的结果数据也可以写入消息队列,作为数据的产生方,供其他团队使用,避免重复计算。在大数据时代,消息队列已经成为数据处理系统不可或缺的一部分。

5d2f0a237a9df4f2cf005ef88bf399a7.png

方便动态扩容

我们已经知道,消息队列可以帮我们缓存用户的请求,让我们有更加宽裕的时间来处理这些请求,那么对于请求越积压越多的情况,显然只通过现存下游服务持续消费是无法满足的。这时候,就需要根据消息队列中数据积压的情况动态的增加下游服务的节点数,避免消息越积压越多,最后到无法控制的地步。

保证最终一致性

既然前面已经引入了"微服务"的概念,必然就会牵扯出分布式事务的问题,而业内解决分布式事务问题基本是采用如下两套方案: - 基于TCC的事务框架 - 消息队列

如果服务双方是同步调用,即要么一起成功,要么一起失败,则此时应该选用TCC的事务框架,这部分内容我们以后会写一篇文章介绍分布式事务框架————seata,那时再专门进行说明。

如果服务双方是异步调用,即上游服务落库后立即返回,不等待下游服务的执行结果时,一般都会采用消息队列来实现。下面的例子虽然不是RocketMQ的方案,但是是使用消息队列解决最终一致性问题的一个通用方案,我们不妨先来看一看。

fdccef33f2d796e2216c0f738079d956.png

图中的过程表述的是将支付宝中的钱转入余额宝,操作步骤如下:

1. 支付宝将zhansan的余额扣除100并将分布式事务消息记录写入本地库中

- 借助了本地数据库的ACID属性,保证余额落库和消息记录落库的ACID

2. 后台有一个定时程序,将本地库中未成功发送到消息队列的消息进行发送,消息中既包含余额宝要处理的账户的金额,也包含分布式事务的id

- 引入本地消息记录表的意义:如果步骤1中直接余额落库,并发送消息,无法保证其原子性。可能消息发送成功了,但是最终本地事务提交失败。如果先提交本地事务,再发送消息,也可能本地提交成功,但是消息未发送。所以在步骤1中,将余额和消息记录同时落库,最后让定时任务去扫描未发送的消息,并进行消息发送,这时候发送成功后再将消息记录标记为已发送

- 这样虽然保证了,消息一定会被发送到消息队列,但是没办法保证消息只发送1份。因为可能消息发送成功后还没来得及修改本地消息记录的状态,就停机了。这时,重启服务器后,会产生重复的消息,这就需要下游服务提供幂等性支持(重复处理同一条消息,不会造成数据错误)

3. 余额宝从消息队列中拿到消息后,在同一个事务中将消息中存储的分布式事务id存储在本地的消息处理表中,然后修改zhansan的余额,最后提交事务

- 这样,如果收到一条重复的消息,在将消息插入到本地消息处理表时,就会发生事务id重复的错误,让事务回滚,从而保证了幂等性

至此,大家应该已经了解了通过消息队列来处理分布式事务的通用解决方案了,在这个例子中我们可以看出,消息的Provider需要在自己的服务中添加一个消息发送表,并维护一个循环任务来发送消息。这对其来说有很大的服务侵入性,在本文的后段,我会介绍RocketMQ的分布式事务方案,它通过自己的一些机制,降低了对消息Provider的侵入性。

设计理念与目标

设计理念

RocketMQ 设计基于主题的发布与订阅模式,其核心功能包括消息发送,消息存储(Broker),消息消费,整体设计追求简单与性能第一,主要体现在如下三个方面。

首先,NameServer设计极其简单,摒弃了业界常用的使用Zookeeper充当信息管理的“注册中心”,而是自研NameServer来实现元数据的管理(Topic路由信息等)。从实际需求出发,因为Topic路由信息无须在集群之间保持强一致,追求最终一致性,并且能容忍分钟级的不一致。正是基于此种情况,RocketMQ的NameServer集群之间互不通信,极大地降低了NameServer实现的复杂程度,对网络的要求也降低了不少,但是性能相比较Zookeeper有了极大的提升。

其次是高效的IO存储机制。RocketMQ追求消息发送的高吞吐量,RocketMQ的消息存储文件设计成文件组的概念,组内单个文件大小固定,方便引人内存映射机制,所有主题的消息存储基于顺序写,极大地提供了消息写性能,同时为了兼顾消 息消费与消息查找,引入了消息消费队列文件与索引文件。

最后是容忍存在设计缺陷,适当将某些工作下放给RocketMQ使用者。消息中间件的实现者经常会遇到一个难题:如何保证消息一定能被消息消费者消费,并且保证只消费一次。RocketMQ的设计者给出的解决办法是不解决这个难题,而是退而求其次,只保证消息被消费者消费,但设计上允许消息被重复消费,这样极大地简化了消息中间件的内核,使得实现消息发送高可用变得非常简单与高效消息重复问题由消费者在消息消费时实现幂等。

设计目标

RocketMQ作为一款消息中间件,需要解决如下问题。 架构模式 RocketMQ与大部分消息中间件一样,采用发布订阅模式,基本的参与组件主要包括:消息发送者、消息服务器(消息存储)、消息消费、路由发现。 顺序消费 所谓顺序消息,就是消息消费者按照消息达到消息存储服务器的顺序消费。RocketMQ可以严格保证消息有序,但是相较于无序队列来说,性能上会有很大的损失,不过这也是在所难免的。 消息过滤 消息过滤是指在消息消费时,消息消费者可以对同一主题下的消息按照规则只消费自己感兴趣的消息。RocketMQ消息过滤支持在服务端与消费端的消息过滤机制。 - 消息在Broker端过滤。Broker只将消息消费者感兴趣的消息发送给消息消费者。 - 消息在消息消费端过滤,消息过滤方式完全由消息消费者自定义,但缺点是有很多无用的消息会从Broker传输到消费端。

消息存储 消息中间件的一个核心实现是消息的存储对消息存储一般有如下两个维度的考量: 消息堆积能力和消息存储性能。RocketMQ追求消息存储的高性能,引人内存映射机制,所有主题的消息顺序存储在同一个文件中。同时为了避免消息无限在消息存储服务器中累积,引入了消息文件过期机制与文件存储空间报警机制。 消息高可用性 通常影响消息可靠性的有以下几种情况。 1. Broker正常关机。 2. Broker异常 Crash。 3. OS Crash。 4. 机器断电,但是能立即恢复供电情况 。 5. 机器无法开机(可能是CPU、主板、内存等关键设备损坏)。 6. 磁盘设备损坏。

针对上述情况,情况1~4的RocketMQ在同步刷盘机制下可以确保不丢失消息,在异步刷盘模式下,会丢失少量消息。情况5-6属于单点故障,一旦发生,该节点上的消息全部丢失,如果开启了异步复制机制,RoketMQ能保证只丢失少量消息,如果使用Master Slave双写机制,可以保证不丢失消息,从而满足消息可靠性要求极高的场合。 消费低延迟 RocketMQ在消息不发生消息堆积时,以长轮询模式实现准实时的消息推送模式。 确保消息必须被消费一次 RocketMQ通过消息消费确认机制(ACK)来确保消息至少被消费一次,但由于ACK消息有可能丢失等其他原因,RocketMQ无法做到消息只被消费一次,有重复消费的可能。 回溯消息 回溯消息是指消息消费端已经消费成功的消息,由于业务要求需要重新消费消息。RocketMQ支持按时间回溯消息,时间维度可精确到毫秒,可以向前或向后回溯。 消息堆积 消息中间件的主要功能是异步解耦,必须具备应对前端的数据洪峰,提高后端系统的可用性,必然要求消息中间件具备一定的消息堆积能力。RocketMQ消息存储使用磁盘文件(内存映射机制),并且在物理布局上为多个大小相等的文件组成逻辑文件组,可以无限循环使用。RocketMQ消息存储文件并不是永久存储在消息服务器端,而是提供了过期机制,默认保留3天。 定时消息 定时消息是指消息发送到Broker后,不能被消息消费端立即消费,要到特定的时间点或者等待特定的时间后才能被消费。如果要支持任意精度的定时消息消费,必须在消息服务端对消息进行排序,势必带来很大的性能损耗,故RocketMQ不支持任意进度的定时消息,而只支持特定延迟级别。 消息重试机制 消息重试是指消息在消费时,如果发送异常,消息中间件需要支持消息重新投递,RocketMQ支持消息重试机制。

架构

aedadf7d9601d09673fd15fde903def3.png

RocketMQ集群中包含4个模块:Namesrv,Broker,Producer,Consumer。 - Namesrv: 存储当前集群所有Brokers信息、Topic跟Broker的对应关系。 - Broker: 集群最核心模块,主要负责Topic消息存储、消费者的消费位点管理(消费进度)。 - Producer: 消息生产者,每个生产者都有一个ID(编号),多个生产者实例可以共用同一个ID。同一个ID下所有实例组成一个生产者集群。 - Consumer: 消息消费者,每个订阅者也有一个ID(编号),多个消费者实例可以共用同一个ID。同一个ID下所有实例组成一个消费者集群。

接下来,我们将按照RocketMQ中的模块,挨个介绍其实现方案。

NameServer

本节主要介绍RocketMQ路由管理、服务注册及服务发现的机制,NameServer是整个RocketMQ的“大脑”。相信大家对“服务发现”这个词语并不陌生,分布式服务SOA架构体系中会有服务注册中心,分布式服务SOA的注册中心主要提供服务调用的解析服务,指引服务调用方(消费者)找到“远方”的服务提供者,完成网络通信,那么RocketMQ的路由中心存储的是什么数据呢?作为一款高性能的消息中间件,如何避免NameServer的单点故障,提供高可用性呢?

Broker消息服务器在启动时向所有NameServer注册,消息生产者(Producer)在发送消息之前先从NameServer获取Broker服务器地址列表,然后根据负载算法从列表中选择一台消息服务器进行消息发送。NameServer与每台Broker服务器保持长连接,并间隔30s检测Broker是否存活,如果检测到Broker宕机,则从路由注册表中将其移除。但是路由变化不会马上通知消息生产者,为什么要这样设计呢?这是为了降低NameServer实现的复杂性,在消息发送端提供容错机制来保证消息发送的高可用性。

NameServer本身的高可用可通过部署多台NameServer服务器来实现,但彼此之间互不通信,也就是NameServer服务器之间在某一时刻的数据并不会完全相同,但这对消息发送不会造成任何影响,这也是RocketMQ NameServer设计的一个亮点,RocketMQ NameServer设计追求简单高效。

存储内容

private final HashMap<String/* topic */, List<QueueData>> topicQueueTable;
private final HashMap<String/* brokerName */, BrokerData> brokerAddrTable;
private final HashMap<String/* clusterName */, Set<String/* brokerName */>> clusterAddrTable;
private final HashMap<String/* brokerAddr */, BrokerLiveInfo> brokerLiveTable;
private final HashMap<String/* brokerAddr */, List<String>/* Filter Server */> filterServerTable;
  • topicQueueTable:Topic消息队列路由信息,消息发送时根据路由表进行负载均衡。
  • brokerAddrTable:Broker基础信息,包含brokerName、所属集群名称、主备Broker地址。
  • clusterAddrTable:Broker集群信息,存储集群中所有Broker名称。
  • brokerLiveTable:Broker状态信息,NameServer每次收到心跳包时会替换该信息。
  • filterServerTable:Broker上的FilterServer列表,用于类模式消息过滤。

QueueData、BrokerData、BrokerLiveInfo类图如下图所示。

68ee12165853419dc187152ea42fd044.png

RocketMQ 2主2从部署图如下所示。

c1003d44f075919062d431afd895dbb1.png

对应运行时数据结构如下图所示。

30fc4b93e67621cf23e7c5df5368bc9d.png

38e685de5948cdb75ff3e2b53740b6bb.png

路由注册

RocketMQ路由注册是通过Broker与NameServer的心跳功能实现的。Broker启动时向集群中所有的NameServer发送心跳语句,每隔30s向集群中所有NameServer发送心跳包,NameServer收到Broker心跳包时会更新brokerLiveTable缓存中BrokerLiveInfo的lastUpdateTimestamp,然后NameServer每隔10s扫描brokerLiveTable,如果连续120s没有收到心跳包,NameServer将移除该Broker的路由信息同时关闭Socket连接。

心跳包

  • brokerAddr:broker地址。
  • brokerId:brokerId,O:Master;大于0:Slave。
  • brokerName:broker名称。
  • clusterName:集群名称。
  • haServerAddr:master地址,初次请求时该值为空,slave向NameServer注册后返回其MasterAddr。
  • requestBody:
  • filterServerList:消息过滤服务器列表。
  • topicConfigWrapper:主题配置。

从心跳包内容我们会发现,每次心跳包中都会包含所有的topic信息,如果一个broker上topic非常多的话,心跳包就会比较大,如果正好赶上网络不好的时候,可能就会导致broker下线。

NameServer与Broker保持长连接,Broker状态存储在brokerLiveTable中,NameServer每收到一个心跳包,将更新brokerLiveTable中关于Broker的状态信息以及路由表(topicQueueTable、 brokerAddrTable、 brokerLiveTable、 filterServerTable)。

路由删除

Broker每隔30s向NameServer发送一个心跳包,心跳包中包含BrokerId、Broker地址、Broker名称、Broker所属集群名称、Broker关联的FilterServer列表。但是如果Broker宕机,NameServer无法收到心跳包,此时NameServer如何来剔除这些失效的Broker呢? NameServer会每隔1Os扫描brokerLiveTable状态表,如果BrokerLive的lastUpdateTimestamp的时间戳距当前时间超过120s,则认为Broker失效,移除该Broker, 关闭与Broker连接,并同时更新topicQueueTable、brokerAddrTable、brokerLiveTable、filterServerTable。

RocketMQ有两个触发点来触发路由删除: 1. NameServer定时扫描brokerLiveTable检测上次心跳包与当前系统时间的时间差,如果时间戳大于120s,则需要移除该Broker信息。 2. Broker在正常被关闭的情况下,会执行unRegisterBroker指令,主动删除NameServer中关于自己的信息。

路由发现

RocketMQ路由发现是非实时的,当Topic路由出现变化后,NameServer不主动推送给客户端,而是由客户端定时拉取主题最新的路由。

工作示意图

cf6789e8a7982e4640692f255fe575d6.png

消息发送

RocketMQ支持3种消息发送方式:同步(sync)、异步(async)、单向(oneway)。 - 同步:发送者向MQ执行发送消息API时,同步等待,直到消息服务器返回发送结果。 - 异步:发送者向MQ执行发送消息API时,指定消息发送成功后的回调函数,然后调用消息发送API后,立即返回,消息发送者线程不阻塞,直到运行结束,消息发送成功或失败的回调任务在一个新的线程中执行。 - 单向:消息发送者向MQ执行发送消息API时,直接返回,不等待消息服务器的结果,也不注册回调函数,简单地说,就是只管发,不在乎消息是否成功存储在消息服务器上。

消息内容

private String topic;
private int flag;
private Map<String, String> properties;
private byte[] body;
private String transactionId;

Message的基础属性主要包括消息所属主题topic,消息Flag,扩展属性,消息体,事务ID。

消息Flag的定义如下,可以看出其主要和事务支持有关,关于RocketMQ的事务机制,我们后面会介绍:

public final static int COMPRESSED_FLAG = 0x1;
public final static int MULTI_TAGS_FLAG = 0x1 << 1;
public final static int TRANSACTION_NOT_TYPE = 0;
public final static int TRANSACTION_PREPARED_TYPE = 0x1 << 2;
public final static int TRANSACTION_COMMIT_TYPE = 0x2 << 2;
public final static int TRANSACTION_ROLLBACK_TYPE = 0x3 << 2;

Message 扩展属性主要包含下面几个。 - tag:消息TAG,用于消息过滤。 - keys:Message索引键,多个用空格隔开,RocketMQ可以根据这些key快速检索到消息。 - waitStoreMsgOK:消息发送时是否等消息存储完成后再返回。 - delayTimeLevel:消息延迟级别,用于定时消息或消息重试。

这些扩展属性存储在Message的properties中。

发送流程

消息发送流程主要的步骤:验证消息、查找路由、消息发送(包含异常处理机制)。

消息验证

消息发送之前,首先确保生产者处于运行状态,然后验证消息是否符合相应的规范,具体的规范要求是主题名称、消息体不能为空、消息长度不能等于0且默认不能超过允许发送消息的最大长度4M(maxMessageSize=l024 1024 4)。

查找路由

消息发送之前,首先需要获取主题的路由信息,只有获取了这些信息我们才知道消息要发送到具体的Broker节点。

如果生产者中缓存了topic的路由信息,如果该路由信息中包含了消息队列,则直接返回该路由信息,如果没有缓存或没有包含消息队列,则向NameServer查询该topic的路由信息。如果最终未找到路由信息,则抛出异常:无法找到主题相关路由信息异常。

这里就有一个问题,如果整个消息队列服务刚运行,各个topic的路由信息是如何创建出来的?一般来说会有两个方案: 1. 在生产者发送消息之前,就人工创建好各个topic的路由信息,这样做的好处是,可以根据该topic消息的实际需求,分配合适的broker数量和消息队列数量。一般来说,生产环境的服务都推荐以这种方式进行。 2. 可以配置各个Broker,打开其自动创建topic的功能(BrokerConfig#autoCreateTopicEnable),这样就会在发送第一个消息时,动态的创建该topic的路由信息。

自动创建topic路由的过程如下:

1. Broker如果开启了自动创建topic功能,则创建默认主题路由信息,并通过心跳包告知NameServer

2. Producer查询本地路由缓存,未找到新topic的路由信息

3. Producer查询NameServer,未找到新topic的路由信息

4. Producer查询NameServer,找到默认主题路由信息

5. Producer根据默认主题路由信息,将消息发送到默认主题的其中一个Broker

6. 收到默认主题消息的Broker,根据消息的原始topic,创建相应的路由信息,并通过心跳包告知NameServer

7. Producer下次发送该topic的消息时

- 如果已经存在该消息的路由(定时拉取):则直接根据路由发送消息

- 如果该消息的路由还没来得及同步:则继续发送到默认主题

从上面的自动创建topic流程中,我们会发现,如果新创建的topic信息没有来得及同步时,再次发送消息,可能会在其他 Broker 也创建该topic的队列,但是如果只是发送了一条该topic的消息后就等待一段时间,等路由信息同步完成后,再发送就会出现整个消息队列集群中,只有一个broker负责该topic,这样就对并发性产生较大的影响,试想一下,你的消息队列本来有10个Broker节点,他们都配置成自动创建Topic,然后10个Producer分别发送不同的topic消息,但是它们都只发送了一条消息就休息了一段时间,这10个Producer根据路由选择策略,碰巧都选择了同一个Broker,那么最后这个消息队列集群,就只有一个Broker在工作,其负担了所有topic的任务。

上面的例子,虽然有些极端,但是这也正是生产环境中不使用自动创建Topic策略的原因。除了这种极端情况,可能上例中的每个Producer都在对应topic路由信息同步前,将消息发送到了多个Broker,这些Broker都会创建相应topic,那么每个Topic都会由多个Broker负责,这样整个服务的并发能力就会得到充分的利用。

路由选择

根据路由信息选择消息队列,返回的消息队列按照broker、序号排序。举例说明,如果topicA在broker-a,broker-b上分别创建了4个队列,那么返回的消息队列如下:

[
  {
    "brokerName":"broker-a",
    "queueId": 0
  },
  {
    "brokerName":"broker-a",
    "queueId": 1
  },
  {
    "brokerName":"broker-a",
    "queueId": 2
  },
  {
    "brokerName":"broker-a",
    "queueId": 3
  },
  {
    "brokerName":"broker-b",
    "queueId": 0
  },
  {
    "brokerName":"broker-b",
    "queueId": 1
  },
  {
    "brokerName":"broker-b",
    "queueId": 2
  },
  {
    "brokerName":"broker-b",
    "queueId": 3
  }
]

首先消息发送端采用重试机制,由retryTimesWhenSendFailed指定同步方式重试次数,异步重试机制在收到消息发送请求后,执行回调之前进行重试,由retryTimesWhenSendAsyncFailed指定。接下来就是循环执行:选择消息队列、发送消息,发送成功则返回,收到异常则重试。

如果在一次消息发送的过程中消息发送失败了,那么在下次重试的过程中,会排除掉上次失败的Broker。

public MessageQueue selectOneMessageQueue(final String lastBrokerName) {
    if (lastBrokerName == null) { //空表示第一次发送
        return selectOneMessageQueue(); // 循环选择一个队列
    } else {
        int index = this.sendWhichQueue.getAndIncrement();
        for (int i = 0; i < this.messageQueueList.size(); i++) {// 遍历所有队列
            int pos = Math.abs(index++) % this.messageQueueList.size();
            if (pos < 0)
                pos = 0;
            MessageQueue mq = this.messageQueueList.get(pos);
            if (!mq.getBrokerName().equals(lastBrokerName)) {// 排除上一次的broker
                return mq;
            }
        }
        return selectOneMessageQueue(); // 如果只有一个broker,则继续循环选择套路
    }
}

public MessageQueue selectOneMessageQueue() { // 循环选择
    int index = this.sendWhichQueue.getAndIncrement();
    int pos = Math.abs(index) % this.messageQueueList.size();
    if (pos < 0)
        pos = 0;
    return this.messageQueueList.get(pos);
}

从上面的实现中,可以看出在一次消息发送的过程中,可以通过重试机制绕开失败过的Broker,但是如果是发送多个消息,上述机制就无法绕开会失败的Broker。

我们前面已经说过,如果Broker宕机,可能会花费很长时间才能同步到各个Producer,那么怎么在Broker宕机的信息同步到Producer之前,绕开它而将消息发送到别的正常Broker上呢,这就不得不提RocketMQ的另一个容错机制————故障延迟机制。

首先我们需要知道RocketMQ在哪里存储失败的Broker,这些信息都存在LatencyFaultToleranceImpl中,其中有一个ConcurrentHashMap<String, FaultItem> faultItemTable存储了所有失败节点的信息。FaultItem中存储了如下内容:

// FaultItem fields
private final String name;
private volatile long currentLatency; //请求该节点的耗时
private volatile long startTimestamp; //预估下次可用的时间点

public boolean isAvailable() { // 当前时间大于等于预估可用点时,则认为可用
    return (System.currentTimeMillis() - startTimestamp) >= 0;
}

FaultItem的信息,在每次消息发送成功,和消息发送失败时,进行更新。 - 如果消息发送成功,currentLatency赋值为本次请求的实际耗时 - 消息发送失败,currentLatency赋值为30s

startTimestamp是根据currentLatency进行设置的,RocketMQ将currentLatency分成了不同的档位,不同档位的currentLatency会对应不同的notAvailableDuration,然后: startTimestamp=System.currentTimeMillis() + notAvailableDuration currentLatency与notAvailableDuration的对应关系如下图:

private long[] latencyMax = {50L, 100L, 550L, 1000L, 2000L, 3000L, 15000L};
private long[] notAvailableDuration = {0L, 0L, 30000L, 60000L, 120000L, 180000L, 600000L};
// 按从大到小的顺序,找到 currentLatency 所在的区间,然后输出该区间对应的不可用时长
private long computeNotAvailableDuration(final long currentLatency) {
    for (int i = latencyMax.length - 1; i >= 0; i--) {
        if (currentLatency >= latencyMax[i])
            return this.notAvailableDuration[i];
    }
    return 0;
}

由此,我们可以算出如果消息发送失败,那么RocketMQ正常来说会禁用该Broker十分钟。

知道了RocketMQ如何存储失败节点,在让我们来看看它是如何利用该信息,达到避开失败节点的效果。

public MessageQueue selectOneMessageQueue(final TopicPublishInfo tpInfo, /*通常情况下,指上次请求失败时用到的节点*/final String lastBrokerName) {
    if (this.sendLatencyFaultEnable) {
        try {
            // 外层轮询,下次请求时会选择下一个队列
            int index = tpInfo.getSendWhichQueue().getAndIncrement();
            for (int i = 0; i < tpInfo.getMessageQueueList().size(); i++) {
                // index用于内层轮询,从而排除不可用的节点
                int pos = Math.abs(index++) % tpInfo.getMessageQueueList().size();
                if (pos < 0)
                    pos = 0;
                MessageQueue mq = tpInfo.getMessageQueueList().get(pos);
                // 验证可用性,latencyFaultTolerance存储了各个Broker发送消息的耗时,已经预估的下次可用时间点
                if (latencyFaultTolerance.isAvailable(mq.getBrokerName())) {
                    // 如果是第一次请求,并且可用,则直接返回
                    // 如果是重试请求,则只使用brokerName等于lastBrokerName相同的,这点大家肯定有疑问:为啥要使用上次失败的,其实这里的lastBrokerName不单单指上次发送失败的节点,
                    // 它还能蕴含推荐节点的信息,本函数的后半段中会看到如何将推荐节点的信息传递到lastBrokerName
                    // 但是我觉得这样写的一个弊端是:当前可用节点,如果和上次失败时所用的节点不一致时,就会被排除掉,这也会影响效率
                    if (null == lastBrokerName || mq.getBrokerName().equals(lastBrokerName))
                        return mq;
                }
            }
            // 走到这一步,代表所有节点都不可用(首次请求)或者上一次失败时用到的节点仍然不可用(重试请求)
            // 随后,将所有不可用的节点,按照潜在可用性(当前可用与否>上次使用该节点时的调用耗时>预估的下次可使用时间),进行了排序,然后选择最优的结果
            // 这里不用担心,连续重试时pickOneAtLeast每次都选择了相同节点,因为其内部也是用了轮训机制,会从最优->次优的顺序给出下一次推荐的节点
            final String notBestBroker = latencyFaultTolerance.pickOneAtLeast();
            //getQueueIdByBroker这个函数名也是惊到我了,完全和其功能不匹配,本行代码意在得到推荐节点是否仍然存在可写队列,如果存在,得出队列数
            int writeQueueNums = tpInfo.getQueueIdByBroker(notBestBroker);
            // 我们得到的推荐节点存在可写队列
            if (writeQueueNums > 0) {
                // 至此我们已经拿到了一个推荐节点,但是接下来代码的作者并没有简单地根据推荐节点来寻找队列,而是靠外层轮训找了下一个队列
                // 如果再次重试过程中没有发生路由信息更新的话,该队列应该仍然是不可用的,并且很可能仍是最初失败的Broker节点的队列,
                // 为什么这么说:假如消息队列为[BrokerA1,BrokerA2,BrokerA3,BrokerA4,BrokerB1,BrokerB2],只有当上一次轮训到BrokerA4时,这里才会跳过BrokerA而得到BrokerB的队列
                // 而且,可能下次更新路由表时,该信息可能就会成为过期数据而被GC,我猜作者是觉得反正这个数据快没用了,不如把它替换成刚才得到的推荐节点信息,这样可以少new一个对象,还能增加下次查找时的效率
                // 之所以说它能增加效率,是因为这个过程实质上是将一个很可能宕机的节点队列换成了最可能可用的节点信息,那么下次再轮训到这个节点时,实际上就跳过了寻找推荐节点的过程
                final MessageQueue mq = tpInfo.selectOneMessageQueue();
                if (notBestBroker != null) {
                    // 把得到的队列的BrokerName改成我们前面得到的推荐节点,这样如果再请求失败并且重试的时候,lastBrokerName其实存储的就是推荐节点的信息了,下次再执行本函数时就会优先使用推荐节点的其他队列
                    mq.setBrokerName(notBestBroker);
                    // 重新计算其队列编号,因为得到的这个队列数可能和推荐节点的队列数不一致,如果用了错误的队列序号,消息发送到Broker那时,肯定会报错
                    // 因此,这里基于外层轮询使用的index,对本次使用的队列编号进行了计算,我觉得这里最终达到的是随机选择的效果
                    mq.setQueueId(tpInfo.getSendWhichQueue().getAndIncrement() % writeQueueNums);
                }
                return mq;
            } else { // 推荐节点不存在可写队列了,说明该节点可能已经宕机,并且NameServer已经删除了其路由信息,并且已经同步过来了
                // 这时候可以将该节点从延迟统计表中删除,不在考虑该节点
                latencyFaultTolerance.remove(notBestBroker);
            }
        } catch (Exception e) {
            log.error("Error occurred when selecting message queue", e);
        }
        // 如果拿到的推荐节点已经不存在可写队列了,就随机选一个队列
        return tpInfo.selectOneMessageQueue();
    }
    // 默认策略
    return tpInfo.selectOneMessageQueue(lastBrokerName);
}

在我看来,RocketMQ的队列选择算法很恐怖,我不确定是特意设计成这样的,还是历史发展出来的奇怪产物。Github仓库中,也有很多人提issue问这个函数的设计深意。而且,其他人的文章中对该函数的介绍都是一笔带过。我这里,以我的理解对该算法进行了详细的分析,可能有些地方理解的不对,希望大家在留言中指出。

简单的说,上述算法按照如下流程工作: 1. 轮训所有队列,通过LatencyFaultTolerance找到可用队列 2. 如果未找到任何可用队列,通过LatencyFaultTolerance存储的信息,按照三个纬度的可用性排序(当前可用与否>上次使用该节点时的调用耗时>预估的下次可使用时间),选出最可能可用的队列 3. 如果上述两个步骤都没有选出队列,则按照最简单的轮训找到下一个队列

消息发送

  1. 根据MessageQueue获取Broker的网络地址。如果找不到Broker信息,则抛出MQClientException,提示Broker不存在。
  2. 为消息分配全局唯一ID,如果消息体默认超过4K(compressMsgBodyOverHowMuch), 会对消息体采用zip压缩,并设置消息的系统标记为MessageSysFlag.COMPRESSED_FLAG。如果是事务Prepared消息,则设置消息的系统标记为MessageSysFlag.TRANSACTION_PREPARED_TYPE。
  3. 如果注册了消息发送钩子函数,则执行消息发送之前的增强逻辑。
  4. 构建消息发送请求包。主要包含如下重要信息:生产者组、主题名称、默认创建主题Key、该主题在单个Broker默认队列数、队列ID(队列序号)、消息系统标记(MessageSysFlag)、消息发送时间、消息标记(RocketMQ对消息中的flag不做任何处理,供应用程序使用)、消息扩展属性、消息重试次数、是否是批量消息等。
  5. 根据消息发送方式,同步、异步、单向方式进行网络传输。
  6. 如果注册了消息发送钩子函数,执行after逻辑。注意,就算消息发送过程中发生RemotingException、MQBrokerException、 InterruptedException时该方法也会执行。

异步发送 消息异步发送是指消息生产者调用发送的API后,无须阻塞等待消息服务器返回本次消息发送结果,只需要提供一个回调函数,供消息发送客户端在收到响应结果回调。异步方式相比同步方式,消息发送端的发送性能会显著提高,但为了保护消息服务器的负载压力,RocketMQ对消息发送的异步消息进行了并发控制,通过参数clientAsyncSemaphoreValue来控制,默认为65535。异步消息发送虽然也可以通过DefaultMQProducer#retryTimesWhenSendAsyncFailed属性来控制消息重试次数,但是重试的调用入口是在收到服务端响应包时进行的,如果出现网络异常、网络超时等将不会重试。

单向发送 单向发送是指消息生产者调用消息发送的API后,无须等待消息服务器返回本次消息发送结果,并且无须提供回调函数,表示消息发送压根就不关心本次消息发送是否成功,其实现原理与异步消息发送相同,只是消息发送客户端在收到响应结果后什么都不做而已,并且没有重试机制。

批量发送

批量消息发送是将同一主题的多条消息一起打包发送到消息服务端,减少网络调用次数,提高网络传输效率。当然,并不是在同一批次中发送的消息数量越多性能就越好,其判断依据是单条消息的长度,如果单条消息内容比较长,则打包多条消息发送会影响其他线程发送消息的响应时间,并且单批次消息发送总长度不能超过DefaultMQProducer#maxMessageSize。批量消息发送要解决的是如何将这些消息编码以便服务端能够正确解码出每条消息的消息内容。

RocketMQ对批量消息使用固定格式进行存储,如下图所示。

4e1de08a02620ebf0a29809a9bf5e4cb.png

消息存储

通过前面的知识,我们已经知道了topic是如何分配到Broker的,以及消息发送方是如何决定把消息发送给哪个Broker的,接下来我们看一看Broker介绍到消息后,是怎么存储消息的。

RocketMQ主要存储的文件包括CommitLog文件、ConsumeQueue文件、IndexFile文件。RocketMQ将所有主题的消息存储在同一个文件中,确保消息发送时顺序写文件,尽最大的能力确保消息发送的高性能与高吞吐量。但由于消息中间件一般是基于消息主题的订阅机制,这样便给按照消息主题检索消息带来了极大的不便。为了提高消息消费的效率,RocketMQ引入了ConsumeQueue消息队列文件,每个消息主题包含多个消息消费队列,每一个消息队列有一个消息文件。IndexFile索引文件,其主要设计理念就是为了加速消息的检索性能,根据消息的属性快速从CommitLog文件中检索消息。

磁盘有时候会比你想象的快很多,有时候也会比你想象的慢很多,关键在如何使用,使用得当,磁盘的速度完全可以匹配上网络的数据传输速度。目前的高性能磁盘,顺序写速度可以达到600MB/s,超过了一般网卡的传输速度,这是磁盘比想象的快的地方。但是磁盘随机写的速度只有大概1OOKB/s, 和顺序写的性能相差6000倍!因为有如此巨大的速度差别,好的消息队列系统会比普通的消息队列系统速度快多个数量级。

存储方案

4b07bb9d03866be1c2dfd742932a946a.png

RocketMQ消息的存储是由ConsumeQueue和CommitLog配合完成的,消息真正的物理存储文件是CommitLog, ConsumeQueue是消息的逻辑队列,类似数据库的索引文件,存储的是指向物理存储的地址。每个Topic下的每个Message Queue都有一个对应的ConsumeQueue文件。

CommitLog以物理文件的方式存放,每台Broker上的CommitLog被本机器所有ConsumeQueue共享。在CommitLog中,一个消息的存储长度是不固定的,RocketMQ采取一些机制,尽量向CommitLog中顺序写,但是随机读。ConsumeQueue的内容也会被写到磁盘里作持久存储,只不过是通过异步刷盘的方式进行。

这样设计的优点: 1. CommitLog顺序写,可以大大提高写入效率。接收到消息时,只有CommitLog是需要同步刷盘的(根据配置,可能也不需要同步刷盘),其他文件都是异步保存,如果发生了宕机,RocketMQ可以根据CommitLog恢复ConsumeQueue文件和IndexFile。 2. 虽然CommitLog是随机读,但是利用操作系统的page cache机制,可以批量地从磁盘读取,cache存到内存中之后,加速后续的读取速度。 3. 为了保证完全的顺序写,需要ConsumeQueue这个中间结构,因为ConsumeQueue里只存偏移量信息,所以尺寸是有限的,在实际情况中,大部分的ConsumeQueue能够被全部读人内存,所以这个中间结构的操作速度很快,可以认为是内存读取的速度。此外为了保证CommitLog和ConsumeQueue的一致性,CommitLog里存储了Consume Queues、Message keys、Tags等所有信息,即使ConsumeQueue丢失,也可以通过commitLog完全恢复出来。

下图是一个Broker在文件系统中存储的各个文件。我们可以看到CommitLog文件夹、ConsumeQueue文件夹,还有在config文件夹中Topic、Consumer的相关信息。最下面那个文件夹index存的是索引文件,这个文件用来加快消息查询的速度。

a7cc2dea73000dd67aefcd3f8071fbf7.png

- commitlog:消息存储目录。 - config:运行期间一些配置信息,主要包括下列信息。 - consumerFilter.json:主题消息过滤信息。 - consumerOffset.json:集群消费模式消息消费进度。 - delayOffset.json:延时消息队列拉取进度。 - subscriptionGroup:消息消费组配置信息。 - topic.json:topic配置属性。 - consumequeue:消息消费队列存储目录。 - index:消息索引文件存储目录。 - abort:如果存在 abort文件说明Broker非正常关闭,该文件默认启动时创建,正常退出之前删除。 - checkpoint:文件检测点存储commitlog文件最后一次刷盘时间戳、consumequeue最后一次刷盘时间、index索引文件最后一次刷盘时间戳。

存储流程

  1. 如果当前Broker停止工作或Broker为SLAVE角色或当前Broker不支持写入则拒绝消息写入;如果消息主题长度超过256个字符、消息属性长度超过65536个字符将拒绝该消息写入。
  2. 如果消息的延迟级别大于0,将消息的原主题名称与原消息队列ID存入消息属性中,用延迟消息主题SCHEDULE_TOPIC、消息队列ID更新原先消息的主题与队列。
  3. 获取当前可以写入的CommitLog文件
  4. 在写入CommitLog之前,先申请putMessageLock,也就是将消息存储到CommitLog文件中是串行的
  5. 设置消息的存储时间,如果CommitLog文件不存在就需要创建新的文件
  6. 创建全局唯一消息ID
  7. 获取该消息在消息队列的偏移量
  8. 计算消息总长度,并写入CommitLog
  9. 如果计算发现CommitLog无法存储所有内容,则创建新的CommitLog,文件名为即将插入消息的偏移
  10. 将消息内容写入CommitLog文件后根据配置进行同步或者异步刷盘
  11. 更新逻辑偏移量,并释放putMessageLock
  12. 根据CommitLog偏移量,消息存储大小,tag的hash值插入一条Message到ConsumeQueue
  13. 根据Key的hash值,CommitLog偏移量,插入一条数据到IndexFile
  14. ConsumerQueue每隔一段时间自动刷盘、IndexFile在每次创建新indexFile时刷盘之前的索引文件、checkpoint文件在刷盘ConsumeQueue和IndexFile时进行更新

1b3fc99cae2391d55b36c8d3bb209aef.png

内存映射

RocketMQ通过使用内存映射文件来提高IO访问性能,无论是CommitLog、 ConsumeQueue还是IndexFile,单个文件都被设计为固定长度,如果一个文件写满以后再创建一个新文件,文件名就为该文件第一条消息对应的全局物理偏移量。例如CommitLog的文件组织方式如下图所示。

3d0262e9d6200a8e2c821880d7f2bd3e.png

RocketMQ使用 MappedFile、 MappedFileQueue来封装存储文件。

1ca141ba32ec2aeb610a450721b7cff0.png

MappedFileQueue

MappedFileQueue是MappedFile的管理容器,MappedFileQueue是对存储目录的封装。MappedFileQueue类的核心属性如下:

private final String storePath; // 存储目录
private final int mappedFileSize; // 单个文件的存储大小
private final CopyOnWriteArrayList<MappedFile> mappedFiles = new CopyOnWriteArrayList<MappedFile>(); //MappedFile文件集合
private final AllocateMappedFileService allocateMappedFileService; // 创建MappedFile服务类
private long flushedWhere = 0; // 当前刷盘指针,表示该指针之前的所有数据全部持久化到磁盘
private long committedWhere = 0; // 当前数据提交指针,内存中ByteBuffer当前的写指针,该值大于等于flushedWhere
private volatile long storeTimestamp = 0; // 刷盘时间戳

知道了MappedFileQueue的存储内容之后,让我们来看看通过它,我们都能做什么。 通过时间查找消息所在的文件 从MappedFile列表中第一个文件开始查找,找到第一个最后一次更新时间大于待查找时间戳的文件,如果不存在,则返回最后一个MappedFile文件。 通过偏移量查找消息所在的文件 因为RocketMQ会定时清除过期的数据,所以第一个MappedFile对应的偏移量不一定是00000000000000000000,所以根据偏移量计算文件位置的算法为:查找偏移量/单个文件的大小 - 第一个文件的起始偏移量/单个文件的大小

(int)((offset / mappedFileSize) - (getFirstMappedFile().getFileFromOffset() / this.mappedFileSize));

MappedFile

MappedFile是RocketMQ内存映射文件的具体实现,其核心属性如下:

// 操作系统每页大小,默认4k
public static final int OS_PAGE_SIZE = 1024 * 4; 
// 当前JVM实例中MappedFile虚拟内存 
private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0); 
// 当前JVM实例中MappedFile对象个数
private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0); 
// 当前该文件的写指针,从0开始(内存映射文件中的写指针)
protected final AtomicInteger wrotePosition = new AtomicInteger(0); 
// 当前文件的提交指针,如果开启transientStorePoolEnable,则数据会存储在TransientStorePool中,然后提交到内存映射ByteBuffer中,再刷写到磁盘。
protected final AtomicInteger committedPosition = new AtomicInteger(0); 
// 刷写到磁盘指针,该指针之前的数据持久化到磁盘中
private final AtomicInteger flushedPosition = new AtomicInteger(0); 
// 文件大小
protected int fileSize; 
// 文件通道 
protected FileChannel fileChannel;
/**
 * Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
 */
// 堆外内存ByteBuffer,如果不为空,数据首先将存储在该Buffer中,然后提交到MappedFile对应的内存映射文件Buffer。transientStorePoolEnable为true时不为空。
protected ByteBuffer writeBuffer = null;
// 堆外内存池,transientStorePoolEnable为true时启用。
protected TransientStorePool transientStorePool = null;
// 文件名称 
private String fileName;
// 该文件的初始偏移量
private long fileFromOffset;
// 物理文件
private File file;
// 物理文件对应的内存映射Buffer
private MappedByteBuffer mappedByteBuffer;
// 文件最后一次内容写入时间
private volatile long storeTimestamp = 0;
// 是否是MappedFileQueue队列中第一个文件
private boolean firstCreateInQueue = false;

在详细介绍RocketMQ的MappedFile之前,我们先插播一段关于MappedByteBuffer的介绍,它是RocketMQ实现内存映射的关键,也是Java官方给出的内存映射方案。

MappedByteBuffer

在深入MappedByteBuffer之前,先看看计算机内存管理的几个术语: - MMC:CPU的内存管理单元。 - 物理内存:即内存条的内存空间。 - 虚拟内存:计算机系统内存管理的一种技术。它使得应用程序认为它拥有连续的可用的内存(一个连续完整的地址空间),而实际上,它通常是被分隔成多个物理内存碎片,还有部分暂时存储在外部磁盘存储器上,在需要时进行数据交换。 - 页面文件:物理内存被占满后,将暂时不用的数据移动到硬盘上。 - 缺页中断:当程序试图访问已映射在虚拟地址空间中但未被加载至物理内存的一个分页时,由MMC发出的中断。如果操作系统判断此次访问是有效的,则尝试将相关的页从虚拟内存文件中载入物理内存。

如果正在运行的一个进程,它所需的内存是有可能大于内存条容量之和的,如内存条是256M,程序却要创建一个2G的数据区,那么所有数据不可能都加载到内存(物理内存),必然有数据要放到其他介质中(比如硬盘),待进程需要访问那部分数据时,再调度进入物理内存。

假设你的计算机是32位,那么它的地址总线是32位的,也就是它可以寻址0xFFFFFFFF(4G)的地址空间,但如果你的计算机只有256M的物理内存0x0FFFFFFF(256M),同时你的进程产生了一个不在这256M地址空间中的地址,那么计算机该如何处理呢?

计算机会对虚拟内存地址空间(32位为4G)进行分页,从而产生页(page),对物理内存地址空间(假设256M)进行分页产生页帧(page frame),页和页帧的大小一样,所以虚拟内存页的个数势必要大于物理内存页帧的个数。在计算机上有一个页表(page table),就是映射虚拟内存页到物理内存页的,更确切的说是页号到页帧号的映射,而且是一对一的映射。

那么问题来了,虚拟内存页的个数 > 物理内存页帧的个数,岂不是有些虚拟内存页的地址永远没有对应的物理内存地址空间?不是的,操作系统是这样处理的:如果要用的页没有找到,操作系统会触发一个页面失效(page fault)功能,操作系统找到一个最少使用的页帧,使之失效,并把它写入磁盘,随后把需要访问的页放到页帧中,并修改页表中的映射,保证了所有的页都会被调度。

FileChannel提供了map方法把文件映射到虚拟内存:

// 只保留了核心代码
public MappedByteBuffer map(MapMode mode, long position, long size)  throws IOException {
    // allocationGranularity一般等于64K,它是虚拟内存的分配粒度,由操作系统指定
    // 这里将position与分配粒度取余,然后真实映射起始位置为mapPosition = position-pagePosition,position 是参数指定的 position,pagePosition是根据内存分配粒度取余的结果,最终算出映射起始地址,这样算是为了内存对齐
    // 这样无论position为多少,得出的各个MappedByteBuffer实例之间的内存都是成块对齐的
    // 对齐的好处:如果两个不同的MappedByteBuffer,即便它们的position不同,但是只要它们有公共映射区域的话,这些公共区域在物理内存上的分页会被共享
    // 如果它们的MapMode是PRIVATE的话,那么会copy-on-write的方式来对修改内容进行私有化
    // 而如果它们的MapMode是SHARED的话,那么对映射的修改,其他实例均可见
    // 实际上,上述的过程都是内核来做的,我们要做的只是调用map0时将对齐好的position输入即可,这实际上是map0下层使用的mmap系统调用的约束
    int pagePosition = (int)(position % allocationGranularity);
    long mapPosition = position - pagePosition;
    long mapSize = size + pagePosition;
    try {
        addr = map0(imode, mapPosition, mapSize);
    } catch (OutOfMemoryError x) {
        System.gc();
        try {
            Thread.sleep(100);
        } catch (InterruptedException y) {
            Thread.currentThread().interrupt();
        }
        try {
            addr = map0(imode, mapPosition, mapSize);
        } catch (OutOfMemoryError y) {
            // After a second OOME, fail
            throw new IOException("Map failed", y);
        }
    }
    int isize = (int)size;
    Unmapper um = new Unmapper(addr, mapSize, isize, mfd);
    if ((!writable) || (imode == MAP_RO)) {
        return Util.newMappedByteBufferR(isize,
                                         addr + pagePosition,
                                         mfd,
                                         um);
    } else {
        return Util.newMappedByteBuffer(isize,
                                        addr + pagePosition,
                                        mfd,
                                        um);
    }
}

上述代码可以看出: 1. map通过native函数map0完成文件的映射工作,下层使用系统调用mmap 2. 如果第一次文件映射导致OOM,则手动触发垃圾回收,休眠100ms后再次尝试映射,如果失败,则抛出异常。 3. 如果映射成功,会得到虚拟内存地址address 4. 根据得到的虚拟内存地址,通过newMappedByteBuffer方法初始化MappedByteBuffer实例,其最终返回的是DirectByteBuffer,如下就是从内存地址生成DirectByteBuffer实例的过程。

static MappedByteBuffer newMappedByteBuffer(int size, long addr, FileDescriptor fd, Runnable unmapper) {
    MappedByteBuffer dbb;
    if (directByteBufferConstructor == null)
        initDBBConstructor();
    dbb = (MappedByteBuffer)directByteBufferConstructor.newInstance(
          new Object[] { new Integer(size),
                         new Long(addr),
                         fd,
                         unmapper }
    return dbb;
}
// 访问权限
private static void initDBBConstructor() {
    AccessController.doPrivileged(new PrivilegedAction<Void>() {
        public Void run() {
            Class<?> cl = Class.forName("java.nio.DirectByteBuffer");
                Constructor<?> ctor = cl.getDeclaredConstructor(
                    new Class<?>[] { int.class,
                                     long.class,
                                     FileDescriptor.class,
                                     Runnable.class });
                ctor.setAccessible(true);
                directByteBufferConstructor = ctor;
        }});
}

由于FileChannelImpl和DirectByteBuffer不在同一个包中,所以有权限访问问题,通过AccessController类获取DirectByteBuffer的构造器进行实例化。

map0()函数返回一个虚拟内存地址address,这样就无需调用read或write方法对文件进行读写,通过address就能够操作文件。底层采用unsafe.getByte方法,通过(address + 偏移量)获取指定内存的数据。 - 第一次访问address所指向的内存区域,导致缺页中断,中断响应函数会在交换区中查找相对应的页面,如果找不到(也就是该文件从来没有被读入内存的情况),则从硬盘上将文件指定页读取到物理内存中(非jvm堆内存)。 - 如果在拷贝数据时,发现物理内存不够用,则会通过虚拟内存机制(swap)将暂时不用的物理页面交换到硬盘的虚拟内存中。

MappedByteBuffer的效率之所以比read/write高,主要是因为read/write过程会涉及到用户内存拷贝到内核缓冲区,而MappedByteBuffer在发生缺页中断时,直接将硬盘内容拷贝到了用户内存,这也就是我们所说的零拷贝技术。所以,采用内存映射的读写效率要比传统的read/write性能高。

MappedByteBuffer使用虚拟内存,因此分配(map)的内存大小不受JVM的-Xmx参数限制,但是也是有大小限制的。如果当文件超出大小限制Integer.MAX_VALUE时,可以通过position参数重新map文件后面的内容。

至此,我们已经了解了文件内存映射的技术,既然Java已经提供了内存映射的方案,还有MappedFile什么事呢?这一层封装又有何意义呢?接下来再回到MappedFile的介绍中来,我将详细介绍RocketMQ的MappedFile都对原生内存映射方案做了哪些增强。

初始化

在不开启RocketMQ的内存映射增强方案时,它会规规矩矩地使用Java的MappedByteBuffer。

this.fileName = fileName;
this.fileSize = fileSize;
this.file = new File(fileName);
// 文件名即是起始偏移量
this.fileFromOffset = Long.parseLong(this.file.getName());
ensureDirOK(this.file.getParent());
// 通过RandomAccessFile构建NIO Channel然后通过Channel::map获得mappedByteBuffer,这就是文件内存映射,
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(fileSize);
TOTAL_MAPPED_FILES.incrementAndGet();

通过RandomAccessFile创建读写文件通道,并将文件内容使用NIO的内存映射将文件映射到内存中,最后得到的就是MappedByteBuffer实例。随后介绍数据存储的时候,你就会发现在不开启RocketMQ内存映射优化时,它都是对mappedByteBuffer进行写入和刷盘。

我们知道,MappedByteBuffer已经很快了,已经是零拷贝了,还有什么可以优化的呢?在前面对MappedByteBuffer的介绍中,我们知道它实际上使用的是虚拟内存,当虚拟内存的使用超过物理内存大小时,势必会造成内存交换,这就会导致在内存使用的过程中进行磁盘IO,而且它不一定是顺序磁盘IO,所以会很慢。而且虚拟内存的交换是由操作系统控制的,系统中的其他进程活动,也会触发RocketMQ内存映射的内存交换。此外,因为文件内存映射的写入过程实际上是写入 PageCache,这就涉及到 PageCache 的锁竞争,而如果直接写入内存的话就不存在该竞争,在异步刷盘的场景下可以达到更快的速度。综上RocketMQ就对其进行了优化,该优化使用transientStorePoolEnable参数控制。

如果transientStorePoolEnable为true,则初始化MappedFile的writeBuffer,该buffer从transientStorePool中获取。

this.writeBuffer = transientStorePool.borrowBuffer();
this.transientStorePool = transientStorePool;

那么TransientStorePool中拿到的buffer和MappedByteBuffer又有什么区别呢?这就得看看transientStorePool的代码了。

// TransientStorePool初始化过程
public void init() {
    for (int i = 0; i < poolSize; i++) {
        ByteBuffer byteBuffer = ByteBuffer.allocateDirect(fileSize);
        final long address = ((DirectBuffer) byteBuffer).address();
        Pointer pointer = new Pointer(address);
        LibC.INSTANCE.mlock(pointer, new NativeLong(fileSize)); // 加锁后,该内存就不会发生交换
        availableBuffers.offer(byteBuffer);
    }
}

从的代码,我们可以看出该内存池的内存实际上用的也是直接内存,把要存储的数据先存入该buffer中,然后再需要刷盘的时候,将该buffer的数据传入FileChannel,这样就和MappedByteBuffer一样能做到零拷贝了。除此之外,该Buffer还使用了com.sun.jna.Library类库将该批内存锁定,避免被置换到交换区,提高存储性能。

至此,我们已经知道了RocketMQ根据配置的不同,可能会使用来自TransientStorePool的writeBuffer或者MappedByteBuffer来存储数据,接下来,我们就来看一看存储数据的过程是如何实现的。

MappedFile插入数据

这里所指的插入数据,是在内存层面将要存储的数据加入到MappedFile的Buffer中,核心实现逻辑在appendMessagesInner: ```java public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb) { assert messageExt != null; assert cb != null;

int currentPos = this.wrotePosition.get();

 if (currentPos < this.fileSize) {
     ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
     byteBuffer.position(currentPos);
     AppendMessageResult result;
     if (messageExt instanceof MessageExtBrokerInner) {
         result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBrokerInner) messageExt);
     } else if (messageExt instanceof MessageExtBatch) {
         result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos, (MessageExtBatch) messageExt);
     } else {
         return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);
     }
     this.wrotePosition.addAndGet(result.getWroteBytes());
     this.storeTimestamp = result.getStoreTimestamp();
     return result;
 }
 log.error("MappedFile.appendMessage return null, wrotePosition: {} fileSize: {}", currentPos, this.fileSize);
 return new AppendMessageResult(AppendMessageStatus.UNKNOWN_ERROR);

} ``` 从第八行我们可以看到,如果writeBuffer不为空,说明使用了TransientStorePool,则使用writeBuffer作为写入时使用的buffer,否则使用mappedByteBuffer。然后根据当前的写指针wrotePosition设置buffer的position,而实际的写入过程在AppendMessageCallback::doAppend中。写入完成后更新写指针wrotePosition和存储时间戳。

slice() 方法创建一个共享缓存区,与原先的ByteBuffer共享内存但维护一套独立的指针(position、mark、limit)。

MappedFile提交

MappedFile提交实际上是将writeBuffer中的数据,传入FileChannel,所以只有在transientStorePoolEnable为true时才有实际作用:

public int commit(final int commitLeastPages) {
    if (writeBuffer == null) {
        //no need to commit data to file channel, so just regard wrotePosition as committedPosition.
        return this.wrotePosition.get();
    }
    if (this.isAbleToCommit(commitLeastPages)) {
        if (this.hold()) {
            commit0(commitLeastPages);
            this.release();
        } else {
            log.warn("in commit, hold failed, commit offset = " + this.committedPosition.get());
        }
    }
    // All dirty data has been committed to FileChannel.
    if (writeBuffer != null && this.transientStorePool != null && this.fileSize == this.committedPosition.get()) {
        this.transientStorePool.returnBuffer(writeBuffer);
        this.writeBuffer = null;
    }
    return this.committedPosition.get();
}

commitLeastPagesTransientStorePool为本次提交最小的页数,如果待提交数据不满commitLeastPages,则不执行本次提交操作,待下次提交。writeBuffer如果为空,直接返回wrotePosition指针,无须执行commit操作,正如前面所说,commit操作主体是writeBuffer。

private boolean isAbleToFlush(final int flushLeastPages) {
    int flush = this.flushedPosition.get();
    int write = getReadPosition();
    if (this.isFull()) {
        return true;
    }
    if (flushLeastPages > 0) {
        return ((write / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE)) >= flushLeastPages;
    }
    return write > flush;
}

判断是否执行commit操作。如果文件己满返回true;如果commitLeastPages大于0, 则比较wrotePosition(当前writeBuffer的写指针)与上一次提交的指针(committedPosition)的差值,除以OS_PAGE_SIZE得到当前脏页的数量,如果大于commitLeastPages则返回true;如果commitLeastPages小于0表示只要存在脏页就提交。

protected void commit0(final int commitLeastPages) {
    int writePos = this.wrotePosition.get();
    int lastCommittedPosition = this.committedPosition.get();
    if (writePos - this.committedPosition.get() > 0) {
        try {
            ByteBuffer byteBuffer = writeBuffer.slice();
            byteBuffer.position(lastCommittedPosition);
            byteBuffer.limit(writePos);
            this.fileChannel.position(lastCommittedPosition);
            this.fileChannel.write(byteBuffer);
            this.committedPosition.set(writePos);
        } catch (Throwable e) {
            log.error("Error occurred when commit data to FileChannel.", e);
        }
    }
}

具体的提交实现。首先创建writeBuffer的共享缓存区,然后将新创建的buffer position回退到上一次提交的位置(committedPosition),设置limit为wrotePosition(当前最大有效数据指针),然后把committedPosition到wrotePosition的数据复制(写入)到FileChannel中,然后更新committedPosition指针为wrotePosition,commit的作用就是将writeBuffer中的数据提交到文件通道FileChannel中,CommitLog在采用异步存储方式时,会有一个后台任务循环的进行commit操作,如果进行同步存储,也会主动调用MappedFile的commit,随后再调用flush刷盘。

MappedFile刷盘

刷盘指的是将内存中的数据刷写到磁盘,永久存储在磁盘中,其具体实现由MappedFile的flush方法实现,如下所示。

public int flush(final int flushLeastPages) {
    if (this.isAbleToFlush(flushLeastPages)) {
        if (this.hold()) {
            int value = getReadPosition();

            try {
                //We only append data to fileChannel or mappedByteBuffer, never both.
                if (writeBuffer != null || this.fileChannel.position() != 0) {
                    this.fileChannel.force(false);
                } else {
                    this.mappedByteBuffer.force();
                }
            } catch (Throwable e) {
                log.error("Error occurred when force data to disk.", e);
            }

            this.flushedPosition.set(value);
            this.release();
        } else {
            log.warn("in flush, hold failed, flush offset = " + this.flushedPosition.get());
            this.flushedPosition.set(getReadPosition());
        }
    }
    return this.getFlushedPosition();
}

flush函数和commit一样也可以传入一个刷盘页数,当脏页数量达到要求时,会进行刷盘操作,如果使用writeBuffer存储的话则调用fileChannel的force将内存中的数据持久化到磁盘,刷盘结束后,flushedPosition会等于committedPosition,否则调用mappedByteBuffer的force,最后flushedPosition会等于writePosition。

我们不妨分析一下wrotePosition,committedPosition,flushedPosition的关系,当有新的数据要写入时,先会写入内存,然后writePosition代表的就是内存写入的末尾,commit过程只有transientStorePoolEnable为true时才有意义,代表的是从writeBuffer拷贝到FileChannel时,拷贝数据的末尾,而flushedPosition则代表将内存数据刷盘到物理磁盘的末尾。

综上所述,我们可以得到一个关于这三个position之间的关系: - transientStorePoolEnable: flushedPosition<=committedPosition<=wrotePosition - MappedByteBuffer only: flushedPosition<=wrotePosition

获取MappedFile最大读指针

RocketMQ文件的一个组织方式是内存映射文件,预先申请一块连续的固定大小的内存,需要一套指针标识当前最大有效数据的位置,获取最大有效数据偏移量的方法由MappedFile的getReadPosition方法实现,如下所示。

/**
 * @return The max position which have valid data
 */
public int getReadPosition() {
    return this.writeBuffer == null ? this.wrotePosition.get() : this.committedPosition.get();
}

获取当前文件最大的可读指针。如果 writeBuffer 为空,则直接返回当前的写指针;如果 writeBuffer 不为空,则返回上一次提交的指针。在 MappedFile 设计中,只有提交了的数据(写入到 MappedByteBuffer 或 FileChannel 中的数据)才是安全的数据。为什么没刷盘之前也认为是安全数据呢,这就和 MappedByteBuffer 和 FileChannel 的写入机制有关了,无论是 MappedByteBuffer 还是 FileChannel 在写入数据时,实际上只是将数据写入 PageCache,而操作系统会自动的将脏页刷盘,这层 PageCache 就是我们应用和物理存储之间的夹层,当我们将数据写入 PageCache 后,即便我们的应用崩溃了,但是只要系统不崩溃,最终也会将数据刷入磁盘。所以,RocketMQ 以写入 PageCache 作为数据安全可读的判断标准。

读取数据

RocketMQ 在读数据时,使用的是 MappedByteBuffer,并且以最大读指针作为可读数据的末尾。之所以使用MappedByteBuffer而不是FileChannel主要是因为它更快,这一点在后面的各种流速度对比中就能看到。

public SelectMappedBufferResult selectMappedBuffer(int pos, int size) {
    int readPosition = getReadPosition();
    if ((pos + size) <= readPosition) {
        if (this.hold()) {
            ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
            byteBuffer.position(pos);
            ByteBuffer byteBufferNew = byteBuffer.slice();
            byteBufferNew.limit(size);
            return new SelectMappedBufferResult(this.fileFromOffset + pos, byteBufferNew, size, this);
        } else {
            log.warn("matched, but hold failed, request pos: " + pos + ", fileFromOffset: "
                + this.fileFromOffset);
        }
    } else {
        log.warn("selectMappedBuffer request pos invalid, request pos: " + pos + ", size: " + size
            + ", fileFromOffset: " + this.fileFromOffset);
    }
    return null;
}

MappedFile销毁

为了保证 MappedFile 在销毁的时候,不对正在进行的读写造成影响,所以 MappedFile 实际上还是一个计数引用资源,每当要进行读写操作时,都需要调用其 hold 函数,当使用完成后需要主动调用 release 函数释放资源。

// ReferenceResource
// 默认引用数为1,当需要销毁时调用release将其减为0,最后释放资源
protected final AtomicLong refCount = new AtomicLong(1);
// 标识资源是否可用(未被销毁)
protected volatile boolean available = true;
// 每当持有资源时,引用数加一,如果发现已经不可用就回退,这里用双层检验保证线程安全:1.isAvailable()2.this.refCount.getAndIncrement() > 0
public synchronized boolean hold() {
    if (this.isAvailable()) {
        if (this.refCount.getAndIncrement() > 0) {
            return true;
        } else {
            this.refCount.getAndDecrement();
        }
    }
    return false;
}
// 释放资源,如果引用数小于0,则开始销毁逻辑
public void release() {
    long value = this.refCount.decrementAndGet();
    if (value > 0)
        return;
    synchronized (this) {
        this.cleanupOver = this.cleanup(value);
    }
}
// 主动触发销毁过程,实际上会调用 release 函数来进行销毁,这里如果销毁失败,会在每次尝试销毁时,按照一定的时间间隔,将引用数-1000来强制进行销毁。
public void shutdown(final long intervalForcibly) {
    if (this.available) {
        this.available = false;
        this.firstShutdownTimestamp = System.currentTimeMillis();
        this.release();
    } else if (this.getRefCount() > 0) {
        if ((System.currentTimeMillis() - this.firstShutdownTimestamp) >= intervalForcibly) {
            this.refCount.set(-1000 - this.getRefCount());
            this.release();
        }
    }
}

MappedFile 的销毁就是通过调用 ReferenceResource 的shutdown来实现的,实际上 MappedFile 是 ReferenceResource 的子类,并实现了其 cleanup 函数。综上所述,MappedFile 的销毁过程就是:MappedFile::destroy -> ReferenceResource::shutdown -> ReferenceResource::release -> MappedFile::cleanup。

public boolean destroy(final long intervalForcibly) {
    this.shutdown(intervalForcibly);
    if (this.isCleanupOver()) {
        try {
            this.fileChannel.close();
            log.info("close file channel " + this.fileName + " OK");
            long beginTime = System.currentTimeMillis();
            boolean result = this.file.delete();
            log.info("delete file[REF:" + this.getRefCount() + "] " + this.fileName
                + (result ? " OK, " : " Failed, ") + "W:" + this.getWrotePosition() + " M:"
                + this.getFlushedPosition() + ", "
                + UtilAll.computeElapsedTimeMilliseconds(beginTime));
        } catch (Exception e) {
            log.warn("close file channel " + this.fileName + " Failed. ", e);
        }
        return true;
    } else {
        log.warn("destroy mapped file[REF:" + this.getRefCount() + "] " + this.fileName
            + " Failed. cleanupOver: " + this.cleanupOver);
    }
    return false;
}

MappedByteBuffer 的释放过程实际上有些诡异,Java官方没有提供公共的方法来进行 MappedByteBuffer 的回收,所以不得不通过反射来进行回收,这也是 MappedByteBuffer 比较坑的一点,我们不妨简单看下 MappedFile 的 cleanup 逻辑。

public boolean cleanup(final long currentRef) {
    if (this.isAvailable()) {
        log.error("this file[REF:" + currentRef + "] " + this.fileName
            + " have not shutdown, stop unmapping.");
        return false;
    }
    if (this.isCleanupOver()) {
        log.error("this file[REF:" + currentRef + "] " + this.fileName
            + " have cleanup, do not do it again.");
        return true;
    }
    clean(this.mappedByteBuffer);
    TOTAL_MAPPED_VIRTUAL_MEMORY.addAndGet(this.fileSize * (-1));
    TOTAL_MAPPED_FILES.decrementAndGet();
    log.info("unmap file[REF:" + currentRef + "] " + this.fileName + " OK");
    return true;
}
public static void clean(final ByteBuffer buffer) {
    if (buffer == null || !buffer.isDirect() || buffer.capacity() == 0)
        return;
    invoke(invoke(viewed(buffer), "cleaner"), "clean");
}

private static Object invoke(final Object target, final String methodName, final Class<?>... args) {
    return AccessController.doPrivileged(new PrivilegedAction<Object>() {
        public Object run() {
            try {
                Method method = method(target, methodName, args);
                method.setAccessible(true);
                return method.invoke(target);
            } catch (Exception e) {
                throw new IllegalStateException(e);
            }
        }
    });
}

private static Method method(Object target, String methodName, Class<?>[] args)
    throws NoSuchMethodException {
    try {
        return target.getClass().getMethod(methodName, args);
    } catch (NoSuchMethodException e) {
        return target.getClass().getDeclaredMethod(methodName, args);
    }
}

private static ByteBuffer viewed(ByteBuffer buffer) {
    String methodName = "viewedBuffer";
    Method[] methods = buffer.getClass().getMethods();
    for (int i = 0; i < methods.length; i++) {
        if (methods[i].getName().equals("attachment")) {
            methodName = "attachment";
            break;
        }
    }

    ByteBuffer viewedBuffer = (ByteBuffer) invoke(buffer, methodName);
    if (viewedBuffer == null)
        return buffer;
    else
        return viewed(viewedBuffer);
}

从上面的代码中我们可以看出 cleanup 先是进行了一些验证,然后就通过多个反射过程进行 MappedByteBuffer 的回收。

对比测试

看完了内存映射和 FileChannel 的使用,我不禁有一个疑问,它们到底哪个更快呢?自己的RocketMQ集群应该使用哪种策略呢?于是找到了别人做的测试,该测试的环境如下: - CPU:intel i7 4核8线程 4.2GHz - 内存:40GB DDR4 - 磁盘:SSD 读写 2GB/s 左右 - JDK:1.8 - OS:Mac OS 10.13.6 - 虚拟内存:9GB

测试注意点: - 为了防止 PageCache 缓存的影响,每次都生成一个新的文件进行读取。 - 为了测试不同数据包对性能的影响,需要使用不同大小的数据包进行多次测试。 - force 对性能影响很大,应该单独测试。 - 使用 1GB 文件进行测试(小文件没有参考意义,大文件 mmap 无法映射)

该测试是在Mac上进行的,在此只做参考使用,在实际部署RocketMQ生产集群时,还应根据实际部署物理机情况进行更深入的测试,最终决定是否开启transientStorePoolEnable。

2004cd8060339534ed1d583a0be90243.png

上图是读测试,从这张图里,我们看到,mmap 性能完胜,特别是在小数据量的情况下。其他的流,只有在读 4kb 的情况下,其他流才开始反杀 mmap。因此,读 4kb 以下的数据,mmap 更优。而消息队列中存储的消息,一般来说都是比较小的,而且RocketMQ限制了消息的最大长度为4M。基于这样的读数据场景,RocketMQ在读数据时,直接使用的是MappedByteBuffer(MMAP),这种选择和这次测试的效果是对应上的。

789b852ad81d74f83e4737be4fbb6000.png

接下来看一下直接写的测试,64字节 是 FileChannel 和 mmap 性能的分水岭,从64字节开始,FileChannel 一路反杀,直到 1GB 文件稍稍输了一丢丢。图中我们可以看到,在存储块的大小为16K时,FileChannel 的 write 效率最高,不知道大家还记不记得,前面在介绍 MappedFile 的 commit 函数时,说过 commit 函数有一个 commitLeastPages,当内存中的数据大于设定的页数(一页4K)时,才会将内存数据写入FileChannel。根据图中的数据,我们可以大胆的估计,这个 commitLeastPages 等于 4 时,效率应该是最高的。然后我们回到 RocketMQ的代码中来,我们会发现 RocketMQ 中 commitCommitLogLeastPages 的默认值就是 4。可见,RocketMQ 的默认设定很可能就是根据实际测试情况调优过的。

此外,我还找到了另一个同学的测试,它测试了SSD云盘的写入效率:

84b61d5866fba34e26a80c506964fa02.png

从图中,我们可以看出当数据块大小大于 16K 时,IO 吞吐量开始接近饱和。这两次测试都是在 SSD 上进行的,它们都不约而同的在写入块为 16K 时达到了极高的写入效率。我猜测,它们使用的 SSD 页大小就是 16K。之前在总结MySQL时,看到有些 SSD 的页大小是 16K,从而达到 16K 的原子写,这样就和 InnoDB 的页大小一致,从而可以省略双写过程。扯的有点远了,让我们再回到测试中来,看看异步刷盘的效率。

1595ec86d460502018465dc8f5307e3d.png

从这张图中,我们可以看到MMAP的效率就没有FileChannel高了,此外,FileChannel 又是在写入块大小为16K时,达到了最高效率。和之前一样,我们可以大胆假设异步刷盘过程很可能也是出现 4 个脏页(4 * 4K = 16K)时,RocketMQ才会进行异步刷盘。再次,回到RocketMQ的代码中来,很快我就发现其默认刷盘页数 flushCommitLogLeastPages 就是 4。

到此为止,本节的测试和 RocketMQ 的默认策略就全都对上了,从读数据采用MMAP技术,再到使用 transientStorePool 异步写入的时机(16K 脏数据),再到异步刷盘的时机(16K 脏页),RocketMQ 在性能优化的路上,真可谓不遗余力,不愧是扛起数次双11高压的核心中间件。

文件详解

了解完了文件存储部分使用的核心技术,在让我们回到 RocketMQ 文件组织的讨论中来,接下来我将挨个分析各个核心文件存储的内容和使用方法。

Commit文件

commitlog 目录的组织方式在前面已经详细介绍过了,该目录下的文件主要存储消息,其特点是每一条消息长度不相同,CommitLog 文件存储的逻辑视图如下图所示,每条消息的前面4个字节存储该条消息的总长度。整个 CommitLog 文件默认大小为 1G。

467f6f798eb19f784e73ac1c82397a06.png

在查找消息时,需要先根据要查找的消息偏移找到消息所在的文件,然后根据消息偏移与文件大小取余,得到消息在文件中的位置,最后根据消息大小,取出指定长度的消息内容。

public SelectMappedBufferResult getMessage(final long offset, final int size) {
    int mappedFileSize = this.defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog();
    MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset, offset == 0);
    if (mappedFile != null) {
        int pos = (int) (offset % mappedFileSize);
        return mappedFile.selectMappedBuffer(pos, size);
    }
    return null;
}

ConsumeQueue文件

RocketMQ 基于主题订阅模式实现消息消费,消费者关心的是一个主题下的所有消息,但由于同一主题的消息不连续地存储在 CommitLog 文件中,试想一下如果消息消费者直接从消息存储文件(CommitLog)中去遍历查找订阅主题下的消息,效率将极其低下,RocketMQ 为了适应消息消费的检索需求,设计了消息消费队列文件(ConsumeQueue),该文件可以看成是 CommitLog 关于消息消费的“索引”文件,消息主题,第二级目录为主题的消息队列。

a7cc2dea73000dd67aefcd3f8071fbf7.png

为了加快检索速度,并且减少空间使用,ConsumeQueue 不会存储所有消息正文,只会存储如下内容:

a51b90cbc57bad2981c7bc21575958a8.png

单个 ConsumeQueue 文件中默认包含 30 万个条目,单个文件的长度为 30w × 20 ≈ 6M 字节, 单个 ConsumeQueue 文件可以看出是一个 ConsumeQueue 条目的数组,其下标为 ConsumeQueue 的逻辑偏移量,消息消费进度存储的偏移量即逻辑偏移量。 ConsumeQueue 即为 CommitLog 文件的索引文件, 其构建机制是当消息到达 CommitLog 文件后, 由专门的线程产生消息转发任务,从而构建消息消费队列文件与下文提到的索引文件。

public SelectMappedBufferResult getIndexBuffer(/*消息索引*/final long startIndex) {
    int mappedFileSize = this.mappedFileSize;
    // 根据消息索引 * 20 得到在 ConsumeQueue 中的物理偏移
    long offset = startIndex * CQ_STORE_UNIT_SIZE;
    if (offset >= this.getMinLogicOffset()) {
        // 找到物理索引所在的文件
        MappedFile mappedFile = this.mappedFileQueue.findMappedFileByOffset(offset);
        if (mappedFile != null) {
            // 物理索引与文件大小取余,得到数据存储的位置,然后通过MappedByteBuffer的到内存映射Buffer
            SelectMappedBufferResult result = mappedFile.selectMappedBuffer((int) (offset % mappedFileSize));
            return result;
        }
    }
    return null;
}

根据 startIndex 获取准备消费的条目。首先 startIndex * 20 得到在 ConsumeQueue 中的物理偏移量,如果该 offset 小于 minLogicOffset,则返回 null,说明该消息已被删除;如果大于 minLogicOffset,则根据偏移量定位到具体的物理文件,然后通过 offset 与物理文大小取模获取在该文件的偏移量,最终的到从 startIndex 开始,到该 ConsumeQueue 有效结尾的所有数据对应的 MappedByteBuffer。

除了根据消息偏移量查找消息的功能外,RocketMQ 还提供了根据时间戳查找消息的功能,具体实现逻辑如下: 1. 首先根据时间戳定位到 ConsumeQueue 物理文件,就是从第一个文件开始找到第一个文件更新时间大于该时间戳的文件。 2. 然后对 ConsumeQueue 中的所有项,使用二分查找,查询每条记录对应的 CommitLog 的最后更新时间和要查询的时间戳 3. 最终找到与时间戳对应的 ConsumeQueue 偏移,或者离时间戳最近的消息的 ConsumeQueue 偏移

Index索引文件

消息消费队列是 RocketMQ 专门为消息订阅构建的索引文件,提高根据主题与消息队列检索消息的速度,另外 RocketMQ 引入了 Hash 索引机制为消息建立索引,HashMap 的设计包含两个基本点: Hash槽 与 Hash 冲突的链表结构。

17a57e2320c893796cf4e7aab7d9948e.png

从图中可以看出,indexFile 总共包含 IndexHeader、 Hash 槽、 Hash 条目(数据)。

IndexHeader IndexHeader头部,包含 40 个字节,记录该 IndexFile 的统计信息,其结构如下。 - beginTimestamp: 该索引文件中包含消息的最小存储时间。 - endTimestamp: 该索引文件中包含消息的最大存储时间。 - beginPhyOffset: 该索引文件中包含消息的最小物理偏移量(CommitLog 文件偏移量)。 - endPhyOffset:该索引文件中包含消息的最大物理偏移量(CommitLog 文件偏移量)。 - hashSlotCount: hashSlot个数,并不是 hash 槽使用的个数,在这里意义不大。 - indexCount: Index条目列表当前已使用的个数,Index条目在Index条目列表中按顺序存储。

Hash槽 Hash槽,一个 IndexFile 默认包含500万个 Hash 槽,每个 Hash 槽存储的是落在该 Hash 槽的 hashcode 最新的 Index 的索引。

Hash 条目 Index条目列表,默认一个索引文件包含 2000 万个条目,每一个 Index 条目结构如下。 - hashcode: key 的 hashcode。 - phyOffset: 消息对应的物理偏移量。 - timeDif:该消息存储时间与第一条消息的时间戳的差值,小于 0 该消息无效。 - preIndexNo:该条目的前一条记录的 Index 索引,当出现 hash 冲突时,构建的链表结构。

Index文件的写入步骤如下: 1. 如果当前已使用条目大于等于允许最大条目数时,则返回 false,表示当前索引文件已写满。如果当前索引文件未写满则根据 key 算出 key 的 hashcode,然后 keyHash 对 hash 槽数量取余定位到 hashcode 对应的 hash 槽下标, hashcode对应的hash槽的物理地址 = IndexHeader 头部(40字节) + 下标 * 每个 hash 槽的大小(4字节)。 2. 读取 hash 槽中存储的数据,如果 hash 槽存储的数据小于 0 或大于当前索引文件中存储的最大条目,则将该槽的值设置为 0。 3. 将条目信息存储在 IndexFile 中。 1. 计算新添加条目的起始物理偏移量,等于头部字节长度 + hash 槽数量 * 单个 hash 槽大小(4个字节) + 当前 Index 条目个数 * 单个 Index 条目大小(20个字节)。 2. 依次将 hashcode、消息物理偏移量、时间差timeDif、原来 Hash 槽的值存入该索引条目中。 3. 将新添加的索引条目索引存入 hash 槽中,覆盖原来的值。 4. 更新文件索引头信息。

public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
    // 判断是否写满了
    if (this.indexHeader.getIndexCount() < this.indexNum) {
        // 计算 hash 槽的位置
        int keyHash = indexKeyHashMethod(key);
        int slotPos = keyHash % this.hashSlotNum;
        int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
        try {
            // 获取原来槽内的值
            int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
            // <= 0 或者大于当前存储数,则认为其无效
            if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
                slotValue = invalidIndex;
            }
            // 计算时间差值
            long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
            timeDiff = timeDiff / 1000;
            if (this.indexHeader.getBeginTimestamp() <= 0) {
                timeDiff = 0;
            } else if (timeDiff > Integer.MAX_VALUE) {
                timeDiff = Integer.MAX_VALUE;
            } else if (timeDiff < 0) {
                timeDiff = 0;
            }
            // 计算索引条目的位置
            int absIndexPos =
                IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                    + this.indexHeader.getIndexCount() * indexSize;
            // 写入索引条目
            this.mappedByteBuffer.putInt(absIndexPos, keyHash);
            this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
            this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
            this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue);
            // 更新槽的值
            this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
            // 更新头部数据
            if (this.indexHeader.getIndexCount() <= 1) {
                this.indexHeader.setBeginPhyOffset(phyOffset);
                this.indexHeader.setBeginTimestamp(storeTimestamp);
            }
            this.indexHeader.incHashSlotCount();
            this.indexHeader.incIndexCount();
            this.indexHeader.setEndPhyOffset(phyOffset);
            this.indexHeader.setEndTimestamp(storeTimestamp);
            return true;
        } catch (Exception e) {
            log.error("putKey exception, Key: " + key + " KeyHashCode: " + key.hashCode(), e);
        }
    } else {
        log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
            + "; index max num = " + this.indexNum);
    }
    return false;
}

至此,索引文件的写入套路就已经介绍完了,它通过 hash 槽存储了 hash 冲突链表的头指针,然后每个索引项都保存了前一个索引项的指针,借此,在文件存储中实现了链表的数据结构。

当根据 key 查找消息时,不光可以设置要查找 key 还可以设置最大查找数量,开始时间戳,结束时间戳,操作步骤如下: 1. 根据 key 计算 hashcode,然后 keyHash 对 hash 槽数量取余定位到 hashcode 对应的 hash 槽下标。 2. 如果对应的 Hash 槽中存储的数据小于 1 或大于当前索引条目个数则表示该 HashCode 没有对应的条目,直接返回。 3. 由于会存在 hash 冲突,根据 slotValue 定位该 hash 槽最新的一个 Item 条目,将存储的物理偏移加入到 phyOffsets 中 ,然后继续验证Item条目中存储的上一个 Index 下标,如果大于等于 1 并且小于最大条目数,则继续查找,否则结束查找。 4. 根据 Index 下标定位到条目的起始物理偏移量,然后依次读取 hashcode、 物理偏移量、时间差、上一个条目的Index下标,循环步骤4。 - 如果存储的时间差小于 0,则直接结束;如果 hashcode 匹配并且消息存储时间介于待查找时间start、 end之间则将消息物理偏移量加入到phyOffsets - 验证条目的前一个 Index 索引,如果索引大于等于 1 并且小于Index条目数,则继续查找,否则结束整个查找。

具体的实现代码如下:

public void selectPhyOffset(final List<Long> phyOffsets, final String key, final int maxNum,
    final long begin, final long end) {
    if (this.mappedFile.hold()) {
        // 计算 hash 槽的位置
        int keyHash = indexKeyHashMethod(key);
        int slotPos = keyHash % this.hashSlotNum;
        int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize;
        try {
            // 获取 hash 槽内存的索引位置
            int slotValue = this.mappedByteBuffer.getInt(absSlotPos);
            // 验证合法性
            if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()
                || this.indexHeader.getIndexCount() <= 1) {
            } else {
                // 遍历索引链表
                for (int nextIndexToRead = slotValue; ; ) {
                    // 数量够了就退出
                    if (phyOffsets.size() >= maxNum) {
                        break;
                    }
                    // 找到本条索引的位置
                    int absIndexPos =
                        IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
                            + nextIndexToRead * indexSize;
                    // 读取索引条目的内容
                    int keyHashRead = this.mappedByteBuffer.getInt(absIndexPos);
                    long phyOffsetRead = this.mappedByteBuffer.getLong(absIndexPos + 4);
                    long timeDiff = (long) this.mappedByteBuffer.getInt(absIndexPos + 4 + 8);
                    int prevIndexRead = this.mappedByteBuffer.getInt(absIndexPos + 4 + 8 + 4);
                    // 验证时间合法性
                    if (timeDiff < 0) {
                        break;
                    }
                    timeDiff *= 1000L;
                    long timeRead = this.indexHeader.getBeginTimestamp() + timeDiff;
                    boolean timeMatched = (timeRead >= begin) && (timeRead <= end);
                    // 验证整体合法性
                    if (keyHash == keyHashRead && timeMatched) {
                        phyOffsets.add(phyOffsetRead);
                    }
                    // 验证链表中下一个节点的合法性,如何合法则继续循环,否则退出
                    if (prevIndexRead <= invalidIndex
                        || prevIndexRead > this.indexHeader.getIndexCount()
                        || prevIndexRead == nextIndexToRead || timeRead < begin) {
                        break;
                    }
                    nextIndexToRead = prevIndexRead;
                }
            }
        } catch (Exception e) {
            log.error("selectPhyOffset exception ", e);
        }
    }
}

CheckPoint文件

checkpoint 的作用是记录 CommitLog、ConsumeQueue、Index文件的刷盘时间点,文件固定长度为 4k,其中只用该文件的前面 24 个字节,其存储格式如下图所示。

3162fae215f8f320880751b77dd11522.png

- physicMsgTimestamp: CommitLog文件刷盘时间点。 - logicsMsgTimestamp: 消息消费队列文件刷盘时间点。 - indexMsgTimestamp: 索引文件刷盘时间点。

更新ConsumeQueue和IndexFile

消息消费队列文件、消息属性索引文件都是基于 CommitLog 文件构建的,当消息生产者提交的消息存储在 CommitLog 文件中,ConsumeQueue、IndexFile需要及时更新,否则消息无法及时被消费,根据消息属性查找消息也会出现较大延迟。RocketMQ 通过开启一个线程 ReputMessageService 来准时转发 CommitLog 文件更新事件,相应的任务处理器根据转发的消息及时更新 ConsumeQueue、IndexFile文件。

文件恢复

由于 RocketMQ 存储首先将消息全量存储在 CommitLog 文件中,然后异步生成转发任务更新 ConsumeQueue、Index 文件。如果消息成功存储到 CommitLog 文件中,转发任务未成功执行,此时消息服务器 Broker 由于某个原因宕机,导致 CommitLog、ConsumeQueue、IndexFile文件数据不一致。如果不加以人工修复的话,会有一部分消息即便在 CommitLog 文件中存在,但由于并没有转发到 ConsumeQueue,这部分消息将永远不会被消费者消费。

接下来我们看一看 RocketMQ Broker 的启动过程: 1. 判断上一次退出是否正常。 - Broker在启动时创建abort文件,在退出时通过注册 JVM 钩子函数删除 abort 文件。如果下一次启动时存在 abort 文件。 说明 Broker 是异常退出的,CommitLog 与 ConsumeQueue 数据有可能不一致,需要进行修复。 2. 加载延迟队列,RocketMQ 定时消息相关。 3. 加载所有 CommitLog 文件,如果文件大小和配置单文件大小不一致则忽略,创建好了将wrotePosition、flushedPosition, committedPosition三个指针都指向文件结尾。后面的恢复过程会将这些指针修正。 4. 加载消息 ConsumeQueue文件。与加载 CommitLog 大致相同。 5. 加载存储检测点,检测点主要记录 commitLog 文件、ConsumeQueue 文件、Index 索引文件的刷盘点。 6. 加载索引文件,如果上次异常退出,而且索引文件上次刷盘时间小于该索引文件最大的消息时间戳该文件将立即销毁。 7. 根据 Broker 是否是正常停止执行不同的恢复策略,下文将分别介绍异常停止、正常停止的文件恢复机制。 8. 恢复 ConsumeQueue 文件后,将在 CommitLog 实例中保存每个消息消费队列当前的存储逻辑偏移量,这也是消息中不仅存储主题、消息队列 ID 还存储了消息队列偏移量的关键所在。

Broker 正常停止

  1. Broker正常停止再重启时,从倒数第三个文件开始进行恢复,如果不足 3 个文件,则从第一个文件开始恢复。
  2. 从要恢复的 CommitLog 中,按照读到的消息大小读出消息正文,然后使用CRC(循环冗余校验)判断消息是否正确。
  3. 遍历 CommitLog 文件,每次取出一条消息,如果检查结果为 true 并且消息的长度大于 0 表示消息正确,校验指针移动到本条消息的末尾;如果查找结果为 true 并且消息的长度等于 0,表示已到该文件的末尾,如果还有下一个文件需要检查,则循环步骤3,否则跳出循环; 如果查找结构为 false,表明该文件未填满所有消息,跳出循环,结束遍历文件。
  4. 通过步骤 3,最终会得到一个校验通过的偏移 offset,通过它来更新 commit 指针和 flush 指针。
  5. 删除 offset 之后的所有文件。

正常停止的时,Broker 会将 IndexFile 和 ConsumeQueue 都更新好,所以如果 Broker 正常停止的话,恢复过程只是修正commit 指针和 flush 指针。

Broker 异常停止

异常文件恢复的步骤与正常停止文件恢复的流程基本相同,其主要差别有两个。首先,正常停止默认从倒数第三个文件开始进行恢复,而异常停止则需要从最后一个文件往前走,找到第一个消息存储正常的文件。其次,如果 CommitLog 目录没有消息文件,如果在消息消费队列 ConsumeQueue 目录下存在文件,则需要销毁。 如何判断一个 CommitLog 文件是正确的呢? 1. 首先判断文件的魔数 2. 如果文件中第一条消息的存储时间等于 0,则认为文件无效 3. 对比文件第一条消息的时间戳与检测点,文件第一条消息的时间戳小于文件检测点 checkpoint 说明该文件部分消息是可靠的,则从该文件开始恢复。 4. 如果根据前 3 步算法找到了合法的 CommitLog,则遍历 CommitLog 中的消息,验证消息的合法性,并将消息重新转发到消息消费队列与索引文件,这样会造成 ConsumeQueue 的冗余,这需要消息的消费者来实现幂等性。 5. 如果步骤3未找到有效 CommitLog,则设置 CommitLog 目录的 flush 指针、 commit 指针都为 0,并销毁消息消费队列文件。

异常停止时,不确定 ConsumeQueue 和 IndexFile 是否正确,所以从最后一个有效文件,重新发送 CommitLog 变动事件,从而触发 ConsumeQueue 和 IndexFile 的更新。

我认为这里有问题,这里只重发最后一个有效文件的 CommitLog 变动事件,如果倒数第二个文件的最后几条改动事件还没有被处理时,创建了新的 CommitLog 文件(最后一个 CommitLog文件)并且成功写入了数据,并刷新了 checkpoint。那么倒数第二个 CommitLog 的最后几条消息就丢失了。为此,我在官方 Github 仓库提了一个 issue,但是目前未收到回复。

而且,如果最后一个 CommitLog 几乎写完,那么也会产生很多的冗余消息,我觉得这里可以从 ConsumeQueue 的最大索引处开始,顺序恢复所有有效的 CommitLog 内容。

刷盘机制

RocketMQ 的存储与读写是基于 JDK NIO 的内存映射机制(MappedByteBuffer)的,消息存储时首先将消息追加到内存,再根据配置的刷盘策略在不同时间进行刷写磁盘。如果是同步刷盘,消息追加到内存后,将同步调用 MappedByteBuffer 的 force 方法;如果是异步刷盘,在消息追加到内存后立刻返回给消息发送端。RocketMQ 使用一个单独的线程按照某一个设定的频率执行刷盘操作。通过在 broker 配置文件中配置 flushDiskType 来设定刷盘方式,可选值为 ASYNC_FLUSH (异步刷盘)、SYNC_FLUSH (同步刷盘),默认为异步刷盘。 ConsumeQueue、IndexFile 刷盘的实现相对于 CommitLog 刷盘机制来说都很简单,ConsumeQueue 是周期性刷盘,索引文件的刷盘并不是采取定时刷盘机制,而是每次想要更新一次索引文件就会将之前的改动刷写到磁盘。接下来我将主要介绍 CommitLog 的刷盘过程。

同步刷盘

同步刷盘,指的是在消息追加到内存映射文件的内存中后,立即将数据从内存刷写到磁盘文件,CommitLog 中有一个刷盘服务 GroupCommitService,所有消息发送线程接收到的同步写入请求,最终都会以请求-回应的方式通知 GroupCommitService 代其进行刷盘操作。当 GroupCommitService 执行完刷盘任务,或者刷盘任务执行超时时,发送线程才会回复消息的 Producer。

我觉得这里引入 GroupCommitService 的意义主要有如下几点: 1. 避免锁竞争 2. 抽象出的Request-Response模型,可以用来实现超时机制 3. 避免无意义的刷新调用,每次刷盘都会刷新最新写入的所有数据,这样如果有实际已经被刷新过的请求过来,只要判断刷新指针就能快速知道是否已经完成 4. 保证了刷盘的顺序

接下来我们看看 GroupCommitService 的核心内容:

// 接收到的请求,直接写入requestsWrite
private volatile List<GroupCommitRequest> requestsWrite = new ArrayList<GroupCommitRequest>();
// 刷盘时,从requestsRead读取刷盘请求,有两个队列的意义是将读写过程的锁冲突消除,后面大家就会看到实际的操作过程
private volatile List<GroupCommitRequest> requestsRead = new ArrayList<GroupCommitRequest>();
// 添加刷盘请求
public synchronized void putRequest(final GroupCommitRequest request) {
    // 写锁
    synchronized (this.requestsWrite) {
        this.requestsWrite.add(request);
    }
    // 通知:有新的请求过来了
    if (hasNotified.compareAndSet(false, true)) {
        waitPoint.countDown(); // notify
    }
}
// 交换读写队列,依靠它完成读写锁冲突分离
private void swapRequests() {
    List<GroupCommitRequest> tmp = this.requestsWrite;
    this.requestsWrite = this.requestsRead;
    this.requestsRead = tmp;
}
// 实际刷盘过程
private void doCommit() {
    // 读锁
    synchronized (this.requestsRead) {
        if (!this.requestsRead.isEmpty()) {
            for (GroupCommitRequest req : this.requestsRead) {
                // There may be a message in the next file, so a maximum of
                // two times the flush
                boolean flushOK = false;
                for (int i = 0; i < 2 && !flushOK; i++) {
                    flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
                    if (!flushOK) {
                        CommitLog.this.mappedFileQueue.flush(0);
                    }
                }
                req.wakeupCustomer(flushOK);
            }
            long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
            if (storeTimestamp > 0) {
                CommitLog.this.defaultMessageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimestamp);
            }
            this.requestsRead.clear();
        } else {
            // Because of individual messages is set to not sync flush, it
            // will come to this process
            CommitLog.this.mappedFileQueue.flush(0);
        }
    }
}
// 线程循环
public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");

    while (!this.isStopped()) {
        try {
            this.waitForRunning(10);
            // 等待结束后,会交换读写队列
            this.doCommit();
        } catch (Exception e) {
            CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
        }
    }
    // Under normal circumstances shutdown, wait for the arrival of the
    // request, and then flush
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        CommitLog.log.warn("GroupCommitService Exception, ", e);
    }
    synchronized (this) {
        this.swapRequests();
    }
    this.doCommit();
    CommitLog.log.info(this.getServiceName() + " service end");
}

异步刷盘

异步刷盘根据是否开启 transientStorePoolEnable 机制,刷盘实现会有细微差别。如果 transientStorePoolEnable 为 true, RocketMQ 会单独申请一个与目标物理文件 (CommitLog) 同样大小的堆外内存,该堆外内存将使用内存锁定,确保不会被置换到虚拟内存中去,消息首先追加到堆外内存,然后提交到 FileChannel 中,再 flush 到磁盘。 如果 transientStorePoolEnable 为 false,消息直接追加到与物理文件直接映射的内存中,然后刷写到磁盘中。

当 transientStorePoolEnable 为 true时,会有一个 CommitRealTimeService 默认每隔 200ms 将直接内存中的数据提交到 FileChannel,一次提交默认至少要包含 4 个页的数据,否则暂时不提交。当 transientStorePoolEnable 为 false 时,这个 CommitRealTimeService 实际上什么都没做。

然后是定时刷盘的逻辑,CommitLog 会有一个 FlushRealTimeService 定时将数据刷入磁盘,默认每隔 10s 进行一次刷盘,和 commit 过程一样,刷盘阶段默认也是至少攒够 4 个页的脏数据才进行刷盘,当 transientStorePoolEnable 为 true时,刷盘过程调用的是 FileChannel 的 force,否则调用的是 MappedByteBuffer 的 force。

过期删除机制

由于 RocketMQ 操作 CommitLog、ConsumeQueue文件是基于内存映射机制并在启动的时候会加载 CommitLog、ConsumeQueue 目录下的所有文件,为了避免内存与磁盘的浪费,不可能将消息永久存储在消息服务器上,所以需要引人一种机制来删除己过期的文件。 RocketMQ 顺序写 CommitLog 文件、ConsumeQueue 文件,所有写操作全部落在最后一个 CommitLog 或 ConsumeQueue 文件上,之前的文件在下一个文件创建后将不会再被更新。RocketMQ 清除过期文件的方法是: 如果非当前写文件在一定时间间隔内没有再次被更新,则认为是过期文件,可以被删除,RocketMQ 不会关注这个文件上的消息是否全部被消费。默认每个文件的过期时间为 72 小时 ,通过在 Broker 配置文件中设置 fileReservedTime 来改变过期时间,单位为小时。

RocketMQ 会每隔 10s 调度一次清除过程,检测是否需要清除过期文件。

消息消费

消息消费以组的模式开展,一个消费组内可以包含多个消费者,每一个消费组可订阅多个主题,消费组之间有集群模式与广播模式两种消费模式。集群模式,主题下的同一条消息只允许被其中一个消费者消费。广播模式,主题下的同一条消息将被集群内的所有消费者消费一次。消息服务器与消费者之间的消息传送也有两种方式:推模式、拉模式。所谓的拉模式,是消费端主动发起拉消息请求,而推模式是消息到达消息服务器后,推送给消息消费者。RocketMQ 消息推模式的实现基于拉模式,在拉模式上包装一层,一个拉取任务完成后开始下一个拉取任务。

消息队列负载机制遵循一个通用的思想: 一个消息队列同一时间只允许被一个消费者消费,一个消费者可以消费多个消息队列。

RocketMQ 支持局部顺序消息消费,也就是保证同一个消息队列上的消息顺序消费。不支持消息全局顺序消费,如果要实现某一主题的全局顺序消息消费,可以将该主题的队列数设置为 1,牺牲高可用性。

RocketMQ 支持两种消息过滤模式:表达式(TAG、SQL92)与类过滤模式。

消费者启动

  1. 构建主题订阅信息
  • 订阅目标topic
  • 订阅重试主题消息。RocketMQ消息重试是以消费组为单位,而不是主题,消息重试主题名为 %RETRY% + 消费组名。消费者在启动的时候会自动订阅该主题,参与该主题的消息队列负载。
  1. 初始化消息进度。如果消息消费是集群模式,那么消息进度保存在 Broker 上; 如果是广播模式,那么消息消费进度存储在消费端。
  2. 根据是否是顺序消费,创建消费端消费线程服务。ConsumeMessageService 主要负责消息消费,内部维护一个线程池。

消息拉取

我们会基于 PUSH 模型来介绍拉取机制,因为其内部包括了 PULL 模型。消息消费有两种模式:广播模式与集群模式,广播模式比较简单,每一个消费者需要去拉取订阅主题下所有消费队列的消息,接下来主要基于集群模式介绍。在集群模式下,同一个消费组内有多个消息消费者,同一个主题存在多个消费队列,消息队列负载,通常的做法是一个消息队列在同一时间只允许被一个消息消费者消费,一个消息消费者可以同时消费多个消息队列。

RocketMQ 使用一个单独的线程 PullMessageService 来负责消息的拉取。

public void run() {
    log.info(this.getServiceName() + " service started");
    while (!this.isStopped()) {
        try {
            PullRequest pullRequest = this.pullRequestQueue.take();
            this.pullMessage(pullRequest);
        } catch (InterruptedException ignored) {
        } catch (Exception e) {
            log.error("Pull Message Service Run Method exception", e);
        }
    }
    log.info(this.getServiceName() + " service end");
}

PullMessageService 从服务端拉取到消息后,会根据消息对应的消费组,转给该组对应的 ProcessQueue,而 ProcessQueue 是 MessageQueue 在消费端的重现、快照。 PullMessageService 从消息服务器默认每次拉取 32 条消息,按消息的队列偏移量顺序存放在 ProcessQueue 中,PullMessageService 然后将消息提交到消费者消费线程池,消息成功消费后从 ProcessQueue 中移除。

消息拉取分为 3 个主要步骤。 1. 消息拉取客户端消息拉取请求封装。 2. 消息服务器查找并返回消息。 3. 消息拉取客户端处理返回的消息。

发送拉取请求

  1. 判断队列状态,如果不需要拉取则退出
  2. 进行消息拉取流控
  • 消息处理总数
  • 消息偏移量跨度
  1. 查询路由表,找到要发送的目标 Broker 服务器,如果没找到就更新路由信息
  2. 如果消息过滤模式为类过滤,则需要根据主题名称、broker地址找到注册在 Broker上的 FilterServer 地址,从 FilterServer 上拉取消息,否则从 Broker 上拉取消息
  3. 发送消息

Broker组装消息

  1. 根据订阅信息,构建消息过滤器
  • tag 过滤器只会过滤 tag 的 hashcode,为了追求高效率
  • SQL 过滤为了避免每次执行 SQL表达式,构建了 BloomFilter,在 Redis 防止缓存击穿那里我们也用过它
  1. 根据主题名称与队列编号获取消息消费队列
  2. 根据拉取消息偏移量,进行校对,如何偏移量不合法,则返回相应的错误码
  3. 如果待拉取偏移量大于 minOffset 并且小于 maxOffs 时,从当前 offset 处尝试拉取 32 条消息,根据消息队列偏移量(ConsumeQueue)从 CommitLog 文件中查找消息
  4. 根据 PullResult 填充 responseHeader 的 nextBeginOffset、 minOffset、 maxOffset
  5. 如果主 Broker 工作繁忙,会设置 flag 建议消费者下次从 Slave 节点拉取消息
  6. 如果 CommitLog 标记可用并且当前节点为主节点,则更新消息消费进度
Bloom Filter是一种空间效率很高的随机数据结构,它的原理是,当一个元素被加入集合时,通过K个Hash函数将这个元素映射成一个位阵列(Bit array)中的K个点,把它们置为1。检索时,我们只要看看这些点是不是都是1就(大约)知道集合中有没有它了:如果这些点有任何一个0,则被检索元素一定不在;如果都是1,则被检索元素很可能在。这就是布隆过滤器的基本思想。 但Bloom Filter的这种高效是有一定代价的:在判断一个元素是否属于某个集合时,有可能会把不属于这个集合的元素误认为属于这个集合(false positive)。因此,Bloom Filter不适合那些“零错误”的应用场合。而在能容忍低错误率的应用场合下,Bloom Filter通过极少的错误换取了存储空间的极大节省。

客户端处理消息

  1. 解码成消息列表,并进行消息过滤
  • 这里之所以还要进行过滤,是因为 Broker 为了追求效率只会根据 tag 的 hashcode 进行过滤,真实 key string 的对比,下放到 Consumer 上进行
  1. 更新 PullRequest 的下一次拉取偏移量,如果过滤后没有一条消息的话,则立即触发下次拉取
  2. 首先将拉取到的消息存入 ProcessQueue,然后将拉取到的消息提交到 ConsumeMessageService 中供消费者消费,该方法是一个异步方法,也就是 PullCallBack 将消息提交到 ConsumeMessageService 中就会立即返回
  3. 根据拉取延时,适时进行下一次拉取

9ee75452b0ecce8f69835f329b1d209b.png

RocketMQ 并没有真正实现推模式,而是消费者主动向消息服务器拉取消息,RocketMQ 推模式是循环向消息服务端发送消息拉取请求,如果消息消费者向 RocketMQ 发送消息拉取时,消息并未到达消费队列,会根据配置产生不同效果: - 不启用长轮询机制:在服务端等待 shortPollingTimeMills=1s 时间后(挂起)再去判断消息是否已到达消息队列,如果消息未到达则提示消息拉取客户端 PULL_NOT_FOUND (消息不存在) - 开启长轮询模式: RocketMQ 一方面会每 5s 轮询检查一次消息是否存在,同时一有新消息到达后立马通知挂起线程再次验证新消息是否是自己感兴趣的消息,如果是, 则从 CommitLog 文件提取消息返回给消息拉取客户端,否则等到挂起超时,超时时间由消息拉取方在消息拉取时封装在请求参数中,PUSH 模式默认为 15s

当新消息达到 CommitLog 时,ReputMessageService 线程负责将消息转发给 ConsumeQueue、IndexFile,如果 Broker 端开启了长轮询模式并且角色主节点,则最终将调用 PullRequestHoldService 线程的 notifyMessageArriving 方法唤醒挂起线程,判断当前消费队列最大偏移量是否大于待拉取偏移量,如果大于则拉取消息。长轮询模式使得消息拉取能实现准实时。

队列负载均衡

在 RocketMQ 中,Consumer 端的两种消费模式(Push/Pull)都是基于拉模式来获取消息的,而在 Push 模式只是对 Pull 模式的一种封装,其本质实现为消息拉取线程在从服务器拉取到一批消息后,然后提交到消息消费线程池后,又“马不停蹄”的继续向服务器再次尝试拉取消息。如果未拉取到消息,则延迟一下又继续拉取。在两种基于拉模式的消费方式(Push/Pull)中,均需要 Consumer 端在知道从 Broker 端的哪一个消息队列—队列中去获取消息。因此,有必要在 Consumer 端来做负载均衡,即 Broker 端中多个 MessageQueue 分配给同一个 ConsumerGroup 中的哪些 Consumer 消费。

在 Consumer 启动后,它就会通过定时任务不断地向 RocketMQ 集群中的所有 Broker 实例发送心跳包(其中包含了,消息消费分组名称、订阅关系集合、消息通信模式和客户端id的值等信息)。Broker 端在收到 Consumer 的心跳消息后,会将它维护在 ConsumerManager 的本地缓存变量 ConsumerTable,同时并将封装后的客户端网络通道信息保存在本地缓存变量 ChannelInfoTable 中,为之后做 Consumer 端的负载均衡提供可以依据的元数据信息。

Consumer 的 RebalanceService 会每隔20s执行一次负载均衡。它会根据消费者通信类型为“广播模式”还是“集群模式”做不同的逻辑处理。因为广播模式,每个 Consumer 都会订阅所有队列的内容,实现很简单,所以这里主要来看下集群模式下的主要处理流程: 1. 从本地缓存变量 TopicSubscribeInfoTable 中,获取该 Topic 主题下的消息消费队列集合 2. 向各个 Broker 端发送获取该消费组下消费者Id列表 3. 先对 Topic 下的消息消费队列、消费者 Id 排序,然后用消息队列分配策略算法(默认为:消息队列的平均分配算法),计算出待拉取的消息队列 4. 根据计算出来的新负载均衡结果,更新本地的队列消费任务 - 删除已经不由自己负责的队列消费任务 - 添加新的由自己负责的队列消费任务,从 Broker 中读取该队列的消费偏移 Offset,然后开始消费任务

RocketMQ 的负载均衡过程并没有通过选主分配的过程进行,而是各个节点自行计算,我觉得主要是为了实现方便,而且 RocketMQ 也不追求一个消息只被消费一次,如果负载均衡的结果出现了短暂冲突(最终应该会趋于一致),也可以靠 Consumer 实现幂等性解决。

消息消费过程

  1. 当 Consumer 拉取收新的消息时,会将这些消息以 32 个为一组,提交给消息消费者线程池
  2. 线程池进行实际消费时,会确认当前消息队列是否仍然归自己管辖(重新负载均衡时,将该队列分配给了别的消费者)
  3. 恢复延时消息主题名
  • RocketMQ 将消息存入 CommitLog 文件时,如果发现消息是延时消息,会首先将原主题存入在消息的属性中,然后设置主题名称为 SCHEDULE_TOPIC,以便时间到后重新参与消息消费。
  1. 执行具体的消息消费函数,最终将返回 CONSUME_SUCCESS (消费成功)或 RECONSUME_LATER (需要重新消费)
  2. 如果业务代码返回 RECONSUME_LATER,根据模式作出不同的处理
  • 广播模式:什么都不处理,只打印log
  • 集群模式:回发消费失败的消息,进行重新消费,如果发送失败,则再次尝试自己消费
  1. 根据消费成功的消息,计算消费者线程池的剩余消息数量和大小,然后更新offset
  • 由于可能会出现一组消息只有后半段被消费成功的情况,所以最终的 offset 为剩余消息池中最小的 offset,这就势必会出现重复消费

重试消息

RocketMQ 提供了几个重试消息的延时级别: 1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 1Om 20m 30m 1h 2h,同时也有消息最大重新消费次数,如果超过了最大重新消费次数则会被单独存储起来,等待人工处理。

重试消息会被存入名为"%RETRY%+消费组名称"的主题中,原始主题会存入属性中。然后会基于定时任务机制,在到期时将任务再次拉取出来。

消费进度管理

  • 广播模式: 同一个消费组的所有消息消费者都需要消费主题下的所有消息,也就是同组内的消费者的消息消费行为是对立的,互相不影响,故消息进度需要独立存储,最理想的存储地方应该是与消费者绑定。这些数据最终会存储在 Consumer 节点的磁盘文件中,采用周期性刷盘的形式存储。
  • 集群模式: 同一个消费组内的所有消息消费者共享消息主题下的所有消息,同一条消息(同一个消息消费队列)在同一时间只会被消费组内的一个消费者消费,并且随着消费队列的动态变化重新负载,所以消费进度需要保存在一个每个消费者都能访问到的地方————Broker,在需要更新 Offset 时,会以网络请求的形式更新 Broker 中存储的 Offset。

8182b1a9ef67edbc15a1b438b96158d0.png

消费者线程池每处理完一个消息消费任务(ConsumeRequest)时会从 ProcessQueue 中移除本批消费的消息,并返回 ProcessQueue 中最小的偏移量,用该偏移量更新消息队列消费进度,如果 ProcessQueue 中的消息 Offset 分别为 [10,30,40,50],这时候消费了30,40,最后的 Offset 仍然为 10。只有当 Offset = 10 的消息被消费后,Offset 才会变为 50。正因为如此,RocketMQ 才会有根据消息 Offset 跨度进行流量控制的功能。

此外,值得一提的是,当发生重新负载均衡后,如果某一队列被分配给了其他消费者,那么该队列对应的 Offset 也会从本机中消除。

顺序消息

RocketMQ 支持局部消息顺序消费,可以确保同一个消息消费队列中的消息被顺序消费,如果需要做到全局顺序消费则可以将主题配置成一个队列。

消息队列负载

如果经过消息队列重新负载(分配)后,分配到新的消息队列时,首先需要尝试向 Broker 发起锁定该消息队列的请求,如果返回加锁成功则创建该消息队列的拉取任务,否则将跳过,等待其他消费者释放该消息队列的锁,然后在下一次队列重新负载时再尝试加锁。如果重新分配后,发现某一队列已不由自己负责,会主动的释放该队列的锁。除此之外,锁的最大存活时间是 60s,如果超过 60s 未续锁,则自动释放。

顺序消息消费与并发消息消费的第一个关键区别: 顺序消息在创建消息队列拉取任务时需要在 Broker 服务器锁定该消息队列。

消息拉取

消息拉取过程中,先会判断该消息队列是否被锁定,如果未被自己锁定,则会延迟一段时间后,再进行拉取任务。

消息消费

如果消费模式为集群模式,启动定时任务,默认每隔 20s 锁定一次分配给自己的消息消费队列(锁的保活)。

在 ConsumeMessageOrderlyService 消费消息时,先会获取内存中的队列锁。也就是说,一个消息消费队列同一时刻只会被一个消费线程池中一个线程消费。除此之外,其他过程基本和并发消费的过程一致。

FilterServer 过滤器

RocketMQ 提供了基于表达式与基于类模式两种过滤模式,前面已经详细介绍了整个消息拉取、基于表达式(TAG)的过滤模式。基于类模式过滤是指在 Broker 端运行 1 个或多个消息过滤服务器(FilterServer), RocketMQ 允许消息消费者自定义消息过滤实现类并将其代码上传到 FilterServer 上,消息消费者向 FilterServer 拉取消息,FilterServer将消息消费者的拉取命令转发到 Broker,然后对返回的消息执行消息过滤逻辑,最终将消息返回给消费端,其工作原理如下图所示。

f92cbe6c8d4c8fd16f3d0ec5c164bf8e.png

1. Broker 进程所在的服务器会启动多个 FilterServer 进程 2. 消费者在订阅消息主题时会上传一个自定义的消息过滤实现类,FilterServer 加载并实例化 3. 消息消费者(Consume)向 FilterServer 发送消息拉取请求,FilterServer 接收到消费者消息拉取请求后,FilterServer 将消息拉取请求转发给 Broker, Broker 返回消息后在 FilterServer 端执行消息过滤逻辑,然后返回符合条件的消息给消费者进行消费

FilterServer 注册

FilterServer 从配置文件中获取 Broker地址,然后将自己的IP与端口发送到 Broker 服务器。随后 Broker 会在其内存中维护一个 FilterServer 列表,此后 FilterServer 和 Broker 之间还会通过心跳来维持注册关系,如果超过 30s 未收到心跳,则会删除关于该 FilterServer 的信息。

为了防止 FilterServer 由于 Crash 而越来越少,Broker 也会定时检查当前 FilterServer 的数量,如果数量小于阈值,则自动创建一个 FilterServer。

还记得前面说过的 Broker 每隔 30s 会向 NameServer 发送心跳包么,在心跳包中就包含该 Broker 的所有 FilterServer 信息,消息的消费者就是从 NameServer 中获取到该 Broker 的所有 FilterServer 信息的。

总结一下:FilterServer 在启动时向 Broker 注册自己,在 Broker 端维护该 Broker 的 FilterServer 信息,并定时监控 FilterServer 的状态,然后 Broker 通过与所有 NameServer 的心跳包向 NameServer 注册 Broker 上存储的 FilterServer 列表,指引消息消费者正确从 FilterServer 上拉取消息。

类过滤机制

  1. 消费者查询需要订阅的主题所在的 Broker 和其对应的 FilterServer
  2. 遍历所有 FilterServer 并发送过滤代码
  3. FilterServer 先通过CRC验证源码的正确性,然后根据消费组名+topic 保存其过滤代码,最后进行编译

如果 FilterServer 设置为不允许直接编译消费者上传的类,则会开启一个定时任务,每隔一段时间从指定的远程服务器下载对应的过滤代码。而远端服务器的过滤代码上传,就需要进行适当的检查,防止图谋不轨的代码上传。

消息拉取

RocketMQ 消息的过滤发生在消息消费的时候,PullMessageService 线程默认从 Broker 上拉取消息,执行相关的过滤逻辑,在 FilterServer 过滤模式下,PullMessageService 线程将拉取地址由原来的 Broker 地址转换成随机一个 FilterServer 地址。

HA机制

为了提高消息消费的高可用性,避免 Broker 发生单点故障引起存储在 Broker 上的消息无法及时消费,RocketMQ 引入了 Broker 主备机制,即消息消费到达主服务器后需要将消息同步到消息从服务器,如果主服务器 Broker 宕机后,消息消费者可以从从服务器拉取消息。

工作机制

RocketMQ HA 的实现原理如下。 1. 主服务器启动,并在特定端口上监听从服务器的连接 2. 从服务器主动连接主服务器,主服务器接收客户端的连接,并建立相关 TCP 连接 3. 从服务器主动向主服务器发送待拉取消息偏移量,主服务器解析请求并返回消息给从服务器 4. 从服务器保存消息并继续发送新 的消息同步请求

如果是同步主从模式,消息发送者将消息刷写到磁盘后,需要继续等待新数据被传输到从服务器,而从服务器数据的复制是在另外一个线程中去拉取的,所以消息发送者在这里需要等待数据传输的结果,RocketMQ 有一个 GroupTransferService,它的职责是负责当主从同步复制结束后通知由于等待 HA 同步结果而阻塞的消息发送者线程。

判断主从同步是否完成的依据是 Slave 中已成功复制的最大偏移量是否大于等于消息生产者发送消息后消息服务端返回下一条消息的起始偏移量,如果是则表示主从同步复制已经完成,唤醒消息发送线程,否则等待 1s 再次判断,每一个任务在一批任务中循环判断 5 次。

RocketMQ HA 主要交互流程如下图所示。

045420b9f25c3131aeb43d704975dc58.png

读写分离

RocketMQ 根据 MessageQueue查找 Broker地址的唯一依据是 brokerName,从 RocketMQ 的 Broker 组织结构中得知同一组 Broker (M-S)服务器,它们的 brokerName 相同但 brokerId 不同,主服务器的 brokerId 为 0,从服务器的 brokerId 大于 0。

在前面介绍消息拉取的时候,提过 Broker 在返回拉取内容的同时还会返回下一次是否要从 Slave 拉取数据,消费者收到该建议后,会找到合适的 Broker 节点进行拉取。那么 Broker 是通过哪种策略来建议的呢?

long diff = maxOffsetPy - maxPhyOffsetPulling;
long memory = (long) (StoreUtil.TOTAL_PHYSICAL_MEMORY_SIZE
    * (this.messageStoreConfig.getAccessMessageInMemoryMaxRatio() / 100.0));
getResult.setSuggestPullingFromSlave(diff > memory);

上述就是建议策略,下面进行解读: - maxOffsetPy: 代表当前主服务器消息存储文件最大偏移量。 - maxPhyOffsetPulling: 此次拉取消息最大偏移量。 - diff:对于PullMessageService线程来说,当前未被拉取到消息消费端的消息长度。 - TOTAL_PHYSICAL_MEMORY_SIZE: RocketMQ 所在服务器总内存大小。 - AccessMessageInMemoryMaxRatio: 表示 RocketMQ 所能使用的最大内存比例,超过该内存,消息将被置换出内存 - memory: 表示 RocketMQ 消息常驻内存的大小,超过该大小, RocketMQ 会将旧的消息置换回磁盘 - 如果 diff 大于 memory,表示当前需要拉取的消息已经超出了常驻内存的大小,表示主服务器繁忙,此时才建议从 Slave 服务器拉取

如果主服务器繁忙则建议下一次从从服务器拉取消息,下次默认从标号为 1 的从节点拉取消息。如果一个 Master 拥有多台 Slave 服务器,参与消息拉取负载的从服务器只会是其中一个。

事务消息

Apache RocketMQ 在4.3.0版中已经支持分布式事务消息,这里 RocketMQ 采用了 2PC 的思想来实现了提交事务消息,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息,如下图所示。

d9b19f37264abc170a774009f23708c7.png

上图说明了事务消息的大致方案,其中分为两个流程:正常事务消息的发送及提交、事务消息的补偿流程。

事务消息发送及提交: 1. 发送消息(half 消息) 2. 服务端响应消息写入结果 3. 根据发送结果执行本地事务(如果写入失败,此时 half 消息对业务不可见,本地事务逻辑不执行) 4. 根据本地事务状态执行 Commit或者 Rollback(Commit 操作生成消息索引,此后消息才对消费者可见)

如果上述第四步没有执行成功,RocketMQ 会有自己的补偿过程: 1. 对没有 Commit/Rollback 的事务消息(pending 状态的消息),从服务端发起一次“回查” 2. Producer 收到回查消息,检查回查消息对应的本地事务的状态 3. 根据本地事务状态,重新 Commit 或者 Rollback

补偿阶段用于解决消息 Commit 或者 Rollback 发生超时或者失败的情况。

Half 消息

在 RocketMQ 事务消息的主要流程中,一阶段的消息如何对用户不可见。其中,事务消息相对普通消息最大的特点就是一阶段发送的消息对用户是不可见的。那么,如何做到写入消息但是对用户不可见呢?RocketMQ事务消息的做法是:如果消息是 Half 消息,将备份原消息的主题与消息消费队列,然后改变主题为 RMQ_SYS_TRANS_HALF_TOPIC。由于消费组未订阅该主题,故消费端无法消费 Half 类型的消息,然后 RocketMQ 会开启一个定时任务,从 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC 中拉取消息进行消费,根据生产者组获取一个服务提供者发送回查事务状态请求,根据事务状态来决定是提交或回滚消息。

其实改变消息主题是 RocketMQ 的常用“套路”,回想一下延时消息和重试消息的实现机制。

Op 消息

在完成一阶段写入一条对用户不可见的消息后,二阶段如果是 Commit 操作,则需要让消息对用户可见;如果是 Rollback 则需要撤销一阶段的消息。先说 Rollback 的情况。对于 Rollback,本身一阶段的消息对用户是不可见的,其实不需要真正撤销消息(实际上 RocketMQ 也无法去真正的删除一条消息,因为是顺序写文件的)。但是区别于这条消息没有确定状态(Pending 状态,事务悬而未决),需要一个操作来标识这条消息的最终状态。RocketMQ事务消息方案中引入了 Op 消息的概念,用 Op 消息标识事务消息已经确定的状态(Commit 或者 Rollback)。如果一条事务消息没有对应的 Op 消息,说明这个事务的状态还无法确定(可能是二阶段失败了)。引入 Op 消息后,事务消息无论是 Commit 或者 Rollback 都会记录一个 Op 操作。Commit 相对于 Rollback 只是在写入 Op 消息前创建 Half 消息的索引。

RocketMQ 将 Op 消息写入到全局一个特定的 Topic 中,这个 Topic 是一个内部的 Topic(像 Half 消息的 Topic 一样),不会被用户消费。Op 消息的内容为对应的 Half 消息的存储的 Offset,这样通过 Op 消息能索引到 Half 消息进行后续的回查操作。

41094c4ccdedaacd450ea08ccffeb123.png

Half消息的索引构建

在执行二阶段 Commit 操作时,需要构建出Half消息的索引。一阶段的 Half 消息由于是写到一个特殊的 Topic,所以二阶段构建索引时需要读取出 Half 消息,并将 Topic 和 Queue 替换成真正的目标的 Topic 和 Queue,之后通过一次普通消息的写入操作来生成一条对用户可见的消息。所以 RocketMQ 事务消息二阶段其实是利用了一阶段存储的消息的内容,在二阶段时恢复出一条完整的普通消息,然后走一遍消息写入流程。

处理二阶段失败

如果在 RocketMQ 事务消息的二阶段过程中失败了,例如在做 Commit 操作时,出现网络问题导致 Commit 失败,那么需要通过一定的策略使这条消息最终被 Commit。RocketMQ 采用了一种补偿机制,称为“回查”。Broker 端对未确定状态的消息发起回查,将消息发送到对应的 Producer 端(同一个 Group 的 Producer),由 Producer 根据消息来检查本地事务的状态,进而执行 Commit 或者 Rollback。Broker 端通过对比 Half 消息和 Op 消息进行事务消息的回查并且推进 CheckPoint(记录那些事务消息的状态是确定的)。

值得注意的是,RocketMQ 并不会无休止的的信息事务状态回查,默认回查 15 次,如果15次回查还是无法得知事务状态,RocketMQ 默认回滚该消息。

实用场景

顺序消息

顺序消息是指消息的消费顺序和产生顺序相同,在有些业务逻辑下,必须保证顺序。比如订单的生成、付款、发货,这 3 个消息必须按顺序处理才行。顺序消息分为全局顺序消息和部分顺序消息,全局顺序消息指某个 Topic 下的所有消息都要保证顺序;部分顺序消息只要保证每一组消息被顺序消费即可,比如上面订单消息的例子,只要保证同一个订单 ID 的三个消息能按顺序消费即可。

  • 全局顺序消息:一个 topic 只留一个队列,并且顺序消费,这时高并发、高吞吐量的功能完全用不上了。
  • 部分顺序消息:在发送端,要做到把同一业务 ID 的消息发送到同一个 Message Queue(hash 映射);在消费过程中,要做到从同一个 Message Queue 读取的消息不被并发处理,Consumer 使用 MessageListenerOrderly 的时候,这样才能达到部分有序。

消息重复问题

解决消息重复有两种方法:第一种方法是保证消费逻辑的幂等性(多次调用和一次调用效果相同);另一种方法是维护一个巳消费消息的记录(例如,和实际消费过程在同一个事务中写入 DB),消费前查询这个消息是否被消费过。这两种方法都需要使用者自己实现。

动态增减机器

NameServer

NameServer 是各自独立的,在添加或删除 NameServer 后,只能能通过一些机制更新其他角色的 NameServer 地址列表即可: - 通过代码设置,比如Producer.setNamesrvAddr (”name-server1-ip:port;name-server2即:port” - 使用 Java 启动参数设置 - 通过 Linux 环境变量设置 - 使用者定时通过 HTTP 服务查询最新的 NameServer 列表

Broker

由于业务增长,需要对集群进行扩容的时候,可以动态增加 Broker 角色的机器。只增加 Broker 不会对原有的 Topic 产生影响,原来创建好的 Topic 中数据的读写依然在原来的那些 Broker 上进行。

减少 Broker 要看是否有持续运行的 Producer,当一个 Topic 只有一个 Master Broker,停掉这个 Broker 后,消息的发送肯定会受到影响,需要在停止这个 Broker 前,停止发送消息。

当某个 Topic 有多个 Master Broker,停了其中一个,这时候是否会丢失消息呢? 答案和 Producer 使用的发送消息的方式有关,如果使用同步方式 send (msg)发送,在 DefaultMQProducer 内部有个自动重试逻辑,其中一个 Broker 停了,会自动向另一个 Broker 发消息,不会发生丢消息现象。 如果使用异步方式发送 send (msg, callback),或者用 sendOneWay 方式,会丢失切换过程中的消息。 因为在异步和 sendOneWay 这两种发送方式下,发送失败不会重试。DefaultMQProducer 默认每 30 秒到 NameServer 请求最新的路由消息,Producer 如果获取不到已停止的 Broker 下的队列信息,后续就自动不再向这些队列发送消息。

如果 Producer 程序能够暂停,在有一个 Master 和一个 Slave 的情况下也可以顺利切换。可以关闭 Producer 后关闭 Master Broker,这个时候所有的读取都会被定向到 Slave 机器,消费消息不受影响。把 MasterBroker 机器置换完后,基于原来的数据启动这个 Master Broker,然后再启动 Producer 程序正常发送消息。

用 Linux 的 kill pid 命令就可以正确地关闭 Broker, BrokerController 下有个 shutdown 函数,这个函数被加到了 ShutdownHook 里,当用 Linux 的 kill 命令时(不能用 kill -9 ), shutdown 函数会先被执行。也可以通过 RocketMQ 提供的工具(mqshutdown broker)来关闭 Broker,它们的原理是一样的。

各种故障的影响

我们期望消息队列集群一直可靠稳定地运行,但有时候故障是难免的,我们列出可能的故障情况,看看如何处理: 1. Broker 正常关闭,启动 2. Broker 异常 Crash,然后启动 3. OS Crash,重启 4. 机器断电,但能马上恢复供电 5. 磁盘损坏 6. CPU、主板、内存等关键设备损坏

假设现有的 RocketMQ 集群,每个 Topic 都配有多 Master 角色的 Broker 供写入,并且每个 Master 都至少有一个 Slave 机器(用两台物理机就可以实现上述配置),我们来看看在上述情况下消息的可靠性情况。

第 1 种情况属于可控的软件问题,内存中的数据不会丢失。如果重启过程中有持续运行的 Consumer, Master机器出故障后,Consumer会自动重连到对应的 Slave 机器,不会有消息丢失和偏差。当 Master 角色的机器重启以后,Consumer又会重新连接到 Master 机器(注意在启动 Master 机器的时候,如果 Consumer 正在从 Slave 消费消息,不要停止 Consumer。 假如此时先停止 Consumer 后再启动 Master机器,然后再启动 Consumer,这个时候 Consumer 就会去读 Master 机器上已经滞后的 offset 值,还记得集群模式 offset 存在 Master Broker 吗,这样造成消息大量重复)。

如果第 1 种情况出现时有持续运行的 Producer,一台 Master 出故障后,Producer 只能向 Topic 下其他的 Master 机器发送消息,如果 Producer 采用同步发送方式,不会有消息丢失。

第2、3、4种情况属于软件故障,内存的数据可能丢失,所以刷盘策略不同,造成的影响也不同,如果 Master、Slave 都配置成 SYNC_FLUSH,可以达到和第 1 种情况相同的效果。

第 5、6 种情况属于硬件故障,发生第 5、6 种情况的故障,原有机器的磁盘数据可能会丢失。如果 Master 和 Slave 机器间配置成同步复制方式,某一台机器发生 5 或 6 的故障,也可以达到消息不丢失的效果。如果机器间是异步复制,两次 Sync 间的消息会丢失。

总的来说,当设置成: - 多 Master,每个 Master 带有 Slave - 主从之间设置成 SYNC_MASTER - Producer 用同步方式写 - 刷盘策略设置成 SYNC_FLUSH

就可以消除单点依赖,即使某台机器出现极端故障也不会丢消息。

消息优先级

RocketMQ 是个先人先出的队列,不支持消息级别或者 Topic 级别的优先级。业务中简单的优先级需求,可以通过间接的方式解决: - 拆分出多个 topic 分配不同的 Consumer 去消费 - 一个 topic 拆分多个队列,每个队列分别消费 - 强优先级:拆分多个 topic A,B,C,消费者在 A 消费完的情况下才会消费 B,同样 B 消费完的情况下,才去消费 C

吞吐优先

  • 过滤感兴趣的消息,减少网络传输 TAG、SQL、FilterServer
  • 增加 Consumer 并发度
  • 检测延时情况,跳过非重要消息
  • 增加 topic 队列
  • 并发 Producer
  • 推荐使用 EXT4 文件系统,IO 调度算法使用 deadline算法

dbe01db483b3cd62dc3ae538ec3a01c1.png
deadline算法大致思想如下: 实现四个队列,其中两个处理正常的 read 和 write 操作,另外两个处理超时的 read 和 write 操作。正常的 read 和 write 队列中,元素按扇区号排序,进行正常的 IO 合并处理以提高吞吐量。因为 IO 请求可能会集中在某些磁盘位置,这样会导致新来的请求一直被合并,可能会有其他磁盘位置的 IO 请求被饿死。超时的 read 和 write 的队列中,元素按请求创建时间排序,如果有超时的请求出现,就放进这两个队列,调度算法保证超时(达到最终期限时间)的队列中的 IO 请求会优先被处理。

调优

这里讨论的系统是指能完成某项功能的软硬件整体,比如我们用 RocketMQ ,加上自己写的 Producer、Consumer 程序,部署到一台服务器上,组成一个消息处理系统。

首先是搭建测试环境,查看硬件利用率。把测试系统搭建好以后,要想办法模拟实际使用时的情况,并且逐步增大请求量,同时检测系统的 TPS。在请求量增大到一定程度时,系统的 QPS 达到峰值,这个时候维持这种请求量,保持系统在峰值状态下运行。然后查看此时系统的硬件使用情况: - 使用 top 命令查看 CPU 和内存的利用率 - 使用 sar -n DEV 2 10 命令查看网卡使用情况 - 使用 netstat -t 查看网卡的连接情况, 看是否有大量连接造成堵塞 - 使用 iostat -xdm 1 查看磁盘的使用情况

经过上面的一系列检查,应该能够找到系统的瓶颈。比如瓶颈是在CPU、网卡还是磁盘?

还有一种情况是这三者都没有到使用极限,这也是一种比较常见而且有优化空间的情况,这种情况说明 CPU 利用率没有发挥出来,比如可能是锁的机制有 bug,造成线程阻塞。

对于 Java 程序来说,接下来可以用 Java 的 profiling 工具来找出程序的具体问题,比如 jvisualvm、 jstack、 perfJ 等。通过上面这些工具,可以逐步定位出是哪些 Java 线程比较慢,那个函数占用的时间多,是否因为存在锁造成了忙等的情况,然后通过不断的更改测试,找到影响性能的关键代码,最终解决问题。

参考内容

[1]《RocketMQ技术内幕》

[2]《RocketMQ实战与原理解析》

[3] https://juejin.im/post/5c18b4bcf265da611d6696d6

[4] https://www.jianshu.com/p/015a16347640

[5] https://juejin.im/post/5cd82323f265da038932b1e6

[6] https://www.cnkirito.moe/file-io-best-practise/

[7] https://blog.csdn.net/v_july_v/article/details/6685894

[8] https://github.com/apache/rocketmq/blob/master/docs/cn/design.md

f32c1b8bba046fb9c68c6a1aa6899c98.png

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:深蓝海洋 设计师:CSDN官方博客 返回首页
评论
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值