LSM-tree 基本原理及應用
LSM-tree 在 NoSQL 系統裡非常常見,基本已經成為必選方案了。今天介紹一下 LSM-tree 的主要思想,再舉一個 LevelDB 的例子。
正文 3056 字,預計閱讀時間 8 分鐘。
LSM-tree
起源於 1996 年的一篇論文《The Log-Structured Merge-Tree (LSM-Tree)》,這篇論文 32 頁,我一直沒讀,對 LSM 的學習基本都來自頂會論文的背景知識以及開源系統文件。今天的內容和圖片主要來源於 FAST'16 的《WiscKey: Separating Keys from Values in SSD-conscious Storage》。
先看名字,log-structured,日誌結構的,日誌是軟體系統打出來的,就跟人寫日記一樣,一頁一頁往下寫,而且系統寫日誌不會寫錯,所以不需要更改,只需要在後邊追加就好了。各種資料庫的寫前日誌也是追加型的,因此日誌結構的基本就指代追加。注意他還是個 “Merge-tree”,也就是“合併-樹”,合併就是把多個合成一個。
好,不扯淡了,說正文了。
LSM-tree 是專門為 key-value 儲存系統設計的,key-value 型別的儲存系統最主要的就兩個個功能,put(k,v):寫入一個(k,v),get(k):給定一個 k 查詢 v。
LSM-tree 最大的特點就是寫入速度快,主要利用了磁碟的順序寫,pk掉了需要隨機寫入的 B-tree。關於磁碟的順序和隨機寫可以參考:《
硬碟的各種概念
》
下圖是 LSM-tree 的組成部分,是一個多層結構,就更一個樹一樣,上小下大。首先是記憶體的 C0 層,儲存了所有最近寫入的 (k,v),這個記憶體結構是有序的,並且可以隨時原地更新,同時支援隨時查詢。剩下的 C1 到 Ck 層都在磁碟上,每一層都是一個在 key 上有序的結構。
寫入流程:一個 put(k,v) 操作來了,首先追加到寫前日誌(Write Ahead Log,也就是真正寫入之前記錄的日誌)中,接下來加到 C0 層。當 C0 層的資料達到一定大小,就把 C0 層 和 C1 層合併,類似歸併排序,這個過程就是Compaction(合併)。合併出來的新的 new-C1 會順序寫磁碟,替換掉原來的 old-C1。當 C1 層達到一定大小,會繼續和下層合併。合併之後所有舊檔案都可以刪掉,留下新的。
注意資料的寫入可能重複,新版本需要覆蓋老版本。什麼叫新版本,我先寫(a=1),再寫(a=233),233 就是新版本了。假如 a 老版本已經到 Ck 層了,這時候 C0 層來了個新版本,這個時候不會去管底下的檔案有沒有老版本,老版本的清理是在合併的時候做的。
寫入過程基本只用到了記憶體結構,Compaction 可以後臺非同步完成,不阻塞寫入。
查詢流程:在寫入流程中可以看到,最新的資料在 C0 層,最老的資料在 Ck 層,所以查詢也是先查 C0 層,如果沒有要查的 k,再查 C1,逐層查。
一次查詢可能需要多次單點查詢,稍微慢一些。所以 LSM-tree 主要針對的場景是寫密集、少量查詢的場景。
LSM-tree 被用在各種鍵值資料庫中,如 LevelDB,RocksDB,還有分散式行式儲存資料庫 Cassandra 也用了 LSM-tree 的儲存架構。
LevelDB
其實光看上邊這個模型還有點問題,比如將 C0 跟 C1 合併之後,新的寫入怎麼辦?另外,每次都要將 C0 跟 C1 合併,這個後臺整理也很麻煩啊。這裡以 LevelDB 為例,看一下實際系統是怎麼利用 LSM-tree 的思想的。
下邊這個圖是 LevelDB 的架構,首先,LSM-tree 被分成三種檔案,第一種是記憶體中的兩個 memtable,一個是正常的接收寫入請求的 memtable,一個是不可修改的immutable memtable。
另外一部分是磁碟上的 SStable (Sorted String Table),有序字串表,這個有序的字串就是資料的 key。SStable 一共有七層(L0 到 L6)。下一層的總大小限制是上一層的 10 倍。
寫入流程:首先將寫入操作加到寫前日誌中,接下來把資料寫到 memtable中,當 memtable 滿了,就將這個 memtable 切換為不可更改的 immutable memtable,並新開一個 memtable 接收新的寫入請求。而這個 immutable memtable 就可以刷磁碟了。這裡刷磁碟是直接刷成 L0 層的 SSTable 檔案,並不直接跟 L0 層的檔案合併。
每一層的所有檔案總大小是有限制的,每下一層大十倍。一旦某一層的總大小超過閾值了,就選擇一個檔案和下一層的檔案合併。就像玩 2048 一樣,每次能觸發合併都會觸發,這在 2048 裡是最爽的,但是在系統裡是挺麻煩的事,因為需要倒騰的資料多,但是也不是壞事,因為這樣可以加速查詢。
這裡注意,所有下一層被影響到的檔案都會參與 Compaction。合併之後,保證 L1 到 L6 層的每一層的資料都是在 key 上全域性有序的。而 L0 層是可以有重疊的。
上圖是個例子,一個 immutable memtable 刷到 L0 層後,觸發 L0 和 L1 的合併,假如黃色的檔案是涉及本次合併的,合併後,L0 層的就被刪掉了,L1 層的就更新了,L1 層還是全域性有序的,三個檔案的資料順序是 abcdef。
雖然 L0 層的多個檔案在同一層,但也是有先後關係的,後面的同個 key 的資料也會覆蓋前面的。這裡怎麼區分呢?為每個key-value加個版本號。所以在 Compaction 時候應該只會留下最新的版本。
查詢流程:先查memtable,再查 immutable memtable,然後查 L0 層的所有檔案,最後一層一層往下查。
LSM-tree讀寫放大
讀寫放大(read and write amplification)是 LSM-tree 的主要問題,這麼定義的:讀寫放大 = 磁碟上實際讀寫的資料量 / 使用者需要的資料量。注意是和磁碟互動的資料量才算,這份資料在記憶體裡計算了多少次是不關心的。比如使用者本來要寫 1KB 資料,結果你在記憶體裡計算了1個小時,最後往磁碟寫了 10KB 的資料,寫放大就是 10,讀也類似。
寫放大:我們以 RocksDB 的 Level Style Compaction 機制為例,這種合併機制每次拿上一層的所有檔案和下一層合併,下一層大小是上一層的 r 倍。這樣單次合併的寫放大就是 r 倍,這裡是 r 倍還是 r+1 倍跟具體實現有關,我們舉個例子。
假如現在有三層,檔案大小分別是:9,90,900,r=10。又寫了個 1,這時候就會不斷合併,1+9=10,10+90=100,100+900=1000。總共寫了 10+100+1000。按理來說寫放大應該為 1110/1,但是各種論文裡不是這麼說的,論文裡說的是等號右邊的比上加號左邊的和,也就是10/1 + 100/10 + 1000/100 = 30 = r * level。個人感覺寫放大是一個過程,用一個數字衡量不太準確,而且這也只是最壞情況。
讀放大:為了查詢一個 1KB 的資料。最壞需要讀 L0 層的 8 個檔案,再讀 L1 到 L6 的每一個檔案,一共 14 個檔案。而每一個檔案內部需要讀 16KB 的索引,4KB的布隆過濾器,4KB的資料塊(看不懂不重要,只要知道從一個SSTable裡查一個key,需要讀這麼多東西就可以了)。一共 24*14/1=336倍。key-value 越小讀放大越大。
總結
關於 LSM-tree 的內容和 LevelDB 的設計思想就介紹完了,主要包括寫前日誌 WAL,memtable,SStable 三個部分。逐層合併,逐層查詢。LSM-tree 的主要劣勢是讀寫放大,關於讀寫放大可以通過一些其