本文作者:劉濤,阿里雲智慧技術專家。
01 Compaction Topic介紹
一般來說,訊息佇列提供的資料過期機制有如下幾種,比如有基於時間的過期機制——資料儲存多長時間後即進行清理,也有基於資料總量的過期機制——資料分割槽資料量達到一定值後進行清理。
而 Compaction Topic 是一種基於 key 的資料過期機制,即對於相同 key 的資料只保留最新值。
該特性的應用場景主要為維護狀態資訊,或者在需要用到 KV 結構時,可以透過 Compaction Topic 將 key-value 資訊直接儲存到 MQ,從而解除對外部資料庫的依賴。比如維護消費位點,可以將消費組加分割槽作為 key ,將消費位點做 offset ,以訊息形式傳送到 MQ ,壓縮之後,消費時獲取最新 offset 資訊即可。另外,像 connect 裡的 source 資訊比如 Binlog 解析位點或其他 source 處理的位點資訊均可存到 Compaction Topic。同時 Compaction Topic 也支援 儲存 RSQLDB 與 RStreams 的 checkpoint 資訊。
02 需要解決的問題
Compaction 過程中,需要解決如下幾個問題:
第一,資料寫入過程中,資料如何從生產者傳送到 broker 並且最終落盤,資料主備之間的 HA 如何保證?
第二,整個 compaction 的流程包括哪幾個步驟?如果資料量太大,如何最佳化?
第三,資料消費時如何索引訊息?如果找不到訊息指定的 offset 訊息,如何處理?
第四,如果有機器故障,如何恢復老資料?
03 方案設計與實現
第一,資料如何寫入。
首先寫入到 CommitLog,主要為複用 CommitLog 本身的 HA 能力。然後透過 reput執行緒將 CommitLog 訊息按照 Topic 加 partition 的維度拆分到不同檔案裡,按分割槽整理訊息,同時生成索引。這樣最終訊息就按 Topic 加 partition的粒度做了規整。
在 compaction 過程中,為什麼不在原先的 commitLog 上做規整,而是再額外按分割槽做規整?原因如下:
- 所有資料都會寫到 CommitLog ,因此單個 Topic 的資料不連續。如果要遍歷單個 topic 的所有資料,可能需要跳著讀,這樣就會導致大量冷讀,對磁碟 IO 影響比較大。
- CommitLog 資料有自動過期機制,會將老資料刪除,因此不能將資料直接寫到 CommitLog,而 CompactionLog 裡的老資料為按 key 過期,不一定會刪除。
- compact 以分割槽為維度進行。如果多個分割槽同時做 compact ,效率較低。因為很多分割槽的 key 同時在一個結構裡,會導致同一個分割槽能夠 compact 的資料比較少, 並且 compact 之後也需要重新寫一份麼,因此,索性就在 compact 之前將訊息透過 reput service 重新歸整一遍。
Compact 流程如下:
第一步,確定需要做 compaction 的資料檔案列表。一般大於兩個檔案,需要排除當前正在寫的檔案。
第二步,將上一步篩選出的檔案做遍歷,得到 key 到 offset 的對映關係。
第三步,根據對映關係將需要保留的資料重新寫到新檔案。
第四步,用新檔案替換老檔案,將老檔案刪除。
第二步的構建 OffsetMap 主要目的在於可以知道哪檔案需要被保留、哪檔案需要被刪除,以及檔案的前後關係,這樣就可以確定寫入的佈局,確定佈局之後,就可以按照append 的方式將需要保留的資料寫到新檔案。
此處記錄的並非 key 到 value 的資訊,而是 key 到 Offset 的資訊。因為 value 的資料 body 可能較長,比較佔空間,而 offset 是固定長度,且透過 offset 資訊也可以明確訊息的先後順序。另外,key 的長度也不固定,直接在 map 儲存原始 key 並不合適。因此我們將 MD5 作為新 key ,如果 MD5 相同 key 認為也相同。
做 compaction 時會遍歷所有訊息,將相同 key 且 offset 小於 OffsetMap 的值刪除。最終透過原始資料與 map 結構得到壓縮之後的資料檔案。
上圖為目錄結構展示。寫入時上面為資料檔案,下面為索引,要 compact 的是標紅兩個檔案。壓縮後的檔案儲存於子目錄,需要將老檔案先標記為刪除,將子目錄檔案與 CQ 同時移到老的根目錄。注意,檔案與 CQ 檔名一一對應,可以一起刪除。
隨著資料量越來越大,構建的 OffsetMap 也會越來越大,導致無法容納。
因此不能使用全量構建方式,不能將所有要 compact 的檔案的 OffsetMap 一次性構建,需要將全量構建改為增量構建,構建邏輯也會有小的變化。
第一輪構建:如上圖,先構建上面部分的 OffsetMap ,然後遍歷檔案,如果 offset 小於 OffsetMap 中對應 key 的 offset 則刪除,如果等於則保留。而下面部分的訊息的offset 肯定大於 OffsetMap 內的 offset ,因此也需要保留。
第二輪構建:從上一次結束的點開始構建。如果上一輪中的某個 key 在新一輪中不存在,則保留上一輪的值;如果存在,則依然按照小於刪除、大於保留的原則進行構建。
將一輪構建變為兩輪構建後, OffsetMap 的大小顯著降低,構建的資料量也顯著降低。
原先的索引為 CommitLog Position、Message Size 和 Tag Hush,而現在我們複用了bcq 結構。由於 Compact 之後資料不連續,無法按照先前的方式直接查詢資料所在物理位置。由於 queueOffset 依然為單調增排列,因此可以透過二分查詢方式將索引找出。
二分查詢需要 queueoffset 資訊,索引結構也會發生變化,而 bcq 帶有 queueoffse 資訊,因此可以複用 bcq 的結構。
Queueoffset 在 compact 前後保持不變。如果 queueoffset 不存在,則獲取第一個大於 queueoffset 的訊息,然後從頭開始將所有全量資料傳送給客戶端。
機器故障導致訊息丟失時,需要做備機的重建。因為 CommitLog 只能恢復最新資料,而 CompactionLog 需要老資料。之前的 HA 方式下,資料檔案可能在 compact 過程中被被刪除,因此也不能基於複製檔案的方式做主備間同步。
因此,我們實現了基於 message 的複製。即模擬消費請求從 master 上拉取訊息。拉取位點一般從 0 開始,大於等於 commitLog 最小offset 時結束。拉取結束之後,再做一次 force compaction 將 CommitLog 資料與恢復時的資料做一次 compaction ,以保證保留的資料是被壓縮之後的資料。後續流程不變。
04 使用說明
生產者側使用現有生產者介面,因為要按分割槽做 compact ,因此需要將相同 key 路由到相同的 MessageQueue,需要自己實現相關演算法。
消費者側使用現有消費者介面,消費到訊息後,存入本地類 Map 結構中再進行使用。我們的場景大多為從頭開始拉資料,因此需要在開始時將消費位點重置到0。拉取完以後,將訊息 key 與 value 傳入本地 kv 結構,使用時直接從該結構拿取即可。