從源碼分析RocketMq消息的存儲原理
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
