源码篇
环境搭建
源码拉取
- github地址:https://github.com/apache/rocketmq
- 官网地址:https://www.apache.org/dyn/closer.cgi?path=rocketmq/4.7.1/rocketmq-all-4.7.1-source-release.zip
- 源码目录结构:
调试
- 创建conf配置文件从distribution拷⻉broker.conf、logback_broker.xml和logback_namesrv.xml。
- 配置启动的工作目录、环境变量已经参数:
- 启动Namesrv:
- 启动Broker:
- 发送消息:
- 消费消息:
NameServer
- 整个namesrv工程代码量很少,结构也很简单:
启动流程
- 启动NameServer的第一步是构造一个NamesrvController实例,这个类是NameServer的核心类。然后初始化、启动 NamesrvController 类,而 createNamesrvController 方法,就是从命令行接收参数,然后将解析成配置类 NamesrvConfig 和 NettyServerConfig 。
- 我们知道在命令行中运行 RocketMQ 是可以指定参数的,它的原理就是下面代码展示的那样。
- 通过 -c 命令可以指定配置文件,将配置文件中的内容解析成 java.util.Properties 类,然后赋值给 NamesrvConfig 和 NettyServerConfig 类完成配置文件的解析与映射。
- 如果指定了 -p 命令,则会在控制台打印配置信息,然后程序直接退出。
- 除此之外还可以使用 -n 参数指定 namesrvAddr 的值,这是在 org.apache.rocketmq.srvutil.ServerUtil # buildCommandlineOptions 方法中指定的参数。
- 当完成配置属性的映射,就会根据配置类 NamesrvConfig 和 NettyServerConfig 构造一 个 NamesrvController 实例。
初始化及心跳机制
- 启动 NameServer 的第二步是通过 NamesrvController#initialize 完成初始化。
- 看一看initialize方法:
- 在初始化 NamesrvController 过程中,会注册一个心跳机制的线程池,它会在启动后5秒开始每隔 10 秒扫描一次不活跃的 broker 。
- 可以看到,在 scanNotActiveBroker 方法中, NameServer 会遍历 RouteInfoManager#brokerLiveTable 这个属性。
- RouteInfoManager#brokerLiveTable 属性存储的是集群中所有 broker 的活跃信息,主要是 BrokerLiveInfo # lastUpdateTimestamp 属性,它描述了 broker 上一次更新的活跃时间戳。若lastUpdateTimestamp 属性超过120秒未更新,则该 broker 会被视为失效并从 brokerLiveTable 中移除。
优雅停机
- NameServer 启动的最后一步,是注册了一个 JVM 的钩子函数,它会在 JVM 关闭之前执行。这个钩子函数的作用是释放资源,如关闭 Netty 服务器,关闭线程池等。
小结一下
NamesrvStartup#main:主函数启动
|--NamesrvStartup#createNamesrvController:构造一个NamesrvController实例
|--new NamesrvController:根据配置类 NamesrvConfig 和 NettyServerConfig 构造一个 NamesrvController 实例
|--NamesrvStartup#start:初始化、启动 NamesrvController 类
|--NamesrvController#initialize:初始化,加载配置,注册线程池
|--RouteInfoManager#scanNotActiveBroker:心跳线程池,它会在启动后5秒开始每隔10秒扫描一次不活跃的broker
|--NamesrvController#start:启动
|--Runtime.getRuntime().addShutdownHook(new ShutdownHookThread):优雅停机
Broker
- 消息中转⻆色,负责存储消息、转发消息。Broker在RocketMQ系统中负责接收从生产者发送来的消息并存储、同时为消费者的拉取请求作准备。代理服务器也存储消息相关的元数据,包括消费者组、消费进度偏移和主题和队列消息等。
Broker作用
作用
- 消息存储
- 消息投递
- 消息查询
- 服务的高可用
包含的模块
- Remoting Module:整个Broker的实体,负载处理来自clients端的请求。
- Client Manager:负载管理可客户端(Producer、Consumer)和维护Consumer的Topic订阅信息。
- Store Service:提供方便简单的API接口处理消息存储到物理硬盘和查询功能。
- HA Service:高可用服务,提供Master Broker和Slave Broker之间的数据同步功能。
- Index Service:根据特定的Message Key对投递到Broker的消息进行索引服务,以提供消息的快速查询。
启动流程
- Broker的启动流程与NameServer的启动流程很类似,大体分为两步:
- BrokerController创建
- NettyServer和NettyClient的配置处理
- 命令行参数的处理
- Broker⻆色的处理
- 创建BrokerController
- 初始化BrokerController通过调用方法
- BrokerController启动
BrokerController创建
- 配置Netty、创建三个配置对象、监听端口、填充配置文件:
- 获取namesrv地址;设置brokerId:主要是处理在集群条件下的Broker⻆色的,从这里看出来brokerId为 MixAll.MASTER_ID = 0 的为Mater节点,其他的为Slave 节点。
- 处理命令行参数,初始化创建并返回controller
BrokerController启动
- 可以看到在启动broker时,也启动了一个线程池,每隔一段时间(默认30秒)向nameserver服务端注册信息。
路由注册
broker发送心跳到nameserver
- BrokerOuterAPI#registerBrokerAll
- 分别给每个nameserver注册broker
nameserver处理请求包
- 处理broker心跳注册broker
- 路由元数据信息
路由删除
- RocktMQ 有两个触发点来触发路由删除:
- NameServer定时扫描 brokerLiveTable检测上次心跳包与 当前系统时间的时间差, 如果时间戳大于 120s,则需要移除该 Broker 信息。
- Broker在正常被关闭的情况下,会执行 unrgisterBroker 指令。
- 这两种方式删除路由的方法都是一样的,就是从路由表中删除与broker相关的信息。
- 维护其他的路由信息表
路由发现
- 路由发现是非实时的,当topic路由出现变化,NameServer不主动推送给客户端,而是由客户端定时拉去主题最新的路由。
- 根据主题获取路由信息,NameServer不主动推,保证nameserver简单高效
- 进行路由的发现
小结一下
BrokerStartup#main:主函数启动
|--BrokerStartup#createBrokerController:创建一个BrokerController实例
|--createBrokerController:配置Netty、创建三个配置对象、监听端口、处理命令行参数、初始化创建并返回controller
|--Runtime.getRuntime().addShutdownHook(...):优雅停机
|--BrokerStartup#start
|--start:各种服务启动、创建定时线程向nameserver注册信息
|--BrokerController#doRegisterBrokerAll:注册路由
|--BrokerOuterAPI#registerBrokerAll:注册broker信息
|--BrokerOuterAPI#registerBroker:分别给每个nameserver注册broker
|--RemotingClient#invokeOneway:不接收返回值,异步的方式
|--RemotingClient#invokeSync:同步方式,接收返回值
DefaultRequestProcessor#processRequest:nameserver处理broker发送的心跳请求包
|--DefaultRequestProcessor#registerBroker:注册broker
|--NamesrvController.getRouteInfoManager()#registerBroker:注册broker
|--DefaultRequestProcessor#getRouteInfoByTopic:根据主题获取路由信息
|--NamesrvController.getRouteInfoManager()#pickupTopicRouteData:根据nameserverControler拿到RouteInfoManager,根据路由获取相关的路由信息来填充TopicRouteData对象
Producer
- RocketMQ发送消息分为三种实现方式:同步发送、异步发送、单向发送。目前的MQ中间件从存储模型来看,分为需要持久化和不需要持久化两种。
消费生产者组件
- 生产者接口MQProducer:
- 接口实现类:
- DefaultMQProducer:默认实现,没有实现发送事务消息的方法。
- TransactionMQProducer:继承自DefaultMQProducer,实现了发送事务消息的方法。
RocketMQ消息
- rocketmq 消息封装类org.apache.rocketmq.common.message.Message
- 隐藏属性
- tags:消息TAG,用于消息过滤。
- keys:消息索引键。
- waitStoreMsgOK:消息发送时是否等消息存储完成后再返回。
- DELAY:消息延迟级别,用于定时消息或消息重试。
- 对于一些关键属性,Message类提供了一组set接口来进行设置:
- 这几个set接口对应的作用分别为为:
属性 | 接口 | 用途 |
---|---|---|
MessageConst.PROPERTY_TAGS | setTags | 在消费消息时可以通过tag进行消息过滤判定 |
MessageConst.PROPERTY_KEYS | setKeys | 可以设置业务相关标识,用于消费处理判定,或消息追踪查询 |
MessageConst.PROPERTY_DELAY_TIME_LEVEL | setDelayTimeLevel | 消息延迟处理级别,不同级别对应不同延迟时间 |
MessageConst.PROPERTY_WAIT_STORE_MSG_OK | setWaitStoreMsgOK | 在同步刷盘情况下是否需要等待数据落地才认为消息发送成功 |
MessageConst.PROPERTY_BUYER_ID | setBuyerId | 没有在代码中找到使用的地方,所以暂不明白其用处 |
这几个字段为什么用属性定义,而不是单独用一个字段进行表示?
- 方便之处可能在于消息数据存盘结构早早定义,一些后期添加上的字段功能为了适应之前的存储结构,以属性形式存储在一个动态字段更为方便,自然兼容。
MessageExt
- 对于发送方来说,上述Message的定义以足够。但对于RocketMQ的整个处理流程来说,还需要更多的字段信息用以记录一些必要内容,比如消息的id、创建时间、存储时间等等。
- Message还有一个名为 MessageConst.PROPERTY_UNIQ_CLIENT_MESSAGE_ID_KEYIDX(UNIQ_KEY) 的属性, 在消息发送时由Producer生成创建。而 msgId 则是消息在Broker端进行存储时通过 MessageDecoder.createMessageId 方法生成的,其构成为:
- 这个MsgId是在Broker生成的,Producer在发送消息时没有该信息,Consumer 在消费消息时则能获取到该值。
生产者启动流程
- 在创建好Producer之后,使用它来发消息之前,需要先启动它,即调用它的start()方法,代码如下:
- 主要看DefaultMQProducerImpl的启动,代码如下:
消息发送流程
- 消息发送流程主要是:验证消息、查找路由、消息发送(包含异常处理机制)。
- 消息验证,主要是进行消息的⻓度验证。
- 一个topic有多个队列,分散在不同的broker。producer在发送消息的时候,需要选择一个队列。
- producer发送消息全局时序图如下:
同步发送
- DefaultMQProducer#send(Message):
- DefaultMQProducerImpl#sendDefaultImpl():
查找路由
- 消息发送之前,首先需要获取主题的路由信息。如果是第一次,则会从namesrv获取topic元数据,获取后会缓存下来,以后从缓存中获取。
选择消息队列
- TopicPublishInfo#selectOneMessageQueue
批量发送
- 单条消息发送时,消息体的内容将保存在body中,批量消息发送,需要将多条消息的内容存储在body中,RocketMQ 对多条消息内容进行固定格式进行存储。
- DefaultMQProducer#send()方法定义如下:
- DefaultMQProducer#batch()方法的实现:
- 序列化MessageBatch的过程:
- 单独一条消息的序列化过程:
消息存储
文件存储路径
- 默认存储路径:
public class MessageStoreConfig {
@ImportantField
private String storePathRootDir = System.getProperty("user.home") + File.separator + "store";
// ...
}
- 可配置:conf/broker.conf
# 存储路径
storePathRootDir=${path}
文件介绍
- 下面对上面的文件进行逐一介绍:
CommitLog
- 一台Broker服务器只有一个CommitLog文件(组),RocketMQ会将所有主题的消息存储在同一个文件中,这个文件中就存储着一条条Message,每条Message都会按照顺序写入。
存储的内容到底⻓什么样子
- 消息的格式,即一条消息包含哪些字段;每个字段所占的字节大小。
- MessageDecoder#decode():
commitlog特点
- CommitLog 目前存储在 MappedFile 有两种内容类型:
- MESSAGE:消息
- BLANK:文件不足以存储消息时的空白占位。
- 关键方法:CommitLog#getMinOffset、CommitLog#rollNextFile、CommitLog#getMessage
- 存储消息 CommitLog#putMessage
刷盘策略:FlushCommitLogService
- CommitLog中提交执行的顺序:
// 刷盘服务,进行同步||异步 flush||commit
handleDiskFlush(result, putMessageResult, msg);
- handleDiskFlush 三种情况:
线程服务 | 场景 | 插入消息性能 |
---|---|---|
CommitRealTimeService | 异步刷盘 && 开启内存字节缓冲区 | 第一 |
FlushRealTimeService | 异步刷盘 && 关闭内存字节缓冲区 | 第二 |
GroupCommitService | 同步刷盘 | 第三 |
ConsumeQueue
解析文件
- 为了加速ConsumeQueue消息条目的检索速度和节省磁盘空间,每一个ConsumeQueue条目不会存储消息的全量信息,其格式设计如下图所示:
消费消息
ConsumeQueue#getIndexBuffer:
- 然后拿到消息在 CommitLog#getMessage 文件中的偏移量和消息⻓度,获取消息。
Index
index索引文件
- RocketMQ引入Hash索引机制,为消息建立索引,它的键就是Message Key 和 Unique Key。
- HashMap的设计包括两个基本点:Hash槽与Hash冲突的链表结构。
- 那么,我们先看看index索引文件的结构
- IndexFile组成介绍:从上面的那个图可以看出,IndexFile 总共包含 IndexHeader、 Hash 槽、 Hash 条目(数据)
IndexHeader
- 头部,包含40字节,记录该IndexFile的统计信息,其结构如下:
- beginTimestamp:该索引文件中包含消息的最小存储时间。
- endTimestamp:该索引文件中包含消息的的最大存储时间。
- beginPhyoffset:该索引文件中包含消息的最小物理偏移量(commitlog文件偏移量)。
- endPhyoffset:该索引文件中包含消息的最大物理偏移量(commiglog文件偏移量)。
- hashslotCount:hashslot个数,并不是hash槽使用的个数,在这里意义不大。
- indexCount:Index条目列表当前已使用的个数,Index条目在Index条目列表中按照顺序存 储。
Hash槽
- 一个IndexFile默认包含500w个Hash槽。每个Hash槽存储的是落在该Hash槽的hashcode最新的 Index 索引。
Index条目列表
- 默认一个索引文件包含2000w个条目,每一个Index条目结构如下:
- hashcode:key的hashcode。
- phyoffset:消息对应的物理偏移量。
- timedif:该消息存储时间和第一条消息的时间戳的差值,小于0该消息无效。
- preIndexNo:该条目的前一条记录的Index索引,当出现hash冲突的时候,构建链表结构。
构建索引
- 我们发送的消息体中,包含 Message Key 或 Unique Key ,那么就会给它们每一个都构建索引。
- 这里重点有两个:
- 根据消息Key计算Hash槽的位置;
- 根据Hash槽的数量和Index索引来计算Index条目的起始位置。
- 将当前 Index条目 的索引值,写在Hash槽 absSlotPos 位置上;将Index条目的具体信息(hashcode/消息偏移量/时间差值/hash槽的值) ,从起始偏移量 absIndexPos 开始,顺序按字节写入。
- 参考代码:IndexFile#putKey()
- 这样构建完Index索引之后,根据 Message Key 或 Unique Key 查询消息就简单了。
- 比如通过 RocketMQ 客户端工具,根据 Unique Key 来查询消息。
- 在 Broker 端,通过 Unique Key 来计算Hash槽的位置,从而找到Index索引数据。从Index索引中拿到消息的物理偏移量,最后根据消息物理偏移量,直接到 CommitLog 文件中去找就可以了。
Checkpoint文件
- checkpoint的作用是记录Commitlog、ConsumeQueue、Index文件的刷盘时间点,文件固定⻓度为 4K,其中只用该文件的前面24个字节,其存储格式如下图所示:
- physicMsgTimestamp:commitLog文件刷盘时间点
- logicsMsgTimestamp:消息消费队列文件刷盘时间点
- indexMsgTimestamp:索引文件刷盘时间点
文件清除机制
- RocketMQ顺序写Commitlog、ConsumeQueue文件,所有写操作全部落在最后一个 CommitLog 或ConsumeQueue文件上,之前的文件在下一个文件创建后,将不会再被更新。
- RocketMQ清除过期文件的方法是:如果非当前写文件在一定时间间隔内没有再次被更新,则认为是过期文件,可以被删除,RocketMQ不会管这个这个文件上的消息是否被全部消费。默认每个文件的过期时间为72小时。通过在Broker配置文件中设置fileReservedTime来改变过期时间,单位为小时。
public class MessageStoreConfig {
private int fileReservedTime = 72;
// ...
}
- 主要清除CommitLog、ConsumeQueue的过期文件。CommitLog 与 ConsumeQueue 对于过期文件 的删除算法、逻辑大同小异,以 CommitLog 过期文件为例来详细分析其实现原理。
// DefaultMessageStore$CleanCommitLogService
class CleanCommitLogService {
// ...
public void run() {
try {
// 1、尝试删除过期文件
this.deleteExpiredFiles();
// 2、重试删除被hange(由于被其他线程引用在第一阶段未删除的文件),在这里再重试一次
this.redeleteHangedFile();
} catch (Throwable e) {
DefaultMessageStore.log.warn(this.getServiceName() + " service has exception. ", e);
}
}
// ...
}
整个执行过程分为两个大的步骤
- DefaultMessageStore$CleanCommitLogService#deleteExpiredFiles:在如下三种情况任意满足之一的情况下将继续执行删除文件操作
- 到了删除文件的时间点,RocketMQ通过deleteWhen设置一天的固定时间执行一次删除过期文件操作,默认为凌晨4点。
- 判断磁盘空间是否充足,如果不充足,则返回true,表示应该触发过期文件删除操作。
- 预留,手工触发,可以通过调用excuteDeleteFilesManualy方法手工触发过期文件删除,目前RocketMQ暂未封装手工触发文件删除的命令。
- 重点分析一下磁盘不足的判断依据,可以跟进去方法:DefaultMessageStore$CleanCommitLogService#isSpaceToDelete
Consumer
消费者类图
- DefaultMQPushConsumer:
消费者启动
- 代码入口,DefaultMQPushConsumer.start()方法
- DefaultMQPushConsumerImpl#start:
- 进入DefaultMQPushConsumerImpl.copySubscription()方法:
- 第 9 步:加载消息进度
this.offsetStore.load();
- offsetStore是用来操作消费进度的对象,看一下RemoteBrokerOffsetStore对象,push模式消费进度最后持久化在broker端,但是consumer端在内存中也持有消费进度,RemoteBrokerOffsetStore参数:
消息的拉取
- 分析一下PUSH模式下的集群模式消息拉取代码,同一个消费组内有多个消费者,一个topic主题下又有多个消费队列,那么消费者是怎么分配这些消费队列的呢?
- 从上面的启动的代码中是不是还记得在 org.apache.rocketmq.client.impl.factory.MQClientInstance#start 中,启动了pullMessageService 服务线程,这个服务线程的作用就是拉取消息,看下他的run方法:
- 从pullRequestQueue中获取pullRequest,如果pullRequestQueue为空,那么线程将阻塞直到有 pullRequest 放入,那么pullRequest 是什么时候放入的呢,有两个地方:
- 看下PullRequest类:
- 如果从pullRequestQueue中take到pullRequest,那么执行this.pullMessage(pullRequest);
- 再进入DefaultMQPushConsumerImpl.pullMessage()方法,这个方法就是整个消息拉取的关键方法:
- 这里想说一下很多地方都用到了状态,是否停止,暂停这样的属性,一般都是用volatile去修饰,在不同线程中起到通信的作用。
- 上面的6、7、8步都在进行流控判断,防止消费端压力太大,未消费消息太多。
- 第 9 步:获取主题订阅信息
- 这里通过pullRequest的messageQueue获取topic,再从rebalanceImpl中通过topic获取 SubscriptionData,作用是去broker端拉取消息的时候,broker端要知道拉取哪个topic下的信息,过滤tag是什么。
消息的消费
- 消息拉取到了之后,消费者要进行消息的消费,消息的消费主要是ConsumeMessageService线程做的,我们先看下ConsumeMessageConcurrentlyService 的构造函数,在这个构造函数中,new了一个名字叫 consumeExecutor 的线程池,在并发消费的模式下,这个线程池也就是消费消息的方式。
是否从Slave读取
- Broker 接收到消息消费者拉取请求,在获取本地堆积的消息量后,会计算服务器的消息堆积量是否大于物理内存的一定值。如果是,则标记下次从 Slave服务器拉取,计算 Slave服务器的 Broker Id,并响应给消费者。
- DefaultMessageStore#getMessage: