一、存儲引擎介紹
講LSM樹之前,需要提下三種基本的存儲引擎,這樣才能清楚LSM樹的由來:
1.1 哈希存儲引擎
是哈希表的持久化實現,支持增、刪、改以及隨機讀取操作,但不支持順序掃描,對應的存儲系統為key-value存儲系統。對於key-value的插入以及查詢,哈希表的復雜度都是O(1),明顯比樹的操作O(n)快,如果不需要有序的遍歷數據,哈希表就是your Mr.Right
代表數據庫:redis、memcache等
通常也常見於其他存儲引擎的查找速度優化上。 Hash 索引結構的特殊性,其檢索效率非常高,索引的檢索可以一次定位,不像B-Tree 索引需要從根節點到枝節點,最后才能訪問到頁節點這樣多次的IO訪問,所以 Hash 索引的查詢效率要遠高於 B-Tree 索引。雖然 Hash 索引效率高,但是 Hash 索引本身由於其特殊性也帶來了很多限制和弊端。
這里列舉缺點:
(1)Hash 索引僅僅能滿足"=","IN"和"<=>"查詢,不能使用范圍查詢。
(2)Hash 索引無法被用來避免數據的排序操作。
(3)Hash 索引不能利用部分索引鍵查詢。
(4)Hash 索引在任何時候都不能避免表掃描。
1.2 B樹存儲引擎
B樹(關於B樹的由來,數據結構以及應用場景可以看之前一篇博文)的持久化實現,不僅支持單條記錄的增、刪、讀、改操作,還支持順序掃描(B+樹的葉子節點之間的指針),對應的存儲系統就是關系數據庫(Mysql等)。
代表數據庫:MongoDB、mysql(基本上關系型數據庫)等
1.3 LSM樹(Log-Structured Merge Tree)存儲引擎
B樹存儲引擎一樣,同樣支持增、刪、讀、改、順序掃描操作。而且通過批量存儲技術規避磁盤隨機寫入問題。當然凡事有利有弊,LSM樹和B+樹相比,LSM樹犧牲了部分讀性能,用來大幅提高寫性能。
LSM被設計來提供比傳統的B+樹或者ISAM更好的寫操作吞吐量,通過消去隨機的本地更新操作來達到這個目標。
那么為什么這是一個好的方法呢?這個問題的本質還是磁盤隨機操作慢,順序讀寫快的老問題。這二種操作存在巨大的差距,無論是磁盤還是SSD。
上圖很好的說明了這一點,他們展現了一些反直覺的事實,順序讀寫磁盤(不管是SATA還是SSD)快於隨機讀寫主存,而且快至少三個數量級。這說明我們要避免隨機讀寫,最好設計成順序讀寫。
The Base LSM Algorithm
從概念上說,最基本的LSM是很簡單的 。將之前使用一個大的查找結構(造成隨機讀寫,影響寫性能),變換為將寫操作順序的保存到一些相似的有序文件(也就是sstable)中。所以每個文件包 含短時間內的一些改動。因為文件是有序的,所以之后查找也會很快。文件是不可修改的,他們永遠不會被更新,新的更新操作只會寫到新的文件中。讀操作檢查很 有的文件。通過周期性的合並這些文件來減少文件個數。
讓我們更具體的看看,當一些更新操作到達時,他們會被寫到內存緩存(也就是memtable)中,memtable使用樹結構來保持key的有序,在大部 分的實現中,memtable會通過寫WAL的方式備份到磁盤,用來恢復數據,防止數據丟失。當memtable數據達到一定規模時會被刷新到磁盤上的一 個新文件,重要的是系統只做了順序磁盤讀寫,因為沒有文件被編輯,新的內容或者修改只用簡單的生成新的文件。
所以越多的數據存儲到系統中,就會有越多的不可修改的,順序的sstable文件被創建,它們代表了小的,按時間順序的修改。
因為比較舊的文件不會被更新,重復的紀錄只會通過創建新的紀錄來覆蓋,這也就產生了一些冗余的數據。
所以系統會周期的執行合並操作(compaction)。 合並操作選擇一些文件,並把他們合並到一起,移除重復的更新或者刪除紀錄,同時也會刪除上述的冗余。更重要的是,通過減少文件個數的增長,保證讀操作的性 能。因為sstable文件都是有序結構的,所以合並操作也是非常高效的。
當一個讀操作請求時,系統首先檢查內存數據(memtable),如果沒有找到這個key,就會逆序的一個一個檢查sstable文件,直到key 被找到。因為每個sstable都是有序的,所以查找比較高效(O(logN)),但是讀操作會變的越來越慢隨着sstable的個數增加,因為每一個 sstable都要被檢查。(O(K log N), K為sstable個數, N 為sstable平均大小)。
所以,讀操作比其它本地更新的結構慢,幸運的是,有一些技巧可以提高性能。最基本的的方法就是頁緩存(也就是leveldb的 TableCache,將sstable按照LRU緩存在內存中)在內存中,減少二分查找的消耗。LevelDB 和 BigTable 是將 block-index 保存在文件尾部,這樣查找就只要一次IO操作,如果block-index在內存中。一些其它的系統則實現了更復雜的索引方法。
即使有每個文件的索引,隨着文件個數增多,讀操作仍然很慢。通過周期的合並文件,來保持文件的個數,因些讀操作的性能在可接收的范圍內。即便有了合 並操作,讀操作仍然會訪問大量的文件,大部分的實現通過布隆過濾器來避免大量的讀文件操作,布隆過濾器是一種高效的方法來判斷一個sstable中是否包 含一個特定的key。(如果bloom說一個key不存在,就一定不存在,而當bloom說一個文件存在是,可能是不存在的,只是通過概率來保證)
所有的寫操作都被分批處理,只寫到順序塊上。另外,合並操作的周期操作會對IO有影響,讀操作有可能會訪問大量的文件(散亂的讀)。這簡化了算法工 作的方法,我們交換了讀和寫的隨機IO。這種折衷很有意義,我們可以通過軟件實現的技巧像布隆過濾器或者硬件(大文件cache)來優化讀性能。
Basic Compaction
為了保持LSM的讀操作相對較快,維護並減少sstable文件的個數是很重要的,所以讓我們更深入的看一下合並操作。這個過程有一點兒像一般垃圾回收算法。
當一定數量的sstable文件被創建,例如有5個sstable,每一個有10行,他們被合並為一個50行的文件(或者更少的行數)。這個過程一 直持續着,當更多的有10行的sstable文件被創建,當產生5個文件時,它們就被合並到50行的文件。最終會有5個50行的文件,這時會將這5個50 行的文件合並成一個250行的文件。這個過程不停的創建更大的文件。像下圖:
上述的方案有一個問題,就是大量的文件被創建,在最壞的情況下,所有的文件都要搜索。
Levelled Compaction
更新的實現,像 LevelDB 和 Cassandra解決這個問題的方法是:實現了一個分層的,而不是根據文件大小來執行合並操作。這個方法可以減少在最壞情況下需要檢索的文件個數,同時也減少了一次合並操作的影響。
按層合並的策略相對於上述的按文件大小合並的策略有二個關鍵的不同:
- 每一層可以維護指定的文件個數,同時保證不讓key重疊。也就是說把key分區到不同的文件。因此在一層查找一個key,只用查找一個文件。第一層是特殊情況,不滿足上述條件,key可以分布在多個文件中。
- 每次,文件只會被合並到上一層的一個文件。當一層的文件數滿足特定個數時,一個文件會被選出並合並到上一層。這明顯不同與另一種合並方式:一些相近大小的文件被合並為一個大文件。
這些改變表明按層合並的策略減小了合並操作的影響,同時減少了空間需求。除此之外,它也有更好的讀性能。但是對於大多數場景,總體的IO次數變的更多,一些更簡單的寫場景不適用。
總結
所以, LSM 是日志和傳統的單文件索引(B+ tree,Hash Index)的中立,他提供一個機制來管理更小的獨立的索引文件(sstable)。
通過管理一組索引文件而不是單一的索引文件,LSM 將B+樹等結構昂貴的隨機IO變的更快,而代價就是讀操作要處理大量的索引文件(sstable)而不是一個,另外還是一些IO被合並操作消耗。
通過以上的分析,應該知道LSM樹的由來了,LSM樹的設計思想非常朴素:將對數據的修改增量保持在內存中,達到指定的大小限制后將這些修改操作批量寫入磁盤,不過讀取的時候稍微麻煩,需要合並磁盤中歷史數據和內存中最近修改操作,所以寫入性能大大提升,讀取時可能需要先看是否命中內存,否則需要訪問較多的磁盤文件。極端的說,基於LSM樹實現的HBase的寫性能比Mysql高了一個數量級,讀性能低了一個數量級。
LSM樹原理把一棵大樹拆分成N棵小樹,它首先寫入內存中,隨着小樹越來越大,內存中的小樹會flush到磁盤中,磁盤中的樹定期可以做merge操作,合並成一棵大樹,以優化讀性能。
LSM的結構
LSM的基本思想是將修改的數據保存在內存,達到一定數量后在將修改的數據批量寫入磁盤,在寫入的過程中與之前已經存在的數據做合並。同B樹存儲模型一樣,LSM存儲模型也支持增、刪、讀、改以及順序掃描操作。LSM模型利用批量寫入解決了隨機寫入的問題,雖然犧牲了部分讀的性能,但是大大提高了寫的性能。
MemTable
LSM本身由MemTable,Immutable MemTable,SSTable等多個部分組成,其中MemTable在內存,用於記錄最近修改的數據,一般用跳躍表來組織。當MemTable達到一定大小后,將其凍結起來變成Immutable MemTable,然后開辟一個新的MemTable用來記錄新的記錄。而Immutable MemTable則等待轉存到磁盤。
Immutable MemTable
所謂Immutable MemTable,即是只能讀不能寫的內存表。內存部分已經有了MemTable,為什么還要使用Immutable MemTable?個人認為其原因是為了不阻塞寫操作。因為轉存的過程中必然要保證內存表的記錄不變,否則如果新插入的記錄夾在兩條已經轉存到磁盤的記錄中間,處理上會很麻煩,轉存期間勢必要鎖住全表,這樣一來就會阻塞寫操作。所以不如將原有的MemTable變成只讀Immutable MemTable,在開辟一個新的MemTable用於寫入,即簡單,又不影響寫操作。
SSTable
SSTable是本意是指有序的鍵值對集合( a set of sorted key-value pairs )。是一個簡單有用的集合,正如它的名字一樣,它存儲的就是一系列的鍵值對。當文件較大的時候,還可以為其建立一個鍵-值的位置的索引,指明每個鍵在SSTable文件中的偏移距離。這樣可以加速在SSTable中的查詢。(當然這一點是可選的,同時讓我想去了Bitcask模型中hint文件,通過記錄 鍵-值的位置 ,來加速索引構建)
使用MemTable和SSTable這兩個組件,可以構建一個最簡單的LSM存儲模型。這個模型與Bitcask模型相比,不存在啟動時間長的問題,但是這個模型的讀性能非常的差,因為一但在MemTable找不到相應的鍵,則需要在根據SSTable文件生成的時間,從最近到較早在SSTable中尋找,如果都不存在的話,則會遍歷完所有的SSTable文件。
如果SSTable文件個數很多或者沒有建立SSTable的文件內索引的話,讀性能則會大大下降。
除了在對SSTable內部建立索引外,還可以使用Bloom Fileter,提高Key不在SSTable的判定速度。同樣,定期合並舊的SSTable文件,在減少存儲的空間的同時,也能提高讀取的速度。下面這幅圖很好的描述了在LSM的大部分結構和操作
LevelDB如何優化讀性能
Leveldb是一個輕量級的,快速的以存儲為目的的key-value存儲引擎。其使用的正是LSM存儲模型。我們可以看看LevelDB是如何來優化讀性能的。在LevelDB中,存在一種元信息文件MANIFEST,用於記錄leveldb的元信息,比如DB使用的Comparator名,以及各SSTable文件的管理信息:如Level層數、文件名、最小key和最大key等等。相比而言,元信息文件而SSTable文件的數目成正比,一般來說不會太多,是可以載入內存的,因此Level可以通過查詢元信息,從而判斷哪些文件中存在我們需要的Key對應的記錄,減少SSTable文件讀取次數。此外,LevelDB的合並操作Compaction是分層次進行的,每一層都有多個SSTable文件,每次合並后除了Level0和內存的MemTable,Immutable MemTable中會有重復的鍵值外,LevelN(N>=1)的各層內部的SSTable文件不會再有重復的鍵值。同時,如果在Level N 層讀到了數據,那么就不需要再往后讀Level N+1,Level N+2等層的數據了.因為Level N層的數據總是比Level N+1等層的數據更“新鮮”。
實現一個簡單的LSM存儲模型
根據上面講述的原理,實現了一個簡單的LSM模型(https://github.com/ym65536/Distributed_System/blob/master/Storage/LSM_Tree.py)。這個模型也內存表為一個跳躍表,SSTable就是簡單的有序鍵值對集合,沒有SSTable內部使用索引,沒有使用Bloom過濾器。其實能就是將我之前的Bitcask模型進行了簡單的改造:
- 將原來的哈希表換成了跳躍表;
- 原來讀取記錄完全依賴哈希表,現在如果在跳躍表中沒有的話,就去讀取文件SSTable文件中的數據,根據文件編號從大到小進行,編號越大,表示數據越新;
- 去掉了加載數據的功能(LSM不需要);
簡單起見,沒有完成對范圍掃描的支持,不過內存表和SSTable都是有序的,因此這個也不是很難。