【RocketMQ】三种刷盘策略分析

2,023 阅读10分钟

RocketMQ拥有海量的消息积压能力,主要是因为它支持消息的持久化,Broker接收到消息后,会将消息写入CommitLog文件。但是,磁盘IO的效率较低,为了保证性能和吞吐量,RocketMQ通过顺序写、内存映射和零拷贝、异步刷盘等一系列手段来优化性能。 ​

首先是Linux系统的高速页缓存(PageCache),通过将一部分内存用作PageCache,写数据时先写到Cache,再由异步线程将脏页数据同步到磁盘,顺序写的性能几乎等于写内存。读数据时先读到Cache,再次读就能命中缓存,而且在PageCache预读机制下,顺序读的性能几乎也等于读内存。所以,只要能保证顺序读写,在PageCache的加持下,磁盘IO读写并不是性能瓶颈。 ​

再是内存映射跟零拷贝,为了减少内存复制,提高磁盘IO的性能,RocketMQ使用「内存映射」技术来读写文件,通过将磁盘上的物理文件直接映射到应用程序的虚拟内存地址空间,减少了数据在用户态和内核态之间来回复制的开销,应用程序看似在读写自己的内存,其实是在读写内核缓冲区的内存。

综上所述,提升磁盘IO性能,不过是将原本要写入磁盘的数据先暂存到内存里,不管是应用程序缓冲区还是内核缓冲区,它都是在内存里,只要数据在内存里,就意味着机器断电数据就丢了。要想保证消息不丢,就只有每次发消息都进行一次刷盘,副作用就是性能会受到较大影响。鱼和熊掌不可兼得,但是用户可以根据自己的实际场景选择适合自己的刷盘策略。

RocketMQ支持三种刷盘策略:

SYNC_FLUSH同步刷盘
ASYNC_FLUSH异步刷盘
ASYNC_FLUSH && transientStorePoolEnable=trye异步刷盘+缓冲区

同步刷盘时,只有消息被真正持久化到磁盘才会响应ACK,可靠性非常高,但是性能会受到较大影响,适用于金融业务。 异步刷盘时,消息写入PageCache就会响应ACK,然后由后台线程异步将PageCache里的内容持久化到磁盘,降低了读写延迟,提高了性能和吞吐量。服务宕机消息不丢失,机器断电少量消息丢失。 异步刷盘+缓冲区,消息先写入直接内存缓冲区,然后由后台线程异步将缓冲区里的内容持久化到磁盘,性能最好。但是最不可靠,服务宕机和机器断电都会丢失消息。 ​

RocketMQ默认采用第二种方案,异步刷盘,只有机器断电才可能导致少量消息丢失,兼顾了性能和可靠性。 image.png

2. 相关组件

在看源码前,先简单了解一下相关类。

2.1 FlushCommitLogService

CommitLog刷盘服务的父类,它是一个抽象类,本身没有实现,只是一个标记类,三种刷盘策略均由三个子类负责完成。 ​

2.2 GroupCommitService

同步刷盘实现,当有新的消息被写入CommitLog,就会提交一个GroupCommitRequest同步刷盘请求,然后执行doCommit方法开始对CommitLog下的MappedFile文件进行强制刷盘。 ​

2.3 FlushRealTimeService

异步刷盘实现,run方法是一个while循环,只要服务没停止,就会一直定对CommitLog下的MappedFile文件进行刷盘。默认间隔时间是500ms,可通过flushIntervalCommitLog属性设置。 ​

Tips:RocketMQ做了一个小优化,异步刷盘时,最小刷盘页是4,即16KB。意味着即使间隔时间到了,只要新写入的数据不足16KB,也会放弃刷盘,因为异步刷盘本身就是允许丢失少量消息的嘛,这样可以避免频繁无意义的刷盘。

2.4 CommitRealTimeService

异步刷盘+缓冲区实现,只有当配置ASYNC_FLUSHtransientStorePoolEnable=true时才会生效。在这种刷盘策略下,RocketMQ会提前申请一块直接内存用作缓冲区,放弃使用mmap写文件。数据先写入缓冲区,然后异步线程每200ms将缓冲区的数据写入FileChannel,再唤醒FlushRealTimeService服务将FileChannel里的数据持久化到磁盘。 ​

3. 源码阅读

Broker接收消息写入CommitLog后的刷盘流程,时序图如下: 在这里插入图片描述

首先,Broker接收到消息后,会将消息交给CommitLog负责存储。CommitLog先定位到最新的MappedFile,然后将消息按照固定格式追加到其中。

AppendMessageResult result = mappedFile.appendMessage(msg, this.appendMessageCallback);

MappedFile有两个很重要的缓冲区,如下:

// 直接内存缓冲区,开启transientStorePoolEnable则优先写入
protected ByteBuffer writeBuffer = null;
// 内存映射缓冲区,mmap写入PageCache
private MappedByteBuffer mappedByteBuffer;

如果采用异步刷盘+缓冲区策略,消息会被写入writeBuffer,此时数据在用户态内存缓冲区,进程退出数据就丢了。 另外两种策略,消息会被写入mappedByteBuffer,采用mmap的方式,数据直接写入内核缓冲区,机器断电数据才会丢。

消息追加到MappedFile,会调用submitFlushRequest方法提交刷盘请求,会根据不同的刷盘策略进行不同的处理。

// 提交刷盘请求
public CompletableFuture<PutMessageStatus> submitFlushRequest(AppendMessageResult result, MessageExt messageExt) {
    if (FlushDiskType.SYNC_FLUSH == this.defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
        // 同步刷盘
        final GroupCommitService service = (GroupCommitService) this.flushCommitLogService;
        // 是否需要等待消息存储完成再响应,默认为true
        if (messageExt.isWaitStoreMsgOK()) {
            GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes(),
                                                                this.defaultMessageStore.getMessageStoreConfig().getSyncFlushTimeout());
            service.putRequest(request);
            return request.future();
        } else {
            service.wakeup();
            return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
        }
    } else {// 异步刷盘
        // 是否开启缓冲区
        if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
            // 没开启,唤醒FlushRealTimeService线程
            flushCommitLogService.wakeup();
        } else {
            // 开启,唤醒CommitRealTimeService线程
            commitLogService.wakeup();
        }
        return CompletableFuture.completedFuture(PutMessageStatus.PUT_OK);
    }
}

3.1 同步刷盘

如果是同步刷盘,会构建GroupCommitRequest提交处理,它代表了一个刷盘请求,属性如下:

public static class GroupCommitRequest {
    // 刷盘偏移量
    private final long nextOffset;
    // 刷盘完成Future
    private CompletableFuture<PutMessageStatus> flushOKFuture = new CompletableFuture<>();
    // 请求开始时间戳
    private final long startTimestamp = System.currentTimeMillis();
    // 刷盘超时时间
    private long timeoutMillis = Long.MAX_VALUE;
}

刷盘请求会被提交到GroupCommitService的写请求队列中等待执行,下面是GroupCommitService的属性:

class GroupCommitService extends FlushCommitLogService {
    // 写请求列表
    private volatile List<GroupCommitRequest> requestsWrite = new ArrayList<GroupCommitRequest>();
    // 读请求列表
    private volatile List<GroupCommitRequest> requestsRead = new ArrayList<GroupCommitRequest>();
}

刷盘请求会被提交到requestsWrite

public synchronized void putRequest(final GroupCommitRequest request) {
    synchronized (this.requestsWrite) {
        this.requestsWrite.add(request);
    }
    this.wakeup();
}

每次刷盘操作完成,都会调用swapRequests方法进行读写队列的交换:

private void swapRequests() {
    List<GroupCommitRequest> tmp = this.requestsWrite;
    this.requestsWrite = this.requestsRead;
    this.requestsRead = tmp;
}

刷盘请求为啥还要分读写两个列表呢? 这是用来做读写分离用的,Producer发送消息的请求量是非常大的,GroupCommitService的刷盘操作是同步的,刷盘期间仍然会有大量的刷盘请求被提交进来,拆分成两个读写列表,请求提交到写列表,刷盘时处理读列表,刷盘结束交换列表,循环往复,两者可以同时进行。

当有同步请求被提交进来,线程就会被唤醒,然后执行doCommit方法,刷盘的核心是调用了MappedFileQueue的flush方法。flush方法需要传flushLeastPages参数,它代表刷盘的最小页数,对于同步刷盘来说,不允许消息丢失,只要写入数据就要刷盘,所以页数为0。

// 同步刷盘,只要有新数据写入就要刷盘,最小页数是0
CommitLog.this.mappedFileQueue.flush(0);
flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset();

flush方法会根据刷盘的偏移量定位到对应的MappedFile,然后调用flush开始刷盘,最后更新刷盘偏移量。

public boolean flush(final int flushLeastPages) {
    boolean result = true;
    // 根据上次刷盘位置定位到MappedFile
    MappedFile mappedFile = this.findMappedFileByOffset(this.flushedWhere, this.flushedWhere == 0);
    if (mappedFile != null) {
        long tmpTimeStamp = mappedFile.getStoreTimestamp();
        // 刷盘
        int offset = mappedFile.flush(flushLeastPages);
        long where = mappedFile.getFileFromOffset() + offset;
        // 更新刷盘位置
        result = where == this.flushedWhere;
        this.flushedWhere = where;
        if (0 == flushLeastPages) {
            this.storeTimestamp = tmpTimeStamp;
        }
    }
    return result;
}

刷盘最核心的方法自然是MappedFile的flush方法了,它会先根据flushLeastPages计算是否需要刷盘。例如最小刷盘页为4时,最小刷盘数据就是16KB,如果写入的数据不足16KB就会跳过刷盘。计算方法是isAbleToFlush

/**
* 是否需要刷盘,flush指针和write指针,
* 只有新写入的数据超过指定页才刷盘,避免频繁无意义的刷盘。
*/
private boolean isAbleToFlush(final int flushLeastPages) {
    // 上次刷盘的位置
    int flush = this.flushedPosition.get();
    // 已写入的位置
    int write = getReadPosition();
    if (this.isFull()) {
        // 文件写满是必须要刷盘的
        return true;
    }
    if (flushLeastPages > 0) {
        // 根据PageSize计算新写入的数据是否达到给定大小
        return ((write / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE)) >= flushLeastPages;
    }
    return write > flush;
}

如果判断需要刷盘,就会调用force方法将缓冲区内容强制刷新到磁盘里。开启缓冲区会强制刷fileChannel,否则强制刷mappedByteBuffer。

if (writeBuffer != null || this.fileChannel.position() != 0) {
    this.fileChannel.force(false);
} else {
    this.mappedByteBuffer.force();
}

3.2 异步刷盘

对于异步刷盘,没有提交刷盘请求一说。它不像同步刷盘,只要有消息写入CommitLog就要执行刷盘操作,因为异步刷盘是定时执行的。 ​

异步刷盘时,仅仅需要调用wakeup方法唤醒线程即可。所以,我们重点看它的run方法。 ​

首先,根据配置判断是否需要定时刷盘,如果配置了定时刷盘,线程会Sleep,wakeup是无法将其唤醒的,它只会按照固定的时间进行刷盘操作。反之,调用waitForRunning方法等待间隔时间,但是该方法是支持被唤醒的,更加灵活。

// 是否定时刷盘,默认是false,近实时的 500ms一次
boolean flushCommitLogTimed = CommitLog.this.defaultMessageStore.getMessageStoreConfig().isFlushCommitLogTimed();
// 定时刷盘的间隔时间
int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushIntervalCommitLog();
// 刷盘最小页数,默认4 16KB
int flushPhysicQueueLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogLeastPages();
// 距离上一次刷盘超过10S,不管页数是否超过4,都会刷盘
int flushPhysicQueueThoroughInterval =
    CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushCommitLogThoroughInterval();

long currentTimeMillis = System.currentTimeMillis();
if (currentTimeMillis >= (this.lastFlushTimestamp + flushPhysicQueueThoroughInterval)) {
    this.lastFlushTimestamp = currentTimeMillis;
    // 距离上次刷盘超过10S,忽略最小刷盘页
    flushPhysicQueueLeastPages = 0;
    printFlushProgress = (printTimes++ % 10) == 0;
}

if (flushCommitLogTimed) {
    Thread.sleep(interval);// 定时刷盘,无法被唤醒
} else {
    this.waitForRunning(interval);// 等待500ms刷盘,允许被唤醒
}

// 刷盘
CommitLog.this.mappedFileQueue.flush(flushPhysicQueueLeastPages);

3.3 异步刷盘+缓冲区

最激进的一种刷盘策略,性能最好,但是也最不可靠。同样没有刷盘请求一说,只需要唤醒线程即可开始工作。

和FlushRealTimeService流程差不多,区别仅仅是将flush换成commit了,先将直接内存缓冲区的数据写入FileChannel,然后唤醒FlushRealTimeService对FIleChannel做持久化。

// 刷盘间隔时间
int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitIntervalCommitLog();
// 最小刷盘页
int commitDataLeastPages = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitCommitLogLeastPages();
// 超过200ms没刷盘,无条件刷盘,写在内容里,很容易丢数据
int commitDataThoroughInterval =
    CommitLog.this.defaultMessageStore.getMessageStoreConfig().getCommitCommitLogThoroughInterval();

long begin = System.currentTimeMillis();
if (begin >= (this.lastCommitTimestamp + commitDataThoroughInterval)) {
    this.lastCommitTimestamp = begin;
    // 超过200ms,忽略最小刷盘页
    commitDataLeastPages = 0;
}

// 仅仅将缓冲区数据写入FileChannel
boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
long end = System.currentTimeMillis();
if (!result) {
    this.lastCommitTimestamp = end;
    // 唤醒FlushRealTimeService线程对FileChannel刷盘
    flushCommitLogService.wakeup();
}

关键点在MappedFileQueue的commit方法,它会根据Commit偏移量定位到MappedFile,然后调用它的commit0方法。它仅仅是将直接内存缓冲区的数据写入FileChannel,此时数据并没有真正持久化到磁盘。

// 仅仅将直接内存缓冲区的数据写入FileChannel
protected void commit0(final int commitLeastPages) {
    int writePos = this.wrotePosition.get();
    int lastCommittedPosition = this.committedPosition.get();

    if (writePos - lastCommittedPosition > commitLeastPages) {
        try {
            // 直接内存缓冲区
            ByteBuffer byteBuffer = writeBuffer.slice();
            byteBuffer.position(lastCommittedPosition);
            byteBuffer.limit(writePos);
            this.fileChannel.position(lastCommittedPosition);
            // 写入FileChannel
            this.fileChannel.write(byteBuffer);
            this.committedPosition.set(writePos);
        } catch (Throwable e) {
            log.error("Error occurred when commit data to FileChannel.", e);
        }
    }
}

先写入FileChannel,再唤醒FlushRealTimeService将FileChannel缓冲区数据持久化到磁盘。 ​

4. 总结

RocketMQ针对CommitLog文件支持三种持久化策略。 同步刷盘时,每次消息写入都会提交刷盘请求给GroupCommitService,调用MappedByteBuffer的force方法将内核缓冲区的数据强制刷新到磁盘,成功才响应ACK。 异步刷盘时,消息写入PageCache立即响应ACK,由FlushRealTimeService线程每隔500ms对CommitLog文件进行一次刷盘操作,流程和上述一样。 异步刷盘且开启缓冲区时,RocketMQ申请一块直接内存用作数据缓冲区,消息先写入缓冲区,然后由CommitRealTimeService线程定时将缓冲区数据写入FileChannel,再唤醒FlushRealTimeService将FileChannel缓冲区数据强制刷新到磁盘。 ​

开启缓冲区有什么用? 类似在内存层面做了读写分离,写数据走直接内存,读数据走PageCache,最大程度的消除了PageCache锁竞争,避免PageCache被交换到Swap分区,导致服务响应耗时出现毛刺。