rocketmq在儲存訊息的時候,最終是通過mmap對映成磁碟檔案進行儲存的,本文就訊息的儲存流程作一個整理。原始碼版本是4.9.2
主要的儲存元件有如下4個:
CommitLog:儲存的業務層,接收“儲存訊息”的請求
MappedFile:儲存的最底層物件,一個MappedFile物件就對應了一個實際的檔案
MappedFileQueue:管理MappedFile的容器
AllocateMappedFileService:非同步建立mappedFile的服務
對於rocketmq來說,儲存訊息的主要檔案被稱為CommitLog,因此就從該類入手。處理儲存請求的入口方法是asyncPutMessage,主要流程如下:
public CompletableFuture<PutMessageResult> asyncPutMessage(final MessageExtBrokerInner msg) {
...
//可能會有多個執行緒併發請求,雖然支援叢集,但是對於每個單獨的broker都是本地儲存,所以記憶體鎖就足夠了
putMessageLock.lock();
try {
//獲取最新的檔案
MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile();
...
//如果檔案為空,或者已經存滿,則建立一個新的commitlog檔案
if (null == mappedFile || mappedFile.isFull()) {
mappedFile = this.mappedFileQueue.getLastMappedFile(0); // Mark: NewFile may be cause noise
}
...
//呼叫底層的mappedFile進行出處,但是注意此時還沒有刷盤
result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
...
} finally {
putMessageLock.unlock();
}
PutMessageResult putMessageResult = new PutMessageResult(PutMessageStatus.PUT_OK, result);
...
}
因此對於Commitlog.asyncPutMessage來說,主要的工作就是2步:
1.獲取或者建立一個MappedFile
2.呼叫appendMessage進行儲存
接下去我們先看MappedFile的建立,檢視mappedFileQueue.getLastMappedFile方法,最終會呼叫到doCreateMappedFile方法,呼叫流如下:
getLastMappedFile-->tryCreateMappedFile-->doCreateMappedFile
protected MappedFile doCreateMappedFile(String nextFilePath, String nextNextFilePath) {
MappedFile mappedFile = null;
//如果非同步服務物件不為空,那麼就採用非同步建立檔案的方式
if (this.allocateMappedFileService != null) {
mappedFile = this.allocateMappedFileService.putRequestAndReturnMappedFile(nextFilePath,
nextNextFilePath, this.mappedFileSize);
} else {
//否則就同步建立
try {
mappedFile = new MappedFile(nextFilePath, this.mappedFileSize);
} catch (IOException e) {
log.error("create mappedFile exception", e);
}
}
...
return mappedFile;
}
因此對於MappedFileQueue來說,主要工作就2步:
1.如果有非同步服務,那麼就非同步建立mappedFile
2.否則就同步建立
接下去主要看非同步建立的流程,檢視allocateMappedFileService.putRequestAndReturnMappedFile
public MappedFile putRequestAndReturnMappedFile(String nextFilePath, String nextNextFilePath, int fileSize) {
...
//建立mappedFile的請求,
AllocateRequest nextReq = new AllocateRequest(nextFilePath, fileSize);
//將其放入ConcurrentHashMap中,主要用於併發判斷,保證不會建立重複的mappedFile
boolean nextPutOK = this.requestTable.putIfAbsent(nextFilePath, nextReq) == null;
//如果map新增成功,就可以將request放入佇列中,實際建立mappedFile的執行緒也是從該queue中獲取request
if (nextPutOK) {
boolean offerOK = this.requestQueue.offer(nextReq);
}
AllocateRequest result = this.requestTable.get(nextFilePath);
try {
if (result != null) {
//因為是非同步建立,所以這裡需要await,等待mappedFile被非同步建立成功
boolean waitOK = result.getCountDownLatch().await(waitTimeOut, TimeUnit.MILLISECONDS);
//返回建立好的mappedFile
return result.getMappedFile();
}
} catch (InterruptedException e) {
log.warn(this.getServiceName() + " service has exception. ", e);
}
return null;
}
因此對於AllocateMappedFileService.putRequestAndReturnMappedFile,主要工作也是2步:
1.將“建立mappedFile”的請求放入佇列中
2.等待非同步執行緒實際建立完mappedFile
接下去看非同步執行緒是如何具體建立mappedFile的。既然AllocateMappedFileService本身就是負責建立mappedFile的,並且其本身也實現了Runnable介面,我們檢視其run方法,其中會呼叫mmapOperation,這就是最終執行建立mappedFile的方法
private boolean mmapOperation() {
boolean isSuccess = false;
AllocateRequest req = null;
try {
//從佇列中拿request
req = this.requestQueue.take();
...
if (req.getMappedFile() == null) {
MappedFile mappedFile;
//判斷是否採用堆外記憶體
if (messageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
try {
//如果開啟了堆外記憶體,rocketmq允許外部注入自定義的MappedFile實現
mappedFile = ServiceLoader.load(MappedFile.class).iterator().next();
mappedFile.init(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
} catch (RuntimeException e) {
//如果沒有自定義實現,那麼就採用預設的實現
log.warn("Use default implementation.");
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize(), messageStore.getTransientStorePool());
}
} else {
//如果未採用堆外記憶體,那麼就直接採用預設實現
mappedFile = new MappedFile(req.getFilePath(), req.getFileSize());
}
...
//這裡會預熱檔案,這裡涉及到了系統的底層呼叫
mappedFile.warmMappedFile(this.messageStore.getMessageStoreConfig().getFlushDiskType(),
this.messageStore.getMessageStoreConfig().getFlushLeastPagesWhenWarmMapedFile());
req.setMappedFile(mappedFile);
}
...
} finally {
if (req != null && isSuccess)
//無論是否建立成功,都要喚醒putRequestAndReturnMappedFile方法中的等待執行緒
req.getCountDownLatch().countDown();
}
return true;
}
因此對於mmapOperation建立mappedFile,主要工作為4步:
1.從佇列中獲取putRequestAndReturnMappedFile方法存放的request
2.根據是否啟用對外記憶體,分支建立mappedFile
3.預熱mappedFile
4.喚醒putRequestAndReturnMappedFile方法中的等待執行緒
接下去檢視mappedFile內部的具體實現,我們可以發現在建構函式中,也會呼叫內部的init方法,這就是主要實現mmap的方法
private void init(final String fileName, final int fileSize) throws IOException {
...
//建立檔案物件
this.file = new File(fileName);
try {
//獲取fileChannel
this.fileChannel = new RandomAccessFile(this.file, "rw").getChannel();
//進行mmap操作,將磁碟空間對映到記憶體
this.mappedByteBuffer = this.fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
...
} finally {
...
}
}
因此對於init執行mmap,主要工作分為2步:
1.獲取檔案的fileChannel
2.執行mmap對映
而如果採用了堆外記憶體,那麼除了上述的mmap操作,還會額外分配對外記憶體
this.writeBuffer = transientStorePool.borrowBuffer();
到這裡,CommitLog.asyncPutMessage方法中的獲取或建立mappedFile就完成了。
接下去需要檢視訊息具體是符合被寫入檔案中的。檢視mappedFile的appendMessage方法,最終會呼叫到appendMessagesInner方法:
public AppendMessageResult appendMessagesInner(final MessageExt messageExt, final AppendMessageCallback cb,
PutMessageContext putMessageContext) {
//如果是啟用了對外記憶體,那麼會優先寫入對外記憶體,否則直接寫入mmap記憶體
ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
byteBuffer.position(currentPos);
AppendMessageResult result;
...
//呼叫外部的callback執行實際的寫入操作
result = cb.doAppend(this.getFileFromOffset(), byteBuffer, this.fileSize - currentPos,
(MessageExtBrokerInner) messageExt, putMessageContext);
...
return result;
}
因此對於appendMessage方法,主要工作分為2步:
1.判斷是否啟用對外記憶體,從而選擇對應的buffer物件
2.呼叫傳入的callback方法進行實際寫入
接下去檢視外部傳入的callback方法,是由CommitLog.asyncPutMessage傳入
result = mappedFile.appendMessage(msg, this.appendMessageCallback, putMessageContext);
而this.appendMessageCallback則是在CommitLog的建構函式中初始化的
this.appendMessageCallback = new DefaultAppendMessageCallback(defaultMessageStore.getMessageStoreConfig().getMaxMessageSize());
檢視DefaultAppendMessageCallback.doAppend方法,因為本文不關心訊息的具體結構,所以省略了大部分構造buffer的程式碼:
public AppendMessageResult doAppend(final long fileFromOffset, final ByteBuffer byteBuffer, final int maxBlank,
final MessageExtBrokerInner msgInner, PutMessageContext putMessageContext) {
...
//獲取訊息編碼後的buffer
ByteBuffer preEncodeBuffer = msgInner.getEncodedBuff();
...
//寫入buffer中,如果啟用了對外記憶體,那麼就會寫入外部傳入的writerBuffer,否則直接寫入mappedByteBuffer中
byteBuffer.put(preEncodeBuffer);
...
return result;
}
因此對於doAppend方法,主要工作分為2步:
1.將訊息編碼
2.將編碼後的訊息寫入buffer中,可以是writerBuffer或者mappedByteBuffer
此時雖然位元組流已經寫入了buffer中,但是對於堆外記憶體,此時資料還僅存在於記憶體中,而對於mappedByteBuffer,雖然會有系統執行緒定時刷資料落盤,但是這並非我們可以控,因此也只能假設還未落盤。為了保證資料能落盤,rocketmq還有一個非同步刷盤的執行緒,接下去再來看下非同步刷盤是如何處理的。
檢視CommitLog的建構函式,其中有3個service,分別負責同步刷盤、非同步刷盤和堆外記憶體寫入fileChannel
public CommitLog(final DefaultMessageStore defaultMessageStore) {
...
//同步刷盤
if (FlushDiskType.SYNC_FLUSH == defaultMessageStore.getMessageStoreConfig().getFlushDiskType()) {
this.flushCommitLogService = new GroupCommitService();
} else {
//非同步刷盤
this.flushCommitLogService = new FlushRealTimeService();
}
//將對外記憶體的資料寫入fileChannel
this.commitLogService = new CommitRealTimeService();
...
}
先看CommitRealTimeService.run方法,其中最關鍵的程式碼如下:
boolean result = CommitLog.this.mappedFileQueue.commit(commitDataLeastPages);
檢視mappedFileQueue.commit方法,關鍵如下:
int offset = mappedFile.commit(commitLeastPages);
檢視mappedFile.commit方法:
public int commit(final int commitLeastPages) {
//如果為空,說明不是堆外記憶體,就不需要任何操作,只需等待刷盤即可
if (writeBuffer == null) {
return this.wrotePosition.get();
}
if (this.isAbleToCommit(commitLeastPages)) {
if (this.hold()) {
//如果是堆外記憶體,那麼需要做commit
commit0();
this.release();
}
...
}
return this.committedPosition.get();
}
檢視commit0方法:
protected void commit0() {
...
//獲取堆外記憶體
ByteBuffer byteBuffer = writeBuffer.slice();
//寫入fileChannel
this.fileChannel.write(byteBuffer);
...
}
因此對於CommitRealTimeService,工作主要分2步:
1.判斷是否是對外記憶體,如果不是那就不需要處理
2.如果是對外記憶體,則寫入fileChannel
最後檢視同步刷盤的GroupCommitService和非同步刷盤FlushRealTimeService,檢視其run方法,會發現其本質都是呼叫瞭如下方法:
CommitLog.this.mappedFileQueue.flush
當然在處理的邏輯上還有計算position等等邏輯,但這不是本文所關心的,所以就省略了。
同步和非同步的區別體現在了執行刷盤操作的時間間隔,對於同步刷盤,固定間隔10ms:
this.waitForRunning(10);
而對於非同步刷盤,時間間隔為配置值,預設500ms:
int interval = CommitLog.this.defaultMessageStore.getMessageStoreConfig().getFlushIntervalCommitLog();
...
if (flushCommitLogTimed) {
Thread.sleep(interval);
} else {
this.waitForRunning(interval);
}
最後檢視mappedFileQueue.flush是如何刷盤的。最終會呼叫到mappedFile的flush方法:
public int flush(final int flushLeastPages) {
...
//如果是使用了堆外記憶體,那麼呼叫的是fileChannel的刷盤
if (writeBuffer != null || this.fileChannel.position() != 0) {
this.fileChannel.force(false);
} else {
//如果非堆外記憶體,那麼呼叫的是mappedByteBuffer的刷盤
this.mappedByteBuffer.force();
}
...
return this.getFlushedPosition();
}
因此最終的刷盤,工作主要分2步,正和前面的CommitRealTimeService工作對應:
1.如果是使用了堆外記憶體,那麼呼叫fileChannel的刷盤
2.如果非堆外記憶體,那麼呼叫mappedByteBuffer的刷盤
至此,整個rocketmq訊息落盤的流程就完成了,接下去重新整理下整個流程:
1.CommitLog:儲存的業務層,接收“儲存訊息”的請求,主要有2個功能:建立mappedFile、非同步寫入訊息。
2.AllocateMappedFileService:非同步建立mappedFile的服務,通過構建AllocateRequest物件和佇列進行執行緒間的通訊。雖然MappedFile的實際建立是通過非同步執行緒執行的,但是當前執行緒會等待建立完成後再返回,所以實際上是非同步阻塞的。
3.MappedFile:儲存的最底層物件,一個MappedFile物件就對應了一個實際的檔案。在init方法中建立了fileChannel,並完成了mmap操作。如果啟用了堆外記憶體,則會額外初始化writeBuffer,實現讀寫分離。
4.MappedFileQueue:管理MappedFile的容器。
5.寫入訊息的時候,會根據是否啟用堆外記憶體,寫入writeBuffer或者mappedByteBuffer。
6.實際落盤是通過非同步的執行緒實現的,分為名義上的同步(GroupCommitService)和非同步(FlushRealTimeService),不過主要區別在於執行落盤方法的時間間隔不同,最終都是呼叫mappedFile的flush方法
7.落盤會根據是否啟用對外記憶體,分別呼叫fileChannel.force或者mappedByteBuffer.force