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默认采用第二种方案,异步刷盘,只有机器断电才可能导致少量消息丢失,兼顾了性能和可靠性。
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_FLUSH且transientStorePoolEnable=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分区,导致服务响应耗时出现毛刺。