零、關鍵詞解釋
cache 是爲了彌補高速設備和低速設備的鴻溝而引入的中間層,最終起到**加快訪問速度**的作用。
buffer 的主要目的進行流量整形,把突發的大數量較小規模的 I/O 整理成平穩的小數量較大規模的 I/O,以**減少響應次數**(比如從網上下電影,你不能下一點點數據就寫一下硬盤,而是積攢一定量的數據以後一整塊一起寫,不然硬盤都要被你玩壞了)。 -- 知乎
數據複製:當我們在某段代碼中往某個文件寫入一段字符串的時候,實際發生了什麼呢?數據從 Application Memory 逐漸被複制到 Disk,中間會有多次數據複製 Application Memory -> Page Cache -> Disk 。讀數據的時候,順序反過來。
內存映射:內存映射( 即 Memory Map,簡稱 mmap 也被稱爲 zero-copy 技術) , 如 Java NIO 中 的 MappedByteBuffer 或者 MappedFileChannel 用的就是這項技術,它的作用其實就是不再使用應用層自己的內存空間(也就是用戶空間的內存),直接操作 Page Cache 區域,減少了數據複製。 通俗解釋,在應用這一層,是讓你把文件的某一段,當作內存一樣來訪問。
Consumer 消費消息過程,使用了零拷貝,零拷貝包含以下兩種方式
1. 使用 mmap + write 方式
優點:即使頻繁調用,使用小塊文件傳輸,效率也很高
缺點:不能很好的利用 DMA 方式,會比 sendfile 多消耗 CPU,內存安全性控制複雜,需要避免 JVM Crash
問題。
2. 使用 sendfile 方式
優點:可以利用 DMA 方式,消耗 CPU 較少,大塊文件傳輸效率高,無內存安全新問題。
缺點:小塊文件效率低於 mmap 方式,只能是 BIO 方式傳輸,不能使用 NIO。
RocketMQ 選擇了第一種方式,mmap+write 方式,因爲有小塊數據傳輸的需求,效果會比 sendfile 更好。
一、前言
存儲子系統的選擇。理論上,從速度上,文件系統 > 分佈式 KV (持久化的,MongoDB)> 分佈式文件系統 > 數據庫,而可靠性則相反。
諸如Kafka之類的消息中間件,在隊列數上升時性能會產生巨大的損失,RocketMQ之所以能單機支持上萬的持久化隊列與其獨特的存儲結構和 mmap 技術來實現。
分區(partition 對應 RocketMQ 的 queue)數量在Kafka中有什麼作用?
Producer(消息發送者)的往消息Server的寫入併發數與分區數成正比。
Consumer(消息消費者)消費某個Topic的並行度與分區數保持一致,假設分區數是20,那麼Consumer的消費並行度最大爲20。
每個Topic由固定數量的分區數組成,分區數的多少決定了單臺Broker能支持的Topic數量,Topic數量又決定了支持的業務數量。
爲什麼Kafka不能支持更多的分區數?
每個分區存儲了完整的消息數據,雖然每個分區寫入是磁盤順序寫,但是多個分區同時順序寫入在操作系統層面變爲了隨機寫入。
由於數據分散爲多個文件,很難利用IO層面的GroupCommit機制,網絡傳輸也會用到類似優化算法。
RocketMQ 存儲架構
如上圖所示,所有的消息數據單獨存儲到一個 Commit Log,完全順序寫,隨機讀。對最終用戶展現的隊列(ConsumeQueue)實際只存儲消息在 Commit Log 的位置信息和 Tag 的 hashcode,並且串行方式刷盤。
順序寫高性能磁盤最高能到 600M/s,但是磁盤隨機寫的速度只有大概 100KB/ s,相差6000倍,所以存儲方式和方法的選擇會導致好幾個數量級的隊列性能差距。
架構優點:
這麼設計帶來的缺點:
以上缺點如何克服:
(1)訪問 Page Cache 時,即使只訪問 1k 的消息,系統也會提前預讀出更多數據,在下次讀的時候,就可能命中內存。
(2)隨機訪問 CommitLog 磁盤數據,系統 IO 調度算法設置爲 NOOP 方式,會在一定程度上將完全的隨機讀變成順序跳躍方式,而順序跳躍方式讀較完全的隨機讀性能會高 5 倍以上
(3)另外 4k 的消息在完全隨機訪問情況下,仍可以到達 8k 次每秒以上的讀性能
-
由於 ConsumeQueue 存儲數據量極少,而且是順序讀,在 Page Cache 預讀作用下,即使消息堆積,ComsumeQueue 的讀取性能幾乎與內存一致。所以可以認爲 ConsumeQueue 完全不阻礙讀性能。
-
CommitLog 存儲了所有元信息,包含消息體,類似於 MySQL 的 binlog,所以只要有 CommitLog 在,ConsumeQueue 即使數據丟失,仍然可以恢復出來
存儲目錄結構:
|-- abort
|-- checkpoint
|-- config
| |-- consumerOffset.json
| |-- consumerOffset.json.bak
| |-- delayOffset.json
| |-- delayOffset.json.bak
| |-- subscriptionGroup.json
| |-- subscriptionGroup.json.bak
| |-- topics.json
| `-- topics.json.bak
|-- commitlog
| |-- 00000003384434229248
| |-- 00000003385507971072
| `-- 00000003386581712896
`-- consumequeue
|-- %DLQ%ConsumerGroupA
| `-- 0
| `-- 00000000000006000000
|-- %RETRY%ConsumerGroupA
| `-- 0
| `-- 00000000000000000000
|-- %RETRY%ConsumerGroupB
| `-- 0
| `-- 00000000000000000000
|-- SCHEDULE_TOPIC_XXXX
| |-- 2
| | `-- 00000000000006000000
| |-- 3
| | `-- 00000000000006000000
|-- TopicA
| |-- 0
| | |-- 00000000002604000000
| | |-- 00000000002610000000
| | `-- 00000000002616000000
| |-- 1
| | |-- 00000000002610000000
| | `-- 00000000002616000000
|-- TopicB
| |-- 0
| | `-- 00000000000732000000
| |-- 1
| | `-- 00000000000732000000
| |-- 2
| | `-- 00000000000732000000
二、源碼閱讀
1、2、client 通過 Netty 發送消息的請求 invokeSync(addr, RemotingCommand, timeoutMillis) 在 NettyRemotingServer 的內部類 NettyServerHandler 服務端處理器接收
3、根據請求 code 從 HashMap<Integer/* request code */, Pair<NettyRequestProcessor, ExecutorService>> processorTable 內存(在 broker 啓動的時候寫入的對應關係)中拿到對應的處理類,比如現在是發送消息,所以 code=310 對應的是 SendMessageProcessor 處理類。
4、提取出請求頭信息
5、響應信息 opaque 保持不變作爲請求響應,這樣客戶端才能用這個跟原來發的請求對應起來。(dubbo 中叫 requestId、responseId 一個道理)
6、對 %RETRY% 類型的消息處理。
如果超過最大消費次數,則 topic 修改成"%DLQ%" + 分組名,即加入死信隊列(Dead Letter Queue)
8 ~ 消息持久化到文件
RocketMQ 的消息存儲與 Kafka 不同,RocketMQ 存儲在 queue 的消息較爲簡潔,comsume queue 只是存消息的索引,而真正的消息在 commitlog 裏面。
(1)ConsumeQueue 消息存儲結構(可以理解爲消息索引):
字段
|
描述
|
數據類型
|
字節
|
offset
|
這條消息在commitLog文件實際偏移量
|
long
|
8
|
size
|
消息大小
|
int
|
4
|
tagsCode
|
消息 tag 哈希值
|
long
|
8
|
(1) topic 和 queueId 來組織文件關係,比如 TopicA 配了讀寫隊列 0、1, 那麼 TopicA 和 QueueId=0 組成一個ConsumeQueue,TopicA和Queue=1組成一個另一個ConsumeQueue.
(2) 按消費端 group 分組重試隊列,如果消費端消費失敗,發送到retry消費隊列中
(3) 按消費端 group 分組死信隊列,如果消費端重試超過指定次數,發送死信隊列
(4) 每個 ConsumeQueue 可以由多個文件組成無限隊列被 MapedFileQueue 對象管理
consumeQueue 的消息處理
上述的消息存儲只是把消息主體存儲到了物理文件中,但是並沒有把消息處理到 ConsumeQueue文件中,那麼到底是哪裏存入的?
任務處理一般都分爲兩種:
(2)CommitLog 消息存儲結構(真正的消息數據):
序號
|
字段
|
描述
|
數據類型
|
字節
|
1
|
totalSize
|
消息總大小
|
int
|
4
|
2
|
magicCode
|
|
int
|
4
|
3
|
bodyCRC
|
crc 校驗碼
|
int
|
4
|
4
|
queueId
|
隊列 id
|
int
|
4
|
5
|
flag
|
標誌值rocketmq不做處理,只存儲後透傳
|
int
|
4
|
6
|
queueOffset
|
這個值是個自增值不是真正的 consume queue 的偏移量,可以代表這個隊列中消息的個數,要通過這個值查找到 consume queue 中數據,QUEUEOFFSET * 20纔是偏移地址
|
long
|
8
|
7
|
physicOffset
|
物理偏移量。即在 commitlog 文件中的存儲位置
|
long
|
8
|
8
|
sysFlag
|
指明消息是事務等消息特徵
|
int
|
4
|
9
|
bornTimeStamp
|
消息生產者生產消息時間
|
long
|
8
|
10
|
BORNHOST
|
消息生產者的 ip:port 信息
|
long
|
8
|
11
|
STORETIMESTAMP
|
消息存儲在 broker 的時間
|
long
|
8
|
12
|
STOREHOSTADDRESS
|
消息存儲在 broker 的地址
|
long
|
8
|
13
|
RECONSUMETIMES
|
消息被某個訂閱組重新消費了幾次(訂閱組之間獨立計數),因爲重試消息發送到topic=%RETRY%groupName的隊列queueId=0的隊列中
|
int
|
4
|
14
|
Prepared Transaction Offset
|
prepared狀態的事物消息
|
long
|
8
|
15
|
bodyLength
|
前4個字節存放消息體大小值,後bodylength大小空間存儲了消息體內容
|
int
|
4 + bodyLength
|
16
|
topicLength
|
前面1個字節存放topic長度,後面存放topic
|
int
|
1 + topicLength
|
17
|
propertiesLength
|
前面2字節存放屬性長度,後面存放屬性數據
|
int
|
2 + propertiesLength
|
// commitlog 文件名還代表文件記錄的初始偏移量
this.fileFromOffset = Long.parseLong(this.file.getName());
// MappedFile 是 commitLog、consumequeue 等文件在內存中的映射。
// file = C:\Users\Yibin_Zhu\store\commitlog\00000000000000000000
// MappedFile .java
private static final AtomicLong TOTAL_MAPPED_VIRTUAL_MEMORY = new AtomicLong(0);
private static final AtomicInteger TOTAL_MAPPED_FILES = new AtomicInteger(0);
// 已經寫入的位置,比如可以用來與 MappedFile 文件大小比較,判斷文件是否滿了
protected final AtomicInteger wrotePosition = new AtomicInteger(0);
//ADD BY ChenYang 提交到了哪個位置(這時候還沒刷盤)
protected final AtomicInteger committedPosition = new AtomicInteger(0);
// 刷盤到了哪個位置
private final AtomicInteger flushedPosition = new AtomicInteger(0);
// MappedFile 文件大小,默認大小 1024*1024*1024=1073741824=1G
protected int fileSize;
protected FileChannel fileChannel;
/**
* 消息將會先放到這裏,如果 writeBuffer 緩存區不爲 null,則再放到 FileChannel 中
* Message will put to here first, and then reput to FileChannel if writeBuffer is not null.
*/
protected ByteBuffer writeBuffer = null;
protected TransientStorePool transientStorePool = null;
private String fileName;
private long fileFromOffset; // 文件起始偏移量,從文件名初始化到這裏。因爲文件順序寫,所有文件都有唯一起始偏移量
// C:\Users\Yibin_Zhu\store\commitlog\00000000000000000000
private File file;
private MappedByteBuffer mappedByteBuffer;
private volatile long storeTimestamp = 0;
private boolean firstCreateInQueue = false;
MappedFile 幾個核心方法:
selectMappedBuffer(int pos, int size) 。consumer 從 broker 拉消息的時候,根據位置 pos 從 mappedByteBuffer 獲取消息
init(final String fileName, final int fileSize)。初始化 fileChannel、mappedByteBuffer
// 將文件的一部分映射到內存,對 mappedByteBuffer 的變更最終會持久化到文件。當映射一旦完成,就不依賴創建它的 fileChannel 了。 關閉 fileChannel 也不會影響映射。
// 內存映射文件的許多細節本質上依賴於底層操作系統,因此不確定。
// 當請求區域並不完全包含在這個 channelFile 中,這個方法的行爲是不確定的。
// 通過此程序或者其他程序,改變文件底層內容或者大小,傳播到 buffer 也是不確定的。
// 改變 buffer 傳播到文件的速率也是不確定的。
// 對於大多數操作系統,將文件映射到內存比通過通常的讀寫方法讀取或寫入幾十千字節的數據更昂貴。 從性能的角度來看,通常只需要將相對較大的文件映射到內存中。
MappedByteBuffer mappedByteBuffer = fileChannel.map(MapMode.READ_WRITE, 0, fileSize);
方式 |
寫入
|
落盤
|
方式一
|
寫入內存字節緩衝區,direct 類型(writeBuffer)
|
從內存字節緩衝區(wirteBuffer) commit 到 FileChannel【fileChannel.write(byteBuffer)】,fileChannel force到磁盤
|
方式二
|
寫入映射文件字節緩衝區(mappedByteBuffer)
|
mappedByteBuffer force 到磁盤
|
// MappedFileQueue 使用 CopyOnWriteArrayList<MappedFile> mappedFiles 存着多個 mappedFile 的組成的 list。
MappedFile mappedFile = this.mappedFileQueue.getLastMappedFile() // 獲取 list 中最後一個 mappedFile
// MappedFileQueue .java
private static final int DELETE_FILES_BATCH_MAX = 10;
private final String storePath; // 存儲路徑 C:\Users\Yibin_Zhu\store\commitlog
private final int mappedFileSize;
// commitLog 在內存中的映射 mappedFile 的 list
private final CopyOnWriteArrayList<MappedFile> mappedFiles = new CopyOnWriteArrayList<MappedFile>();
private final AllocateMappedFileService allocateMappedFileService;
private long flushedWhere = 0; // 從哪開始刷盤
private long committedWhere = 0; // 從哪開始提交
private volatile long storeTimestamp = 0;
MapedFileQueue在獲取getLastMapedFile時,如果需要創建新的MapedFile會計算出下一個MapedFile文件地址,通過預分配服務AllocateMapedFileService異步預創建下一個MapedFile文件,這樣下次創建新文件請求就不要等待,因爲創建文件特別是一個1G的文件還是有點耗時的
11、把消息放入內存中
因爲是順序寫入 commitLog,所以每次都是從 MappedFileQueue 從取最後一個 mappedFile
加鎖寫入
//寫入的時候,有 2 種 buffer 選擇,所以刷盤的時候也是從 2 種 buffer 裏面刷
ByteBuffer byteBuffer = writeBuffer != null ? writeBuffer.slice() : this.mappedByteBuffer.slice();
調用 CommitLog 的內部類 DefaultAppendMessageCallback 的 doAppend() 方法
long wroteOffset = fileFromOffset + byteBuffer.position() // 物理偏移量= 文件偏移量 + buffer寫到的位置
可選:if ((msgLen + END_FILE_MIN_BLANK_LENGTH) > maxBlank) 如果消息大於文件剩餘空間,則文件剩餘空間填充 BLANK
//組裝好消息,寫入 buffer
byteBuffer.put(this.msgStoreItemMemory.array(), 0, msgLen)
12 ~ 15、刷盤 handleDiskFlush(result, putMessageResult, msg)
同步刷盤、異步刷盤
13、 flush(flushLeastPages) 刷盤
同步刷盤類:
// getWroteBytes 即消息長度 msgLen。 long wroteOffset = fileFromOffset + byteBuffer.position();
// GroupCommitRequest request = new GroupCommitRequest(result.getWroteOffset() + result.getWroteBytes());
flushOK = CommitLog.this.mappedFileQueue.getFlushedWhere() >= req.getNextOffset(); // 有新的消息寫入
if (!flushOK) { // 刷盤邏輯
CommitLog.this.mappedFileQueue.flush(0);
}
// 這裏面 countDown
req.wakeupCustomer(flushOK);
// MappedFile.java
// 刷盤邏輯 -> 刷盤前判斷
private boolean isAbleToFlush(final int flushLeastPages) {
int flush = this.flushedPosition.get(); // 刷到了哪個位置
if (this.isFull()) {
return true;
}
if (flushLeastPages > 0) {
return ((write / OS_PAGE_SIZE) - (flush / OS_PAGE_SIZE)) >= flushLeastPages;
}
return write > flush;
}
是 CommitLog 的 內部類 class GroupCommitService extends FlushCommitLogService。刷的時候使用 CountDownLatch wait,刷成功 countdown。【fileChannel.force(false) 或者 mappedByteBuffer.force()】
同步刷盤與異步刷盤的唯一區別是異步刷盤寫完 Page Cache 直接返回,而同步刷盤需要等待刷盤完成才返回。
異步刷盤的類:
// Asynchronous flush
if (!this.defaultMessageStore.getMessageStoreConfig().isTransientStorePoolEnable()) {
flushCommitLogService.wakeup();
} else {
commitLogService.wakeup();
}
(1)是 CommitLog 的 內部類 class FlushRealTimeService extends FlushCommitLogService。 【fileChannel.force(false) 或者 mappedByteBuffer.force()】
(2)或者使用 CommitRealTimeService 異步刷盤。 只是 commit 到 fileChannel 而不 flush 【fileChannel.write(byteBuffer);】
異步刷盤可能的問題:
(1)在有RAID卡,SAS 15000轉磁盤測試順序寫文件,速度可以達到 300M/s 左右,而線上的網卡一般都爲千兆網卡(124MB/s),寫磁盤速度明顯快於數據網絡入口速度,那麼是否可以做到寫完內存就向用戶返回,由後臺線程刷盤呢?
由於磁盤速度大於網卡速度,那麼刷盤的進度肯定可以跟上消息的寫入速度。
(2)萬一由於此時系統壓力過大,可能堆積消息,除了寫入IO,還有讀取IO,萬一出現磁盤讀取落後情況,會不會導致系統內存溢出,答案是否定的,原因如下:
寫入消息到PAGECACHE時,如果內存不足,則嘗試丟棄乾淨的PAGE,騰出內存供新消息使用,策略是LRU方式。
如果幹淨頁不足,此時寫入PAGECACHE會被阻塞,系統嘗試刷盤部分數據,大約每次嘗試32個PAGE,來找出更多幹淨PAGE。
綜上,內存溢出的情況不會出現。
後臺服務 |
操作內容
|
性能
|
CommitRealTimeService
|
異步刷盤 && buff 由 linux 系統自動 flush 到磁盤
|
最好
|
FlushRealTimeService
|
異步刷盤 && buff 由應用 flush 到磁盤
|
中等
|
GroupCommitService
|
同步刷盤
|
最差
|
14、從刷盤 offset 找到需要刷盤的 mappedFile
findMappedFileByOffset(this.flushedWhere, this.flushedWhere == 0)
15、找到 mappedFile 後,開始刷盤 mappedFileQueue.flush(flushLeastPages)
如果使用 FlushRealTimeService 進行異步刷盤,則使用 fileChannel.force(false) 或者 mappedByteBuffer.force() 強制刷盤
16、高可用
如果配置了同步寫入 slave,則同步寫入slave broker
否則,異步寫入 slave
17 ~ 24、ConsumeQueue 數據寫入
在 broker 啓動的時候,會啓動 ConsumerQueue 和 CommitLog 異步校準線程(this.reputMessageService.start())。因爲寫入消息的時候,只同步/異步寫入 commitlog,而 consumer queue 的映射關係當時沒有建立,而是靠這個異步線程來對比 CommitLog 和 ConsumeQueue 來寫入新的消息映射到 ConsumeQueue。(這樣異步更新 ConsumeQueue,又能進一步提升寫入消息性能)
具體執行就在 ReputMessageService.run()
19、offset 對比
reputFromOffset 比 commit 數據 offset 小則說明,有映射關係未建立,則需更新 ConsumeQueue
20、從 commit 拉取數據
getData(reputFromOffset)
21、調度構建 ConsumeQueue
CommitLogDispatcherBuildConsumeQueue.dispatch(DispatchRequest request)
22、存入消息位置信息(即 commitLog 映射信息)
23、根據 topic 和 queueId 能唯一定位到一個 ConsumeQueue (PS:我是不是可以在這裏找到 下一個30分鐘文件)
即找到 ConsumeQueue 對應的文件
24、將 ConsumeQueue 信息寫入緩衝區
this.fileChannel.position(currentPos);
this.fileChannel.write(ByteBuffer.wrap(data));
後面線程異步去刷入磁盤
25 ~ 建立索引
doDispatch(dispatchRequest) 是一個列表循環處理,一個是處理 ConsumeQueue,另一個就是構建索引。構建索引是爲了按照 MessageKey 查詢消息,在創建 Message 的時候可選指定。
下面引用官方文檔:
RocketMQ可以爲每條消息指定Key,並根據建立高效的消息索引,索引邏輯結果如上圖所示(PS:類似 HashMap 的索引),查詢過程如下:
1、根據查詢的key的hashcode%slotNum得到具體的槽的位置(slotNum是一個索引文件裏面包含的最大槽的數目,例如圖中所示slotNum=500W)。
2、根據slotValue(slot位置對應的值)查找到索引項列表的最後一項(倒序排列,slotValue總是指向最新的一個索引項)。
3、遍歷索引項列表返回查詢時間範圍內的結果集(默認一次最大返回的32條記錄)
4、Hash衝突;尋找key的slot位置時相當於執行了兩次散列函數,一次key的hash,一次key的hash值取模,因此這裏存在兩次衝突的情況;第一種,key的hash值不同但模數相同,此時查詢的時候會在比較一次key的hash值(每個索引項保存了key的hash值),過濾掉hash值不相等的項。第二種,hash值相等但key不等,出於性能的考慮衝突的檢測放到客戶端處理(key的原始值是存儲在消息文件中的,避免對數據文件的解析),客戶端比較一次消息體的key是否相同。
5、存儲;爲了節省空間索引項中存儲的時間是時間差值(存儲時間-開始時間,開始時間存儲在索引文件頭中),整個索引文件是定長的,結構也是固定的 。
索引建立過程:
public boolean putKey(final String key, final long phyOffset, final long storeTimestamp) {
if (this.indexHeader.getIndexCount() < this.indexNum) { // 索引最大數量是 indexNum=2000W
int keyHash = indexKeyHashMethod(key); // 第一次散列。
int slotPos = keyHash % this.hashSlotNum; // 第二次散列。 hashSlotNum=500W key具體槽位。參看 1、根據查詢的key的hashcode%slotNum得到具體的槽的位置
int absSlotPos = IndexHeader.INDEX_HEADER_SIZE + slotPos * hashSlotSize; // Slot 的位置。INDEX_HEADER_SIZE=40 hashSlotSize=4
FileLock fileLock = null;
try {
// fileLock = this.fileChannel.lock(absSlotPos, hashSlotSize,
// false);
int slotValue = this.mappedByteBuffer.getInt(absSlotPos); // 拿到 slot 中的值
if (slotValue <= invalidIndex || slotValue > this.indexHeader.getIndexCount()) {
slotValue = invalidIndex;
}
long timeDiff = storeTimestamp - this.indexHeader.getBeginTimestamp();
timeDiff = timeDiff / 1000;
if (this.indexHeader.getBeginTimestamp() <= 0) {
timeDiff = 0;
} else if (timeDiff > Integer.MAX_VALUE) {
timeDiff = Integer.MAX_VALUE;
} else if (timeDiff < 0) {
timeDiff = 0;
}
// Index 在索引文件具體位置 indexSize=20
int absIndexPos =
IndexHeader.INDEX_HEADER_SIZE + this.hashSlotNum * hashSlotSize
+ this.indexHeader.getIndexCount() * indexSize;
// 消息數據存放
this.mappedByteBuffer.putInt(absIndexPos, keyHash);
this.mappedByteBuffer.putLong(absIndexPos + 4, phyOffset);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8, (int) timeDiff);
this.mappedByteBuffer.putInt(absIndexPos + 4 + 8 + 4, slotValue); // 指向前一個節點
// 存入索引。 根據Message 的 Key 找到 absSlotPos和對應的value indexCount,根據 indexCount可以找到 absIndexPos 位置
this.mappedByteBuffer.putInt(absSlotPos, this.indexHeader.getIndexCount());
if (this.indexHeader.getIndexCount() <= 1) {
this.indexHeader.setBeginPhyOffset(phyOffset);
this.indexHeader.setBeginTimestamp(storeTimestamp);
}
this.indexHeader.incHashSlotCount();
this.indexHeader.incIndexCount();
this.indexHeader.setEndPhyOffset(phyOffset);
this.indexHeader.setEndTimestamp(storeTimestamp);
return true;
} catch (Exception e) {
log.error("putKey exception, Key: " + key + " KeyHashCode: " + key.hashCode(), e);
} finally {
if (fileLock != null) {
try {
fileLock.release();
} catch (IOException e) {
log.error("Failed to release the lock", e);
}
}
}
} else {
log.warn("Over index file capacity: index count = " + this.indexHeader.getIndexCount()
+ "; index max num = " + this.indexNum);
}
return false;
}
消息刪除:
刪除過期 CommitLog(默認超過 72 小時刪除)
刪除過期 ConsumeQueue(跟 CommitLog offset 對比,commitLog 偏移量大於 CQ 存儲的偏移量,說明響應的 commitLog已經被刪除了,CQ也就可以刪除了)
Broker 重啓數據恢復:
如何找到上次刷盤位置?
“checkpoint”此文件會記錄刷盤的時間戳,恢復時,根據時間戳來掃描 Commit Log 的存儲時間戳,就可以找到從
哪裏開始恢復。
如果此文件丟失,則會對 Commit Log 進行全盤掃描恢復,這種情況會耗時較長。
broker 啓動的時候
// BrokerController.java
messageStore.load()
// DefaultMessageStore.java
public boolean load() {
boolean result = true;
try {
boolean lastExitOK = !this.isTempFileExist();
log.info("last shutdown {}", lastExitOK ? "normally" : "abnormally");
if (null != scheduleMessageService) {
result = result && this.scheduleMessageService.load(); // 定時消息加載
}
// load Commit Log 加載 CommitLog 此時只是把文件內存加載到 MappedFile,具體wrotePosition、committedPosition 等只是全部設爲 fileSize,還未恢復
// load Consume Queue 加載 ConsumeQueue
result = result && this.loadConsumeQueue();
if (result) {
this.storeCheckpoint =
new StoreCheckpoint(StorePathConfigHelper.getStoreCheckpoint(this.messageStoreConfig.getStorePathRootDir()));
// 加載索引
this.indexService.load(lastExitOK);
// 恢復
this.recover(lastExitOK);
log.info("load over, and the max phy offset = {}", this.getMaxPhyOffset());
}
} catch (Exception e) {
log.error("load exception", e);
result = false;
}
if (!result) {
this.allocateMappedFileService.shutdown();
}
return result;
}
// 恢復
private void recover(final boolean lastExitOK) {
// 遍歷 ConsumeQueue 的消息,來恢復 MappedFile 的 wrotePosition、committedPosition、flushedPosition,
// mappedFileQueue 的 flushedWhere、committedWhere,當然遍歷完也知道了 maxPhysicOffset,即commitLog 中最大的寫入位置。
// flushedWhere、committedWhere 和 wrotePosition、committedPosition、flushedPosition 正常情況下就都是相同的,處理到具體的什麼位置,即文件名(帶有文件偏移信息) + 文件內的偏移。
this.recoverConsumeQueue();
if (lastExitOK) {
this.commitLog.recoverNormally();
} else {
// 根目錄下有 abort 文件,說明未正常關閉 broker,所以走這裏恢復。
// 基本上和恢復 ConsumeQueue 差不多,就是如果有 checkpoint 文件的時間戳找到對應要恢復的文件(其實就是最後一個文件,因爲是順序寫入的),會快點,也是遍歷相應文件找到寫到什麼位置。
// 恢復 flushedWhere、committedWhere 和 wrotePosition、committedPosition、flushedPosition。
this.commitLog.recoverAbnormally();
}
this.recoverTopicQueueTable();
}
提示:
BrokerId = 0 表明 broker 爲 master。其他爲 slaver。master 可以讀/寫,而 slaver 只能讀。
沒有用zk選主,所以 broker 主從不會切換,啓動了就固定了。