8、消息持久化设计
1、RocketMQ的持久化⽂件结构
消息持久化也就是将内存中的消息写⼊到本地磁盘的过程。⽽磁盘IO操作通常是⼀个很耗性能,很慢的操作,所以,对消息持久化机制的设计,是⼀个MQ产品提升性能的关键,甚⾄可以说是最为重要的核⼼也不为过。这部分我们就先来梳理RocketMQ是如何在本地磁盘中保存消息的。
在进⼊源码之前,我们⾸先需要看⼀下RocketMQ在磁盘上存了哪些⽂件。RocketMQ消息直接采⽤磁盘⽂件保存消息,默认路径在${user_home}/store⽬录。这些存储⽬录可以在broker.conf中⾃⾏指定。
存储⽂件主要分为三个部分:
- CommitLog:存储消息的元数据。所有消息都会顺序存⼊到CommitLog⽂件当中。CommitLog由多个⽂件组成,每个⽂件固定⼤⼩1G。以第⼀条消息的偏移量为⽂件名。
- ConsumerQueue:存储消息在CommitLog的索引。⼀个MessageQueue⼀个⽂件,记录当前MessageQueue被哪些消费者组消费到了哪⼀条CommitLog。
- IndexFile:为了消息查询提供了⼀种通过key或时间区间来查询消息的⽅法,这种通过IndexFile来查找消息的⽅法不影响发送与消费消息的主流程
另外,还有⼏个辅助的存储⽂件,主要记录⼀些描述消息的元数据:
- checkpoint:数据存盘检查点。⾥⾯主要记录commitlog⽂件、ConsumeQueue⽂件以及IndexFile⽂件最后⼀次刷盘的时间戳。
- config/*.json:这些⽂件是将RocketMQ的⼀些关键配置信息进⾏存盘保存。例如Topic配置、消费者组配置、消费者组消息偏移量Offset 等等⼀些信息。
- abort:这个⽂件是RocketMQ⽤来判断程序是否正常关闭的⼀个标识⽂件。正常情况下,会在启动时创建,⽽关闭服务时删除。但是如果遇到⼀些服务器宕机,或者kill -9这样⼀些⾮正常关闭服务的情况,这个abort⽂件就不会删除,因此RocketMQ就可以判断上⼀次服务是⾮正常关闭的,后续就会做⼀些数据恢复的操作。
整体的消息存储结构,如下图:

简单来说,Producer发过来的所有消息,不管是属于那个Topic,Broker都统⼀存在CommitLog⽂件当中,然后分别构建ConsumeQueue⽂件和IndexFile两个索引⽂件,⽤来辅助消费者进⾏消息检索。这种设计最直接的好处是可以较少查找⽬标⽂件的时间,让消息以最快的速度落盘。对⽐Kafka存⽂件时,需要寻找消息所属的Partition⽂件,再完成写⼊。当Topic⽐较多时,这样的Partition寻址就会浪费⾮常多的时间。所以Kafka不太适合多Topic的场景。⽽RocketMQ的这种快速落盘的⽅式,在多Topic的场景下,优势就⽐较明显了。
然后在⽂件形式上:
CommitLog⽂件的⼤⼩是固定的。⽂件名就是当前CommitLog⽂件当中存储的第⼀条消息的Offset。
ConsumeQueue⽂件主要是加速消费者进⾏消息索引。每个⽂件夹对应RocketMQ中的⼀个MessageQueue,⽂件夹下的⽂件记录了每个MessageQueue中的消息在CommitLog⽂件当中的偏移量。这样,消费者通过ConsumeQueue⽂件,就可以快速找到CommitLog⽂件中感兴趣的消息记录。⽽消费者在ConsumeQueue⽂件中的消费进度,会保存在config/consumerOffset.json⽂件当中。
IndexFile⽂件主要是辅助消费者进⾏消息索引。消费者进⾏消息消费时,通过ConsumeQueue⽂件就⾜够完成消息检索了,但是如果消费者指定时间戳进⾏消费,或者要按照MeessageId或者MessageKey来检索⽂件,⽐如RocketMQ管理控制台的消息轨迹功能,ConsumeQueue⽂件就不够⽤了。IndexFile⽂件就是⽤来辅助这类消息检索的。他的⽂件名⽐较特殊,不是以消息偏移量命名,⽽是⽤的时间命名。但是其实,他也是⼀个固定⼤⼩的⽂件。
了解了RocketMQ的这些基础知识后,接下来我们就可以抽象出⼏个核⼼的问题,协助分析RocketMQ的源码。
- RocketMQ为了提升⽂件的写⼊速度,引⼊了和Kafka类似的顺序写机制。 但是这个顺序写到底是怎么回事呢?
- RocketMQ在broker.conf⽂件中,提供了刷盘⽅式的配置项flushDiskType,有两个配置项ASYNC_FLUSH 异步刷盘和 SYNC_FLUSH 同步刷盘。 这两种刷盘⽅式的本质是什么样的呢?
- RocketMQ管理这些⽇志⽂件的完整⽅案是什么样的?⽂件如果⼀直写⼊,迟早把硬盘撑满。RocketMQ是如何管理的呢?
由此,可以拓展出⼀些更深层次的问题,这是对RocketMQ存盘⽂件最基础的了解,但是只有这样的设计,是不⾜以⽀撑RocketMQ的三⾼性能的。RocketMQ如何保证ConsumeQueue、IndexFile两个索引⽂件与CommitLog中的消息对⻬?如何保证消息断电不丢失?如何保证⽂件⾼效的写⼊磁盘?等等。如果你想要去抓住RocketMQ这些三⾼问题的核⼼设计,那么还是需要到源码当中去深究。
2、commitLog写⼊
消息存储的⼊⼝在: DefaultMessageStore.asyncPutMessage⽅法
1、在进⾏消息写⼊前,如何进⾏的加锁?
加锁时,先通过topicQueueLock锁对列,因为数据是以MessageQueue位单位传⼊进来的,锁对列可以保证同⼀个MessageQueue的数据是按顺序写⼊的。然后通过putMessageLock锁写⼊操作。保证同⼀时刻,只有⼀个线程在写⼊消息。
另外,putMessageLock可以根据配置信息选择是SpingLock⾃旋锁还是ReentrantLock可重⼊锁。⾃旋锁就是⼀直尝试CAS直到拿到锁。ReentrantLock做⼀次CAS,拿不到就休眠,直到前⾯线程unlock的时候唤醒,继续竞争锁(⾮公平) 。两者的区别在于如果写⼊的消息⾮常多,竞争⾮常激烈,适合⽤ReentrantLock,减少CPU空转。竞争没有那么激烈,则适合⽤⾃旋锁,得到锁的速度更快。
2、 CommitLog⽂件是按照顺序写的⽅式写⼊的。 result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
这个mappedFile是RocketMQ⾃⼰实现的⼀个DefaultMappedFIle。 appendMessage⽅法就是关于顺序写,最好的实际案例。⽐任何⼋股⽂都真实可信。
3、消息写⼊后如何刷盘
应⽤程序只能将⽂件写⼊到PageCache内存中,这是断点就丢失的。只有调⽤刷盘操作后,将数据写⼊磁盘,才能保证断电不丢失。RocketMQ是如何控制刷盘这件事的?CommitLog⽂件写⼊数据后,就会进⾏刷盘 return handleDiskFlushAndHA(putMessageResult, msg, needAckNums, needHandleHA);
3、⽂件同步刷盘与异步刷盘
RocketMQ实现了⾮常简单⽅便的刷盘⽅式配置。 在broker配置⽂件⾥,flushDiskType参数就可以直接配置刷盘⽅式。这个参数有两个选项SYNC_FLUSH就是同步刷盘,ASYNC_FLUSH 。
关于同步刷盘和异步刷盘的区别,简单理解就是Broker写⼊消息时是不是⽴即进⾏刷盘。来⼀条消息就进⾏⼀次刷盘,这就是同步刷盘,这样数据安全性更⾼。过⼀点时间进⾏⼀次刷盘,这就是异步刷盘,这样操作系统的IO执⾏效率更⾼。那么事实到底是什么样的呢?最好的⽅法当然是到源码中验证⼀下。
⼊⼝:CommitLog.handleDiskFlush org.apache.rocketmq.store.CommitLog下的GroupCommitService线程的run⽅法
js
@Override
public CompletableFuture<PutMessageStatus> handleDiskFlush(AppendMessageResult result, MessageExt messageExt) {
// Synchronization flush 同步刷盘
if (FlushDiskType.SYNC_FLUSH == CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
if (messageExt.isWaitStoreMsgOK()) {//构建request的时候从配置⽂件中读取了刷盘超时时间,默认5秒。
GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
CommitLog.this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
flushDiskWatcher.add(request);//这⾥只是监控刷盘是否超时。
service.putRequest(request);//实际进⾏刷盘,刷盘操作先排队,再执⾏。
return request.future();
} else {
service.wakeup();
return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
}
}
// Asynchronous flush
else {
if (!CommitLog.this.defaultMessageStore.isTransientStorePoolEnable()) {//默认false
flushCommitLogService.wakeup();
} else {
commitRealTimeService.wakeup();
}
return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
}
}
先来看同步刷盘
同步刷盘通过GroupCommitService完成,同步刷盘的流程:
这种读写对列双缓存的设计,可以有效的提⾼⾼并发场景下的数据⼀致性问题。
之前在介绍同步刷盘和异步刷盘时,会简单的说同步刷盘就是来⼀条消息进⾏⼀次刷盘。但是从源码中分析出来的却并不是这么简单。因为你要知道刷盘对操作系统来说是⼀个很重的操作。过于频繁的调⽤刷盘操作,会给操作系统带来很⼤的IO负担。这⾥也需要思考两个问题:
1、传统⼋股⽂会说同步刷盘可以保证消息安全。因为消息尽快写到了磁盘当中,断电就不会丢失。但是,实际情况是,RocketMQ的同步刷盘在后台任务中同样是要休眠的,意味着,消息写⼊PageCache缓存再到写⼊磁盘,这中间依然是会有时间差的。这意味着同样会有断电丢失的可能。那为什么普遍都认为配置同步刷盘就可以保证消息安全呢?
2、从RocketMQ中可以看到,对于刷盘操作,并不是简单的想怎么调⽤就怎么调⽤。当调⽤刷盘操作过于频繁时,是需要进⾏优化的。那么,是不是可以回顾下Kafka中的刷盘频率是怎么配置的?刷盘间隔时间log.flush.interval.ms可以设置成1吗?
然后看异步刷盘
默认情况下,是使⽤CommitRealTimeService线程来进⾏刷盘。 this.commitRealTimeService = new CommitLog.CommitRealTimeService();
这个异步刷盘的过程就相对简单⼀些。就是休眠⼀段时间,⼲⼀次活。休眠间隔由配置⽂件指定。
核⼼流程,简短出如下⼏个步骤:
js
//获取配置的刷盘间隔时间。默认200毫秒
int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitIntervalCommitLog();
try{
boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);//主要是提交commitWhere参数
CommitLog.this.flushManager.wakeUpFlush(); //waitForRunning会阻塞线程,wakeup后会继续执⾏线程
this.waitForRunning(interval);//休眠⼀次间隔时间
} catch(){
}
//提交失败,重试,最多⼗次。
boolean result = false;
for (int i = 0; i < RETRY_TIMES_OVER && !result; i++) {
result = CommitLog.this.mappedFileQueue.commit(0);
CommitLog.log.info(this.getServiceName() + " service shutdown, retry " + (i + 1) + " times " + (result ? "OK" : "Not OK"));
}
这⾥⽐较好玩的是RocketMQ对于后台线程的并发控制。有兴趣可以关注⼀下。RocketMQ提供了⼀个⾃⼰实现的CountDownLatch2⼯具类来提供线程阻塞功能,使⽤CAS驱动CountDownLatch2的countDown操作。每来⼀个消息就启动⼀次CAS,成功后,调⽤⼀次countDown。⽽这个CountDonwLatch2在Java.util.concurrent.CountDownLatch的基础上,实现了reset功能,这样可以进⾏对象重⽤。如果你对JUC并发编程感兴趣,那么这也是⼀个不错的学习点。
4、CommitLog主从复制
⼊⼝:CommitLog.handleDiskFlushAndHA
在主要的DefaultHAService中,会在启动过程中启动三个守护进程。
js
//DefaultHAService#start
@Override
public void start() throws Exception {
this.acceptSocketService.beginAccept(); //维护主从⻓连接
this.acceptSocketService.start();
this.groupTransferService.start(); //主从同步,跟主节点相关
this.haConnectionStateNotificationService.start(); //主从同步,跟从节点相关
if (haClient != null) {
this.haClient.start();
}
}
这其中与Master相关的是acceptSocketService和groupTransferService。其中acceptSocketService主要负责维护Master与Slave之间的TCP连接。groupTransferService主要与主从同步复制有关。⽽slave相关的则是haClient。
⾄于其中关于主从的同步复制与异步复制的实现流程,还是⽐较复杂的。相⽐4.x版本,还添加了集群⾃动选主功能的⽀持,有兴趣的同学可以深⼊去研究⼀下。
这⾥只抽象出两个重点关注的地⽅:
- groupTransferService同样采⽤了读写双Buffer的⽅法。可⻅这种⽅案在RocketMQ中是⾮常认可的,也可以作为处理⾼并发请求的⼀种经验。
- 在主从同步中,RocketMQ对于性能进⾏极致追求,甚⾄放弃了完整的Netty请求⽅案,⽽转⽤更轻量级的Java的NIO来构建。
5、分发ConsumeQueue和IndexFile
当CommitLog写⼊⼀条消息后,在DefaultMessageStore的start⽅法中,会启动⼀个后台线程reputMessageService。源码就定义在DefaultMessageStore中。这个后台线程每隔1毫秒就会去拉取CommitLog中最新更新的⼀批消息。如果发现CommitLog中有新的消息写⼊,就会触发⼀次doDispatch。
js
//org.apache.rocketmq.store.DefaultMessageStore中的ReputMessageService线程类
public void doDispatch(DispatchRequest req) {
for (CommitLogDispatcher dispatcher : this.dispatcherList) {
dispatcher.dispatch(req);
}
}
dispatchList中包含两个关键的实现类CommitLogDispatcherBuildConsumeQueue和CommitLogDispatcherBuildIndex。源码就定义在DefaultMessageStore中。他们分别⽤来构建ConsumeQueue索引和IndexFile索引。
具体的构建逻辑⽐较复杂,有兴趣同学可以⾃⾏研究。
并且,如果服务异常宕机,会造成CommitLog和ConsumeQueue、IndexFile⽂件不⼀致,有消息写⼊CommitLog后,没有分发到索引⽂件,这样消息就丢失了。DefaultMappedStore的load⽅法提供了恢复索引⽂件的⽅法,⼊⼝在load⽅法。
6、过期⽂件删除机制
⼊⼝: DefaultMessageStore.addScheduleTask -> DefaultMessageStore.this.cleanFilesPeriodically() 和
DefaultMessageStore.this.cleanQueueFilesPeriodically()
在这个⽅法中会启动两个线程,cleanCommitLogService⽤来删除过期的CommitLog⽂件,cleanConsumeQueueService⽤来删除过期的ConsumeQueue和IndexFile⽂件。
在删除CommitLog⽂件时,Broker会启动后台线程,每60秒,检查CommitLog、ConsumeQueue⽂件。然后对超过72⼩时的数据进⾏删除。也就是说,默认情况下, RocketMQ只会保存3天内的数据。这个时间可以通过fileReservedTime来配置。
触发过期⽂件删除时,有两个检查的纬度,⼀个是,是否到了触发删除的时间,也就是broker.conf⾥配置的deleteWhen属性。另外还会检查磁盘利⽤率,达到阈值也会触发过期⽂件删除。这个阈值默认是72%,可以在broker.conf⽂件当中定制。但是最⼤值为95,最⼩值为10。
然后在删除ConsumeQueue和IndexFile⽂件时,会去检查CommitLog当前的最⼩Offset,然后在删除时进⾏对⻬。
需要注意的是,RocketMQ在删除过期CommitLog⽂件时,并不检查消息是否被消费过。 所以如果有消息⻓期没有被消费,是有可能直接被删除掉,造成消息丢失的。
RocketMQ整个⽂件管理的核⼼⼊⼝在DefaultMessageStore的start⽅法中,整体流程总结如下:

7、⽂件索引结构
了解了⼤部分的⽂件写⼊机制之后,最后我们来理解⼀下RocketMQ的索引构建⽅式。
1、CommitLog⽂件的⼤⼩是固定的,但是其中存储的每个消息单元⻓度是不固定的,具体格式可以参考org.apache.rocketmq.store.CommitLog中计算消息⻓度的⽅法
js
protected static int calMsgLength(int sysFlag, int bodyLength, int topicLength, int propertiesLength) {
int bornhostLength = (sysFlag & MessageSysFlag.BORNHOST_V6_FLAG) == 0 ? 8 : 20;
int storehostAddressLength = (sysFlag & MessageSysFlag.STOREHOSTADDRESS_V6_FLAG) == 0 ? 8 : 20;
final int msgLen = 4 //TOTALSIZE
+ 4 //MAGICCODE
+ 4 //BODYCRC
+ 4 //QUEUEID
+ 4 //FLAG
+ 8 //QUEUEOFFSET
+ 8 //PHYSICALOFFSET
+ 4 //SYSFLAG
+ 8 //BORNTIMESTAMP
+ bornhostLength //BORNHOST
+ 8 //STORETIMESTAMP
+ storehostAddressLength //STOREHOSTADDRESS
+ 4 //RECONSUMETIMES
+ 8 //Prepared Transaction Offset
+ 4 + (bodyLength > 0 ? bodyLength : 0) //BODY
+ 1 + topicLength //TOPIC
+ 2 + (propertiesLength > 0 ? propertiesLength : 0) //propertiesLength
+ 0;
return msgLen;
}
正因为消息的记录⼤⼩不固定,所以RocketMQ在每次存CommitLog⽂件时,都会去检查当前CommitLog⽂件空间是否⾜够,如果不够的话,就重新创建⼀个CommitLog⽂件。⽂件名为当前消息的偏移量。
2、ConsumeQueue⽂件主要是加速消费者的消息索引。他的每个⽂件夹对应RocketMQ中的⼀个MessageQueue,⽂件夹下的⽂件记录了每个MessageQueue中的消息在CommitLog⽂件当中的偏移量。这样,消费者通过ComsumeQueue⽂件,就可以快速找到CommitLog⽂件中感兴趣的消息记录。⽽消费者在ConsumeQueue⽂件当中的消费进度,会保存在config/consumerOffset.json⽂件当中。
⽂件结构: 每个ConsumeQueue⽂件固定由30万个固定⼤⼩20byte的数据块组成,数据块的内容包括:msgPhyOffset(8byte,消息在⽂件中的起始位置)+msgSize(4byte,消息在⽂件中占⽤的⻓度)+msgTagCode(8byte,消息的tag的Hash值)。
msgTag是和消息索引放在⼀起的,所以,消费者根据Tag过滤消息的性能是⾮常⾼的。
在ConsumeQueue.java当中有⼀个常量CQ_STORE_UNIT_SIZE=20,这个常量就表示⼀个数据块的⼤⼩。

例如,在ConsumeQueue.java当中构建⼀条ConsumeQueue索引的⽅法 中,就是这样记录⼀个单元块的数据的。
js
private boolean putMessagePositionInfo(final long offset, final int size, final long tagsCode,final long cqOffset) {
if (offset + size <= this.maxPhysicOffset) {
log.warn("Maybe try to build consume queue repeatedly maxPhysicOffset={} phyOffset={}", maxPhysicOffset, offset);
return true;
}
this.byteBufferIndex.flip();
this.byteBufferIndex.limit(CQ_STORE_UNIT_SIZE);
this.byteBufferIndex.putLong(offset);
this.byteBufferIndex.putInt(size);
this.byteBufferIndex.putLong(tagsCode);
//.......
}
3、IndexFile⽂件主要是辅助消息检索。他的作⽤主要是⽤来⽀持根据key和timestamp检索消息。他的⽂件名⽐较特殊,不是以消息偏移量命名,⽽是⽤的时⽂件结构: 他的⽂件结构由 indexHeader(固定40byte)+ slot(固定500W个,每个固定20byte) + index(最多500W*4个,每个固定20byte) 三个部分组成。间命名。但是其实,他也是⼀个固定⼤⼩的⽂件。
⽂件结构: 他的⽂件结构由 indexHeader(固定40byte)+ slot(固定500W个,每个固定20byte) + index(最多500W*4个,每个固定20byte) 三个部分组成。
9、延迟消息机制
1、关注重点
⽬前版本RocketMQ中提供了两种延迟消息机制,⼀种是指定固定的延迟级别。通过在Message中设定⼀个MessageDelayLevel参数,对应18个预设的延迟级别。另⼀种是指定固定的时间点。通过在Message中设定⼀个DeliverTimeMS指定⼀个Long类型表示的具体时间点。到了时间点后,RocketMQ会⾃动发送消息。
延迟消息是RocketMQ相⽐其他MQ产品,⾮常有特⾊的⼀个功能。同时,延迟任务也是我们在开发过程中经常会遇⻅的功能需求。我们重点就是来梳理RocketMQ是如何设计这两种延迟消息机制的。
2、源码重点
⾸先来梳理第⼀种指定固定延迟级别的延迟消息
核⼼使⽤机制就是在Message中设定⼀个MessageDelayLevel参数,对应18个延迟级别。然后Broker中会创建⼀个默认的Schedule_Topic主题,这个主题下有18个队列,对应18个延迟级别。消息发过来之后,会先把消息存⼊Schedule_Topic主题中对应的队列。然后等延迟时间到了,再转发到⽬标队列,推送给消费者进⾏消费。
这类延迟消息由⼀个很重要的后台服务scheduleMessageService来管理。 他会在broker启动时也⼀起加载。
1、消息写⼊到系统内置的Topic中
Broker在处理消息之前,会注册⼀系列的钩⼦,类似于过滤器,对消息做⼀些预处理。其中就会对延迟消息做处理。
其中HookUtils中有⼀个⽅法,就会在Broker处理消息之前对延迟消息做⼀些特殊处理。
js
public static PutMessageResult handleScheduleMessage(BrokerController brokerController,final MessageExtBrokerInner msg) {
final int tranType = MessageSysFlag.getTransactionValue(msg.getSysFlag());
if (tranType == MessageSysFlag.TRANSACTION_NOT_TYPE|| tranType == MessageSysFlag.TRANSACTION_COMMIT_TYPE) {
if (!isRolledTimerMessage(msg)) {
if (checkIfTimerMessage(msg)) {
if (!brokerController.getMessageStoreConfig().isTimerWheelEnable()) {
//wheel timer is not enabled, reject the message
return new PutMessageResult(PutMessageStatus.WHEEL_TIMER_NOT_ENABLE, null);
}//转移指定时间点的延迟消息
PutMessageResult transformRes = transformTimerMessage(brokerController, msg);
if (null != transformRes) {
return transformRes;
}
}
}
// Delay Delivery
if (msg.getDelayTimeLevel() > 0) {//转移固定延迟级别
transformDelayLevelMessage(brokerController, msg);
}
}
return null;
}
在这个⽅法中就对消息属性进⾏判断。如果是延迟消息,就会转发到系统内置的Topic中。
固定延迟级别的延迟消息,转移到SCHEDULE_TOPIC_XXXX Topic中,对列对应延迟级别。
transformDelayLevelMessage⽅法就会修改消息的⽬标Topic和队列。接下来Broker就会将消息像正常消息⼀样写⼊到系统内置的延迟Topic中。这个Topic下默认18个队列,就对应18个预设的延迟级别。
js
//K9 固定延迟级别的消息,直接转存到 SCHEDULE_TOPIC_XXXX TOPIC下
public static void transformDelayLevelMessage(BrokerController brokerController, MessageExtBrokerInner msg) {
if (msg.getDelayTimeLevel() > brokerController.getScheduleMessageService().getMaxDelayLevel()) {
msg.setDelayTimeLevel(brokerController.getScheduleMessageService().getMaxDelayLevel());
}
//保留消息的原始Topic和队列
// Backup real topic, queueId
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
//修改成系统内置的Topic和队列
msg.setTopic(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC);
msg.setQueueId(ScheduleMessageService.delayLevel2QueueId(msg.getDelayTimeLevel()));
}
指定时间点的延迟消息,转移到rmq_sys_wheel_timer Topic中,对列固定为0。
transformTimerMessage⽅法会对指定时间点的延迟消息进⾏检查。如果还没有到指定的时间点,同样修改消息的⽬标Topic和队列,接下来Broker就会将消息转移到rmq_sys_wheel_timer Topic中,对列固定为0
js
//K9 指定时间点的延迟消息,转移到rmq_sys_wheel_timer Topic下
private static PutMessageResult transformTimerMessage(BrokerController brokerController,MessageExtBrokerInner msg) {
//do transform
int delayLevel = msg.getDelayTimeLevel();
long deliverMs;
try {
if (msg.getProperty(MessageConst.PROPERTY_TIMER_DELAY_SEC) != null) {
deliverMs = System.currentTimeMillis() + Long.parseLong(msg.getProperty(MessageConst.PROPERTY_TIMER_DELAY_SEC))* 1000;
} else if (msg.getProperty(MessageConst.PROPERTY_TIMER_DELAY_MS) != null) {
deliverMs = System.currentTimeMillis() + Long.parseLong(msg.getProperty(MessageConst.PROPERTY_TIMER_DELAY_MS));
} else {
deliverMs = Long.parseLong(msg.getProperty(MessageConst.PROPERTY_TIMER_DELIVER_MS));
}
} catch (Exception e) {
return new PutMessageResult(PutMessageStatus.WHEEL_TIMER_MSG_ILLEGAL, null);
}
if (deliverMs > System.currentTimeMillis()) {
if (delayLevel <= 0 && deliverMs - System.currentTimeMillis() > brokerController.getMessageStoreConfig().getTimerMaxDelaySec() * 1000L) {
return new PutMessageResult(PutMessageStatus.WHEEL_TIMER_MSG_ILLEGAL, null);
}
int timerPrecisionMs = brokerController.getMessageStoreConfig().getTimerPrecisionMs();
if (deliverMs % timerPrecisionMs == 0) {
deliverMs -= timerPrecisionMs;
} else {
deliverMs = deliverMs / timerPrecisionMs * timerPrecisionMs;
}
if (brokerController.getTimerMessageStore().isReject(deliverMs)) {
return new PutMessageResult(PutMessageStatus.WHEEL_TIMER_FLOW_CONTROL, null);
}
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_TIMER_OUT_MS, deliverMs + "");
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_TOPIC, msg.getTopic());
MessageAccessor.putProperty(msg, MessageConst.PROPERTY_REAL_QUEUE_ID, String.valueOf(msg.getQueueId()));
msg.setPropertiesString(MessageDecoder.messageProperties2String(msg.getProperties()));
msg.setTopic(TimerMessageStore.TIMER_TOPIC);
msg.setQueueId(0);
} else if (null != msg.getProperty(MessageConst.PROPERTY_TIMER_DEL_UNIQKEY)) {
return new PutMessageResult(PutMessageStatus.WHEEL_TIMER_MSG_ILLEGAL, null);
}
return null;
}
2、固定延迟级别的消息,处理后转储回业务指定的Topic
延迟消息被转存到系统的Topic下之后,接下来就是要启动⼀系列的定时任务。延迟时间到了后,再将消息转储回到Producer提交的业务Topic和Queue中,这样就可以正常被消费者消费了。
这个转储的核⼼服务是scheduleMessageService,他也是Broker启动过程中的⼀个功能组件。随BrokerController组件⼀起构建。这个服务只在master节点上启动,⽽在slave节点上会主动关闭这个服务。
js
//org.apache.rocketmq.broker.BrokerController
//K4 Broker向NameServer进⾏⼼跳注册
if (!isIsolated && !this.messageStoreConfig.isEnableDLegerCommitLog() && !this.messageStoreConfig.isDuplicationEnable()) {
//这⾥⾯会启动处理延迟消息的scheduleMessageService。这些服务只在Master上启动。
changeSpecialServiceStatus(this.brokerConfig.getBrokerId() == MixAll.MASTER_ID);
this.registerBrokerAll(true, false, true);
}
//后⾯会开始加载scheduleMessageService
result = result && this.scheduleMessageService.load();
//具体的启动⽅法
public synchronized void changeScheduleServiceStatus(boolean shouldStart) {
if (isScheduleServiceStart != shouldStart) {
LOG.info("ScheduleServiceStatus changed to {}", shouldStart);
if (shouldStart) {
this.scheduleMessageService.start();
} else {
this.scheduleMessageService.stop();
}
isScheduleServiceStart = shouldStart;
if (timerMessageStore != null) {
timerMessageStore.syncLastReadTimeMs();
timerMessageStore.setShouldRunningDequeue(shouldStart);
}
}
}
由于RocketMQ的主从节点⽀持切换,所以就需要考虑这个服务的幂等性。在节点切换为slave时就要关闭服务,切换为master时就要启动服务。并且,即便节点多次切换为master,服务也只启动⼀次。所以在ScheduleMessageService的start⽅法中,就通过⼀个CAS操作来保证服务的启动状态。
if (started.compareAndSet(false, true)) {
这个CAS操作还保证了在后⾯,同⼀时间只有⼀个DeliverDelayedMessageTimerTask执⾏。这种⽅式,给整个延迟消息服务提供了⼀个基础保证。
ScheduleMessageService会每隔1秒钟执⾏⼀个executeOnTimeup任务,将消息从延迟队列中写⼊正常Topic中。 代码⻅ScheduleMessageService中的DeliverDelayedMessageTimerTask.executeOnTimeup⽅法。
在executeOnTimeup⽅法中,就会去扫描SCHEDULE_TOPIC_XXXX这个Topic下的所有messageQueue,然后扫描这些MessageQueue对应的ConsumeQueue⽂件,找到没有处理过的消息,计算他们的延迟时间。如果延迟时间没有到,就等下⼀秒再重新扫描。如果延迟时间到了,就进⾏消息转储。
将消息转回到原来的⽬标Topic下。
js
#org.apache.rocketmq.broker.schedule.ScheduleMessageService
private boolean syncDeliver(MessageExtBrokerInner msgInner, String msgId, long offset, long offsetPy,int sizePy) {//构建这个process时,将Message的消息转回了业务指定的Topic和Queue。
PutResultProcess resultProcess = deliverMessage(msgInner, msgId, offset, offsetPy, sizePy, false);
PutMessageResult result = resultProcess.get();//通过asyncPutMessage⽅法正常投递
boolean sendStatus = result != null && result.getPutMessageStatus() == PutMessageStatus.PUT_OK;
if (sendStatus) {
ScheduleMessageService.this.updateOffset(this.delayLevel, resultProcess.getNextOffset());
}
return sendStatus;
}
整个延迟消息的实现⽅式是这样的:

⽽ScheduleMessageService中扫描延迟消息的主要逻辑是这样的:
js
public void executeOnTimeUp() {
ConsumeQueueInterface cq = ////找到延迟队列对应的ConsumeQueue⽂件
ScheduleMessageService.this.brokerController.getMessageStore().getConsumeQueue(TopicValidator.RMQ_SYS_SCHEDULE_TOPIC,delayLevel2QueueId(delayLevel));
//...
ReferredIterator<CqUnit> bufferCQ = cq.iterateFrom(this.offset);
//...
long nextOffset = this.offset;
try {
while (bufferCQ.hasNext() && isStarted()) {
CqUnit cqUnit = bufferCQ.next(); //获取⼀条ConsumeQueue记录
long offsetPy = cqUnit.getPos();
int sizePy = cqUnit.getSize();
long tagsCode = cqUnit.getTagsCode();
//...
//计算下⼀个ConsumeQueue单元的位置。下⼀次扫描就从这个地⽅开始。
nextOffset = currOffset + cqUnit.getBatchNum();
long countdown = deliverTimestamp - now;
if (countdown > 0) {//还没到延迟时间
this.scheduleNextTimerTask(currOffset, DELAY_FOR_A_WHILE);
ScheduleMessageService.this.updateOffset(this.delayLevel, currOffset);
return;
}
//获取CommitLog中的实际消息
MessageExt msgExt = ScheduleMessageService.this.brokerController.getMessageStore().lookMessageByOffset(offsetPy, sizePy);
if (msgExt == null) {
continue;
}
MessageExtBrokerInner msgInner = ScheduleMessageService.this.messageTimeUp(msgExt);
//....
//时间到了就转储
boolean deliverSuc;
if (ScheduleMessageService.this.enableAsyncDeliver) {//异步投递,默认false
deliverSuc = this.asyncDeliver(msgInner, msgExt.getMsgId(), currOffset, offsetPy, sizePy);
} else {//K9 固定延迟级别的延迟消息同步投递
deliverSuc = this.syncDeliver(msgInner, msgExt.getMsgId(), currOffset, offsetPy, sizePy);
}
if (!deliverSuc) {
this.scheduleNextTimerTask(nextOffset, DELAY_FOR_A_WHILE);
return;
}
}
} catch (Exception e) {
log.error("ScheduleMessageService, messageTimeUp execute error, offset = {}", nextOffset, e);
} finally {
bufferCQ.release();
}
//部署下⼀次任务
this.scheduleNextTimerTask(nextOffset, DELAY_FOR_A_WHILE);
}
3、指定时间的延迟消息,通过时间轮算法进⾏定时计算
对于指定时间点的延迟消息,也有⼀个核⼼后台线程来处理。就是timerMessageStore。
timerMessageStore会随着scheduleMessageService⼀起加载。
js
//K8 加载timerMessageStore,处理指定时间点的延迟消息
if (messageStoreConfig.isTimerWheelEnable()) {
result = result && this.timerMessageStore.load();//处理指定时间点的延迟消息
}
在这个load⽅法中会调⽤⼀个initService⽅法,加载五个核⼼线程
js
public void initService() {
enqueueGetService = new TimerEnqueueGetService();
enqueuePutService = new TimerEnqueuePutService();
dequeueWarmService = new TimerDequeueWarmService();
dequeueGetService = new TimerDequeueGetService();
timerFlushService = new TimerFlushService();
int getThreadNum = Math.max(storeConfig.getTimerGetMessageThreadNum(), 1);
dequeueGetMessageServices = new TimerDequeueGetMessageService[getThreadNum];
for (int i = 0; i < dequeueGetMessageServices.length; i++) {
dequeueGetMessageServices[i] = new TimerDequeueGetMessageService();
}
int putThreadNum = Math.max(storeConfig.getTimerPutMessageThreadNum(), 1);
dequeuePutMessageServices = new TimerDequeuePutMessageService[putThreadNum];
for (int i = 0; i < dequeuePutMessageServices.length; i++) {
dequeuePutMessageServices[i] = new TimerDequeuePutMessageService();
}
}
这五个核⼼Service会结合TimeMeessageStore中的⼏个核⼼队列来进⾏操作。
js
protected final BlockingQueue<TimerRequest> enqueuePutQueue;
protected final BlockingQueue<List<TimerRequest>> dequeueGetQueue;
protected final BlockingQueue<TimerRequest> dequeuePutQueue;
private final TimerWheel timerWheel;
private final TimerLog timerLog;
他们的关系是这样的:

然后,核⼼的延迟任务,是使⽤⼀个TimeWheel时间轮组件来判断的,这是做定时任务时⽤得⾮常多的⼀种算法。这种算法的核⼼,其实就是时钟。 时间不断推移,时间轮就会不断吐出到期的数据。
时间轮算法有两个核⼼:
- 数据按照预设的过期时间,放到对应的slot上(时钟表上的每个秒钟刻度)。 如果数据的延迟时间超过了时间轮的最⼤数据数,就会在slot上记录⼀个轮次(钟表上当前的第1秒和第⼆天的1秒,指向同⼀个刻度,但是数据上记录⼀个轮次,就能区分天了)
- 时间轮上设置⼀个指针变量(钟表上的秒钟),指针会按固定时间前进(秒钟每秒前移⼀格)。指针指向的Slot(秒钟指向的刻度),就是当前已经到期的数据(当然,如果对应slot上的轮次>1那就没有到期,只要将数据上的轮次-1就可以了)。
RocketMQ中的时间轮算法实现是这样的:

主要以下⼏点:
1、TimerWheel整体是⼀个数组,⼯作原理可以理解为⼀个时钟盘。盘上的每个刻度是⼀个slot。每个slot记录⼀条数据的索引。所有具体的消息数据都是放到⼀个LocalBuffer缓存数组中的。每个Slot就描述⼀条或多条LocalBuffer上的具体消息数据。
整个时间轮默认slot的个数:slotsTotal={TIMER_WHEEL_TTL_DAY}x{DAY_SECS}(7 x 86400) 即七天的秒数,时间精度timerPrecisionMs=1000,也就是⼀秒。即每个slot⾥会保存1秒的消息索引。也就是说RocketMQ的延迟消息时间精度是1秒(实际上API上的延迟时间是可以设置到毫秒,但是具体执⾏时,精度只能到1秒)
也就是说七天内的数据,时钟转⼀轮就可以全部判断出来了。七天外的数据,时钟就要多转⼏轮才能判断出来。
2、在TimerMessageStore中有两个变量currReadTimeMs 和 currReadTimeMs。 这两个指针就类似于时钟上的指针。其中,currWriteTimeMs指向当前正
在写⼊数据的slot。 ⽽currReadTimeMs指向当前正在读取数据的slot。这两个变量不断往前变化,就可以像时钟的指针⼀样依次读取每⼀秒上的数据。这时候读到的slot是可以表示当前这⼀秒的数据 ,还有 时间轮转过多轮后的数据。
读到数据后,只要过滤掉以后轮次的数据,就可以拿到当前时间点的Slot数据。这样就可以通过TimerDequeueGetService调⽤TimerWheel.dequeue()⽅法从时间轮中拿出来,放到后续对列中,再继续处理。
这个时间轮算法可以随时放⼊指定过期时间的数据,然后⼜会⾃动将到了过期时间的数据吐出来。很明显,即便脱离RocketMQ的延迟消息业务场景,也是⼀个⾮常强⼤的⼯具。如果有实⼒把RocketMQ的这个时间轮算法单独抽取出来,那么以后,这就是⼀个堪⽐quartz,xxljob之类的现成⼯具了。
10、⻓轮询机制
1、功能回顾
RocketMQ对消息消费者提供了Push推模式和Pull拉模式两种消费模式。但是这两种消费模式的本质其实都是Pull拉模式,Push模式可以认为是⼀种定时的Pull机制。但是这时有⼀个问题,当使⽤Push模式时,如果RocketMQ中没有对应的数据,那难道⼀直进⾏空轮询吗?如果是这样的话,那显然会极⼤的浪费⽹络带宽以及服务器的性能,并且,当有新的消息进来时,RocketMQ也没有办法尽快通知客户端,⽽只能等客户端下⼀次来拉取消息了。针对这个问题,RocketMQ实现了⼀种⻓轮询机制 long polling。
⻓轮询机制简单来说,就是当Broker接收到Consumer的Pull请求时,判断如果没有对应的消息,不⽤直接给Consumer响应(给响应也是个空的,没意义),⽽是就将这个Pull请求给缓存起来。当Producer发送消息过来时,增加⼀个步骤去检查是否有对应的已缓存的Pull请求,如果有,就及时将请求从缓存中拉取出来,并将消息通知给Consumer。
⻓轮询机制是所有基于⻓连接进⾏消息传输的分布式系统都需要考虑的⼀个很重要的优化机制。接下来我们就来看看RocketMQ是如何实现的。
2、源码重点
Consumer请求缓存,代码⼊⼝PullMessageProcessor#processRequest⽅法
PullRequestHoldService服务会随着BrokerController⼀起启动。⽣产者线:从DefaultMessageStore.doReput进⼊
整个流程以及源码重点如下图所示:

实际上,⻓轮询机制也是在不断优化的。在之前版本中,检查PullRequestHoldService中的PullRequest,是在Producer发送消息时进⾏的。现在这个版本已经把检查过程转移到了PullRequestHoldService的后台线程中。