RocketMQ刷盘流程

前言
这里推荐两个看源码较好用的快捷键,可以提高源码阅读效率(win10系统):
1. ctrl + alt + h:查看方法的调用链
2. ctrl + h:查看类的继承关系和接口实现关系

简介

消息存储完成后,会被操作系统持久化到磁盘,也就是刷盘。
RocketMQ 支持两种刷盘方式,根据 DefaultMessageStore.getMessageStoreConfig().getFlushDiskType() 获取到的 FlushDiskType 来判定是同步刷盘或是异步刷盘,若 FlushDiskType 为 SYNC_FLUSH,则表示同步刷盘;若为 ASYNC_FLUSH,则为异步刷盘。 RocketMQ 默认为异步刷盘,且若想使用同步刷盘,则需调用 MessageStoreConfig.setFlushDiskType() 更改 FlushDiskType 为 SYNC_FLUSH。  

RocketMQ 消息刷盘机制大致分以下两部分介绍:
1. 刷盘服务线程的类型、创建和启动
2. 消息刷盘的逻辑

刷盘服务线程的类型和创建、启动

刷盘服务线程类型

消息刷盘线程分别由三个类实现:GroupCommitService、FlushRealTimeService、CommitRealTimeService,其中 GroupCommitService 负责同步刷盘服务,FlushRealTimeService 负责异步刷盘服务,CommitRealTimeService 负责异步转存服务。这三种类都是CommitLog的内部类,且都继承自 FlushCommitLogService 类。

刷盘服务线程何时创建

我们先来看看调用链:BrokerStartup.createBrokerController() -> BrokerController.initialize() -> DefaultMessageStore.DefaultMessageStore() -> CommitLog.CommitLog(),到构造CommitLog对象的时候停止,看一下CommitLog的构造函数:

public CommitLog(final DefaultMessageStore defaultMessageStore) {
    this.mappedFileQueue = new MappedFileQueue(defaultMessageStore.getMessageStoreConfig().getStorePathCommitLog(),
        defaultMessageStore.getMessageStoreConfig().getMappedFileSizeCommitLog(), defaultMessageStore.getAllocateMappedFileService());
    this.defaultMessageStore = defaultMessageStore;

    if (FlushDiskType.SYNC_FLUSH == defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
        // 同步刷盘
        this.flushCommitLogService = new GroupCommitService();
    } else {
        // 异步刷盘
        this.flushCommitLogService = new FlushRealTimeService();
    }

    // 异步转存
    this.commitLogService = new CommitRealTimeService();

    this.appendMessageCallback = new DefaultAppendMessageCallback(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
    batchEncoderThreadLocal = new ThreadLocal<MessageExtBatchEncoder>() {
        @Override
        protected MessageExtBatchEncoder initialValue() {
            return new MessageExtBatchEncoder(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
        }
    };
    this.putMessageLock = defaultMessageStore.getMessageStoreConfig().isUseReentrantLockWhenPutMessage() ? new PutMessageReentrantLock() : new PutMessageSpinLock();

}

可以看出,CommitLog 对象在 Broker 启动时的 BrokerStartup.createBrokerController() 方法中经过一系列调用创建,在创建 CommitLog 对象的时候,会根据 DefaultMessageStore.getMessageStoreConfig().getFlushDiskType() 值决定创建 GroupCommitService 或 FlushRealTimeService 对象,创建的对象由 FlushCommitLogService 类型的 flushCommitLogService 引用,也就是说,RocketMQ 每次启动仅能支持一种刷盘方式:同步或异步,而不同时支持同步和异步刷盘方式。同时会创建 CommitRealTimeService 对象,该对象由 FlushCommitLogService 类型的 commitLogService 引用

 刷盘服务线程何时启动

还是看调用链:BrokerStartup.main() -> start() -> BrokerController.start() -> DefaultMessageStore.start() -> CommitLog.start() ,可以知道,在创建完 BrokerController 对象后启动 BrokerController 时,会调用 CommitLog.start() 方法,我们看一下这个方法

public void start() {
    this.flushCommitLogService.start();

    if (defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
        this.commitLogService.start();
    }
}

 这里可以看出,进入该方法后,flushCommitLogService 线程会被启动。同时,根据 defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable() 是否开启内存缓存池(默认不开启)决定是否启动 commitLogService 线程

 小结

1. 到这里我们知道了,刷盘线程有三种,分别提供同步刷盘、异步刷盘和异步转存服务。
2. 其中,默认使用的是异步刷盘,同时默认不使用异步转存服务(即 transientStorePoolEnable 默认为 false),也就是说,RocketMQ 默认仅创建和启动 FlushRealTimeService 线程。
3. 此外,我们还知道了,刷盘服务线程在创建BrokerController的时候创建,在启动BrokerController的时候被启动。
4. 当内存缓存池 TransientStorePool 可用时((即 transientStorePoolEnable 为 true),消息会先提交到 TransientStorePool 中的 WriteBuffer 内部,再提交到 MappedFile 的 FileChannle 中,此时异步刷盘服务就是 CommitRealTimeService。
接下来,我们来看一下线程是如何刷盘的。

 消息刷盘的逻辑

 消息刷盘的方法 -- handleDiskFlush()

 在 CommitLog.putMessage() 方法中,会完成消息写到内存中的任务(这个过程不展开说了,需要了解的读者可以到这篇博文来查看),在该方法的末尾,会调用 handleDiskFlush() ,部分代码如下:

public PutMessageResult putMessage(final MessageExtBrokerInner msg) {

    // 消息存储到内存的过程(省略)

    if (null != unlockMappedFile && this.defaultMessageStore.getMessageStoreConfig().isWarmMapedFileEnable()) {
        this.defaultMessageStore.unlockMappedFile(unlockMappedFile);
    }

    PutMessageResult putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result);

    // Statistics
    storeStatsService.getSinglePutMessageTopicTimesTotal(msg.getTopic()).incrementAndGet();
    storeStatsService.getSinglePutMessageTopicSizeTotal(topic).addAndGet(result.getWroteBytes());

    // 刷盘,消息持久化
    handleDiskFlush(result, putMessageResult, msg);
    // 主从同步
    handleHA(result, putMessageResult, msg);

    return putMessageResult;
}

CommitLog.handleDiskFlush() 方法中,会根据 flushDiskType 值来决定是同步刷盘还是异步刷盘,下面分别进行介绍。

 同步刷盘

 同步刷盘大致可以分为两部分:
1. 构造写消息请求并唤醒刷盘线程
2. 刷盘线程执行run()代码体进行消息刷盘

1. 构造写消息请求并唤醒刷盘线程

下面来看看 CommitLog.handleDiskFlush() 方法,我们先看同步刷盘的逻辑:在该部分代码中主要做了以下四件事(结合代码):
@1:根据 getFlushDiskType() 方法获得的 flushDiskType 变量的值判断是否使用同步刷盘,若是(即该变量值为 SYNC_FLUSH),则获取 flushCommitLogService 同步刷盘线程
@2:构造刷盘请求,该请求为一个 GroupCommitRequest 对象
@3:将刷盘请求放入线程(即 GroupCommitService 对象)的写请求队列
@4:同步等待获取结果

public void handleDiskFlush(AppendMessageResult result, PutMessageResult putMessageResult, MessageExt messageExt) {
    // Synchronization flush
    if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
        final GroupCommitService service = (GroupCommitService)  this.flushCommitLogService;      //@1 
        if (messageExt.isWaitStoreMsgOK()) {
            GroupCommitRequest request = new GroupCommitRequest  (result.getWroteOffset() + result.getWroteBytes());   //@2
            service.putRequest(request);   //@3
            CompletableFuture<PutMessageStatus> flushOkFuture = request.future();   //@4
            PutMessageStatus flushStatus = null;
            try {
                flushStatus = flushOkFuture.get(this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout(),
                        TimeUnit.MILLISECONDS);
            } catch (InterruptedException | ExecutionException | TimeoutException e) {
                //flushOK=false;
            }
            if (flushStatus != PutMessageStatus.PUT_OK) {
                log.error("do groupcommit, wait for flush failed, topic: " + messageExt.getTopic() + " tags: " + messageExt.getTags()
                    + " client address: " + messageExt.getBornHostString());
                putMessageResult.setPutMessageStatus(PutMessageStatus.FLUSH_DISK_TIMEOUT);
            }
        }else {
            service.wakeup();
        }
    }

    // 异步刷盘
}

接下来进入 CommitLog.GroupCommitService.putRequest() 方法,可知该方法做了如下两件事(代码注释):

public synchronized void putRequest(final GroupCommitRequest request) {
    synchronized (this.requestsWrite) {
        // 1. 将刷盘请求添加进写请求队列
        this.requestsWrite.add(request);
    }
    // 2. 唤醒刷盘线程处理请求
    this.wakeup();
}

 再看看 ServiceThread.wakeup() 方法,查看代码可知,该方法用于唤醒刷盘线程,以实现主线程(存储消息线程)和刷盘线程间的协调

public void wakeup() {
    // hasNotified默认为false,因此compareAndSet方法返回true,同时会将hasNotified修改为true
    if (hasNotified.compareAndSet(false, true)) {
        // waitPoint 是 CountDownLatch2 类型对象,count 值为1,因此 countDown 执行后会唤醒刷盘线程
        waitPoint.countDown(); // notify
    }
}

 可以知道,到这里,刷盘线程被唤醒了。接下来,刷盘线程将执行相应的 run()方法,完成自己的工作

 2. 刷盘线程执行 run()代码体进行消息刷盘

 我们查看同步刷盘线程的 CommitLog.GroupCommitService.run() 方法,可以知道线程的执行代码:

public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");

    // 刷盘线程是否停止
    while (!this.isStopped()) {
        try {
            this.waitForRunning(10);   // @1
            this.doCommit();   // @2
        } 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");
}

 该方法是线程的主要执行逻辑,解释如下:

 @1:this.waitForRunning(10); 语句,进入该方法查看,可知该方法交换了读写队列,这个操作有以下好处:可实现读写分离,使刷盘线程在进行刷盘(读读消息队列)的时候,存储消息的线程仍然可以将写请求添加到写消息队列,避免产生锁竞争。

protected void waitForRunning(long interval) {
    // hasNotified在上面的wakeup()唤醒刷盘线程后已变为true,因此该if语句返回true,同时hasNotified变回false
    if (hasNotified.compareAndSet(true, false)) {
        // 交换读写队列
        this.onWaitEnd();
        return;
    }

    //entry to wait
    waitPoint.reset();

    try {
        waitPoint.await(interval, TimeUnit.MILLISECONDS);
    } catch (InterruptedException e) {
        log.error("Interrupted", e);
    } finally {
        hasNotified.set(false);
        this.onWaitEnd();
    }
}

@2:this.doCommit(); 语句,该方法是线程刷盘过程的主要实现,解释如下:
  Ⅰ:对读请求队列加锁
  Ⅱ:遍历读请求队列,取出读请求队列中的刷盘请求。从这里可以看出,RocketMQ 是一次处理一批刷盘请求
  Ⅲ:因为消息可能分别存在了两个 mappedFile 中,因此需要至少刷盘两次。每次刷盘会更新 flushOK 变量,该变量值由刷盘后消息的偏移量和请求消息的偏移量的比较来确定,用于判断此次刷盘是否结束。若尚未结束,则进行第二次刷盘。刷盘调用的是 MappedFileQueue.flush() 方法,此时开始真正刷盘,具体细节放到了后面
  Ⅳ:唤醒等待刷盘结果的线程 

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 = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
                for (int i = 0; i < 2 && !flushOK; i++) {   // Ⅲ
                    // 调用MappedFileQueue的flush()方法刷盘
                    CommitLog.this.mappedFileQueue.flush(0);
                    flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();
                }

                req.wakeupCustomer(flushOK ? PutMessageStatus.PUT_OK : PutMessageStatus.FLUSH_DISK_TIMEOUT);   // Ⅳ
            }

            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);
        }
    }
}

 异步刷盘

 起点仍然是CommitLog.handleDiskFlush()方法

public void handleDiskFlush(AppendMessageResult result, PutMessageResult putMessageResult, MessageExt messageExt) {
    
    // 同步刷盘过程(省略)

    // Asynchronous flush
    else {
        if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            // 若没有开启内存缓存池,则唤醒 flushCommitLogService
            flushCommitLogService.wakeup();
        } else {
            // 否则唤醒 commitLogService
            commitLogService.wakeup();
        }
    }
}

FlushRealTimeService 在启动后,会在死循环中周期性的进行刷盘操作

FlushRealTimeService.run()
该方法实现了异步刷盘的主逻辑。这里面有几个比较重要的参数,如下:
boolean flushCommitLogTimed:休眠策略,为 true 时,调用 Thread.sleep()休眠,为 false 时,调用 waitForRunning()休眠,每次休眠时长 interval 大小,默认 false
int interval:休眠时长,也作刷盘周期,默认为 500ms
int flushPhysicQueueLeastPages:每次刷盘至少要刷多少页内容,每页大小为 4 k,默认每次要刷 4 页
int flushPhysicQueueThoroughInterval:两次刷写之间的最大时间间隔,默认 10 s

接下来结合代码解释该方法的主逻辑:
@1:若距离上次刷盘时间间隔大于 flushPhysicQueueThoroughInterval,则将 flushPhysicQueueLeastPages 设置为0,表明将所有内存缓存全部刷到文件中
@2:根据不同休眠策略,进行休眠等待,默认 flushCommitLogTimed 为 false,即默认走 @3
@3:默认走这里,调用 ServiceThread.waitForRunning()方法,休眠 interval 大小的时长
@4:与同步刷盘一样,刷盘时调用的是 MappedFileQueue.flush()方法
@5:若线程被停止了,则重试 RETRY_TIMES_OVER(默认为10)大小的次数,每次重试进行一次刷盘,直到内存中所有消息完成刷盘

@3 语句中我们会以为异步刷盘是每隔 500ms 刷盘一次,但结合 CommitLog.handleDiskFlush() 方法,可以知晓每异步写入一条消息,都会触发 flushCommitLogService.wakeup() 直接中断 this.waitForRunning(interval)。因此异步刷盘并非想当然的每隔500ms 刷一次盘。而是如果没有新的消息写入,会休眠 500ms,但收到了新的消息后,可以被唤醒,做到消息及时被刷盘,而不是一定要等 500 ms。

public void run() {
    CommitLog.log.info(this.getServiceName() + " service started");

    while (!this.isStopped()) {
        boolean flushCommitLogTimed = CommitLog.this.defaultMessageStore.getMessageStoreConfig().isFlushCommitLogTimed();

        int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushIntervalCommitLog();
        int flushPhysicQueueLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogLeastPages();

        int flushPhysicQueueThoroughInterval =
            CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogThoroughInterval();

        boolean printFlushProgress = false;

        // Print flush progress
        long currentTimeMillis = System.currentTimeMillis();
        if (currentTimeMillis >= (this.lastFlushTimestamp + flushPhysicQueueThoroughInterval)) {   // @1
            this.lastFlushTimestamp = currentTimeMillis;
            flushPhysicQueueLeastPages = 0;
            printFlushProgress = (printTimes++ % 10) == 0;
        }

        try {
            if (flushCommitLogTimed) {   // @2
                Thread.sleep(interval);
            } else {
                this.waitForRunning(interval);   // @3
            }

            if (printFlushProgress) {
                this.printFlushProgress();
            }

            long begin = System.currentTimeMillis();
            CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages);   // @4
            long storeTimestamp = CommitLog.this.mappedFileQueue.getStoreTimestamp();
            if (storeTimestamp > 0) {
                CommitLog.this.defaultMessageStore.getStoreCheckpoint().setPhysicMsgTimestamp(storeTimestamp);
            }
            long past = System.currentTimeMillis() - begin;
            if (past > 500) {
                log.info("Flush data to disk costs {} ms", past);
            }
        } catch (Throwable e) {
            CommitLog.log.warn(this.getServiceName() + " service has exception. ", e);
            this.printFlushProgress();
        }
    }

    // Normal shutdown, to ensure that all the flush before exit
    boolean result = false;
    for (int i = 0; i < RETRY_TIMES_OVER && !result; i++) {   // @5
        result = CommitLog.this.mappedFileQueue.flush(0);
        CommitLog.log.info(this.getServiceName() + " service shutdown, retry " + (i + 1) + " times " + (result ? "OK" : "Not OK"));
    }

    this.printFlushProgress();

    CommitLog.log.info(this.getServiceName() + " service end");
}

消息最终刷盘 

无论是同步刷盘还是异步刷盘,在主流程的逻辑处理完后,最终都是调用 MappedFileQueue 的 flush()方法进行消息刷盘

MappedFileQueue.flush()
该方法的主要逻辑解释如下(结合代码):
@1:flushedWhere 记录了最后一条被刷到文件的内容的全局物理偏移量。所以此次刷盘就要根据偏移量,找到本次要刷盘的起始点位于哪个mappedFile,该 mappedFile 存储在 CopyOnWriteArrayList 类型的列表里
@2:调用 MappedFile.flush()方法刷盘
@3:更新 flushedWhere 值

public boolean flush(final int flushLeastPages) {
    boolean result = true;
    MappedFile mappedFile = this.findMappedFileByOffset(this.flushedWhere, this.flushedWhere == 0);   // @1
    if (mappedFile != null) {
        long tmpTimeStamp = mappedFile.getStoreTimestamp();
        int offset = mappedFile.flush(flushLeastPages);   // @2
        long where = mappedFile.getFileFromOffset() + offset;
        result = where == this.flushedWhere;
        this.flushedWhere = where;   // @3
        if (0 == flushLeastPages) {
            this.storeTimestamp = tmpTimeStamp;
        }
    }

    return result;
}

MappedFile.flush()
同样结合代码分析主逻辑如下:
@1:校验是否满足刷盘条件,该方法根据 flushLeastPages 的值,有两种处理逻辑
    1. 若 flushLeastPages 值为0,对比 wrotePosition 和 flushedPosition 的值,若 flushedPosition > wrotePosition,则返回 true
    2. 若 flushLeastPages 值大于0,则判断当前剩余未刷盘内容长度,是否超过最小刷盘长度 flushLeastPages,若超过,则返回 true,避免不必要的刷盘操作。
@2:校验 mappedFile 是否还能用
@3:因为默认不开启内存缓存池(即 transientStorePoolEnable 默认为 false),所以将使用 @4 中的 mappedByteBuffer 存储消息
@4:最终调用 MappedByteBuffer.force()方法刷盘

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

            try {
                //We only append data to fileChannel or mappedByteBuffer, never both.
                if (writeBuffer != null || this.fileChannel.position() != 0) {   // @3
                    this.fileChannel.force(false);
                } else {
                    this.mappedByteBuffer.force();   // @4
                }
            } 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();
}

至此消息最终刷到磁盘中,一次同步或异步刷盘结束

 

 

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值