摘要:Hash索引有兩個明顯的限制:(1)當key的數量很多時,維護Hash索引會給內存帶來很大的壓力;(2)區間查詢很低效。如何對這兩個限制進行優化呢?這就輪到本文介紹的主角,LSM樹,出場了。
我們通過append-only log的數據結構,實現了一個具備高寫入性能的key-value數據庫。append-only log之所以有很高的寫入性能,主要得益於磁盤的順序寫入。這可能違反了我們對磁盤的認知,因為在我們的印象中,寫磁盤總是很慢。其實不然,准確地說應該是隨機寫磁盤很慢,因為在寫之前可能會進行多次尋址。如果只是順序寫磁盤,性能是非常的高,如下的一個ACM報告中顯示,順序寫磁盤甚至比隨機寫內存的性能還要高!
舉個例子,Kafka是一個高性能的消息隊列,它的厲害之處就在於極致地利用磁盤的順序寫入性能,如果生產者和消費者的速率相當,消息甚至可以在操作系統的Page Cache層面就完成了傳遞。所以,以后別再認為寫磁盤很慢了!
append-only log大幅提升了數據寫入性能,但是隨之而來的是,非常低的數據讀取性能。針對這一點,我們采用Hash索引進行了優化,優化的效果也非常的顯著。然而,Hash索引有兩個明顯的限制:(1)當key的數量很多時,維護Hash索引會給內存帶來很大的壓力;(2)區間查詢很低效。如何對這兩個限制進行優化呢?這就輪到本文介紹的主角,LSM樹,出場了。
LSM樹(Log-Structured Merge Tree)並不是一種數據結構,准確來說是一種存儲模型,由MemTable、Immutable MemTable、SSTable等部分組成。它也是利用了append-only log的優勢,大幅提升了寫入性能。同時,因為key的存儲有序性,所以具備了不錯的讀取性能,也克服了上文所述Hash索引的兩個限制。下面,本文將一步步分析LSM樹是如何做到這一點的。
SSTable
在最簡單的數據庫例子中,因為數據是無序存儲的,所以在讀取某個key的值時,就需要遍歷整個數據文件,算法復雜度是O(n)。為了提升讀性能,我們不得不在內存中維護所有key的Hash索引。
假如存儲數據時,對記錄按照key進行排序的會怎樣?
對於key有序存儲這種情況,即使不用Hash索引,也能得到很好的查詢效率,因為我們可以使用二分查找法(Binary Search)來快速找到key所在的位置,算法復雜度是O(logn)。LSM樹正是采用key有序這種方式來組織數據存儲的,並稱之為SSTable。
SSTable(Sorted String Table)是LSM樹最基礎的一個存儲結構,存儲在磁盤中,並且數據按照key進行排序的。數據保持key有序的好處是可以在O(logn)的時間下,快速找到一個key值,相比於純粹的append-only log有了很大的提升。但是,如果所有的數據都存儲在一個SSTable上,數據量一大,查詢效率也會下降。因此,LSM樹通常會將數據分散存儲在多個SSTable中,並且記錄每個SSTable的最大key和最小key,這樣就能快速定位到一個key存儲在哪個SSTable上了。
SSTable數據查找示例
// SSTable,數據保存到SSTable后只讀不寫 public class SSTable { ... // 數據存儲路徑 private final LogFile logFile; // 該SStable中存儲的最小Key private String minKey; // 該SStable中存儲的最大Key private String maxKey; // 使用二分查找法獲取key值 public String get(String key) { // step1:先判斷是否在SSTable的范圍內 if (key.compareTo(minKey) < 0 || key.compareTo(maxKey) > 0) { return ""; } // step2:二分查找 long start = 0; long end = logFile.size(); while (start < end) { long mid = start + (end - start) / 2; // 先找到一條record到起始offset long startOffset = logFile.startOffsetOf(mid); String record = logFile.read(startOffset); String midKey = Util.keyOf(record); if (key.compareTo(midKey) == 0) { return Util.valueOf(record); } else if (key.compareTo(midKey) < 0) { end = mid; } else { // 找到一條record到起始offset時可能會有mid == start的情況 if (mid == start) { break; } start = mid; } } return ""; } ... }
這里只是介紹了一種比較簡單的SSTable實現方式,實際上,各種LSM樹存儲引擎對SSTable的實現都有所差異,比如LevelDB就將SSTable划分成兩大塊,數據存儲區存儲key:value數據,數據管理區存儲索引等數據。
那么怎樣才能保證SSTable的有序性呢?
類似的在磁盤中維護數據有序的存儲結構最常見的當屬B/B+樹了,如果對SSTable也采用類似的存儲結構,那么帶來的第一個問題就是每次寫入都會伴隨着磁盤的隨機寫,從而影響了數據的寫入性能,這明顯就違反了LSM樹的初衷。為了同時兼顧SSTable的有序性以及寫入性能,LSM樹采用了MemTable這一組件。
MemTable
相比於磁盤上維護一個有序的數據結構,在內存上實現數據有序要簡單得多,而且具備較高的讀寫性能,常見的有序數據結構有紅黑樹、AVL樹、跳表等,不管你插入的順序如何,讀出來的數據總是有序的。MemTable正是LSM維護在內存中的一個有序的數據結構,接下來我們看下LSM是如何利用Memtable做到同時兼顧SSTable的有序行和寫入性能的:
1、當寫入請求過來時,先將record寫入到Memtable中,這樣就能保證record在內存中有序了,而且具備較高的寫入性能。
2、當Memtable的大小達到一定的閾值后(通常是幾個Mb的大小),將MemTable轉成Immutable MemTable(一個只讀不寫的MemTable),並創建新的MemTable接收寫請求。
和《從Hash索引到LSM樹(一)》中的segment file機制類似,一個時刻只有current segment file接收寫請求,其他的只讀不寫。
3、通過后台任務,定時將Immutable MemTable的數據刷到SSTable中,因為Immutable MemTable本身的有序性,所以就能保證SSTable中的數據是有序的,而且數據寫入SSTable文件時完全是順序寫入,做到了有序性和寫入性能的兼顧。
4、當讀請求過來時,查找的順序是MemTable->Immutable MemTable->SSTable,找到則返回,否則一步步執行下去。
Memtable同時兼顧有序和寫性能
Memtable底層通常采用跳表來實現(LevelDB、HBase都采用了這一實現方法),相比較AVL和紅黑樹,跳表在插入數據的時候可以避免頻繁的樹節點調整操作,所以寫入效率很高,而且實現起來也更簡單些。
// LSM維護在內存中的有序數據結構,數據寫入時先寫MemTable public class MemTable { // 基於跳表實現的key-value結構 private final ConcurrentSkipListMap<String, String> cache; // 當前存儲數據的大小 private AtomicInteger size; ... // 查找key public String get(String key) { if (!cache.containsKey(key)) { return ""; } return cache.get(key); } // 添加key:value,並更新當前Memtable的大小 public void add(String key, String value) { cache.put(key, value); size.addAndGet(key.length() + value.length()); } // 返回當前Memtable的大小(字節為單位) // 用於判斷達到閾值之后,轉成Immutable MemTable public int size() { return this.size.get(); } // 達到閾值之后轉儲到SSTable public void compact2Sst(SSTable sst) { cache.forEach(sst::add); } ... }
LsmKvDb
使用LSM樹作為存儲引擎的數據庫,通常對SSTable進行分層管理,方便查詢以及后續的Compact操作。本文也將采用對SSTable進行分層的架構實現LsmKvDb。
LsmKvDb存儲架構
首先對Level進行抽象,每個Level都由多個SSTable組成:
// 對LSM的層的抽象,由SSTable組成 public class Level { private final List<SSTable> ssts; ... public void add(SSTable sst) { ssts.add(sst); } // 在當前level總查找key對應的value, 從老到新遍歷所有SSTable public String find(String key) { for (int i = ssts.size() - 1; i >= 0; i--) { String value = ssts.get(i).get(key); if (!value.equals("")) { return value; } } return ""; } // sst與當前level進行compact操作 public void compactWith(SSTable sst) {...} // 對給定sst集合與當前level進行compact操作 public void compactWith(List<SSTable> ssts) {...} }
LsmKvDb的實現代碼如下,寫數據時寫入MemTable,當達到閾值后轉Immutable MemTable。Immutable MemTable與MemTable具有相同的數據結構,唯一不同的是前者只讀不寫,后者既讀也寫。
/** * 基於LSM樹的key-value數據庫, 采用分層架構 * MemTable -> Immutable MemTable -> Level0 -> Level1 -> Level2 */ public class LsmKvDb implements KvDb { ... // 存儲SSTable的目錄 private final String sstDir; // 當前寫入的MemTable private MemTable memTable; // MemTable到達閾值大小后轉儲到immutableMts private final List<MemTable> immutableMts; // 后台定時將immutableMts中的MemTable刷到Level0,各SSTable之間可能由Key重疊 private final Level level0; // 后台定時將Level0中的SSTable與Level1中的進行合並 private final Level level1; // 當Level1中的SSTable的數量到達一定閾值后,選擇最老的SSTable與Level2中的進行合並 private final Level level2; ... @Override public String get(String key) { // step1: 從MemTable中讀取 String value = memTable.get(key); if (!value.equals("")) { return value; } // step2: 從Immutable MemTable中讀取,從新到老 for (int i = immutableMts.size() - 1; i >= 0; i--) { value = immutableMts.get(i).get(key); if (!value.equals("")) { return value; } } // step3: 從Level0中讀取 value = level0.find(key); if (!value.equals("")) { return value; } // step4: 從Level1中讀取 ... // step5: 從Level2中讀取 ... return ""; } @Override public void set(String key, String value) { memTable.add(key, value); // 當MemTable大小到達閾值后轉成Immutable MemTable if (memTable.size() > MEMTABLE_MAX_SIZE) { synchronized (this) { immutableMts.add(memTable); memTable = MemTable.create(); } } } ... }
Compaction
在文章《從Hash索引到LSM樹(一)》已經對Compaction機制已經有了講解,其目的是清除掉已經被覆寫或刪除了的紀錄,避免數據存儲文件無休止的增長下去。對於LSM樹而言,該機制同樣適用,隨着數據的不斷添加、更新和刪除,一些SSTable之間必然存在着重疊的key或被刪除的key。通過Compaction,可以將多個SSTable合並成一個,從而節省了磁盤空間。
在上篇文章中,對segment file的compact操作主要依賴於Hash索引。因為是索引覆蓋全部的key,所以可以很容易通過新的segment file的Hash索引來判斷該key是否已經被處理過。但對於SSTable而言,並沒有覆蓋全部key的Hash索引,那么如何進行compact才高效呢?
得益於SSTable的有序性,我們可以應用歸並排序算法來進行compact操作!
LSM樹的Compaction通常有三種類型,分別是minor compact、major compact和full compact。
Minor Compact
minor compact指的是將Immutable MemTable中的數據直接轉存到Level0中的SSTable。
minor compact
因為是直接將各個Immutable MemTable的數據轉儲成SSTable,並沒有做合並的操作,因此在Level0中,各個SSTable之間的key可能存在重疊的現象。
Major Compact
major compact指的是將Level n中的SSTable合並到Level n+1中。
Level0 -> Level1的合並步驟如下:
1、選中Level0中的最老的SSTable sst0,然后在Level0中找到與sst0 的key存在重疊的所有SSTable sst0...n。
2、在Level1中,選取所有與 sst0...n存在key重疊的SSTable sst'0...m。
3、對sst0...n和sst'0...m采用多路歸並排序算法進行合並,得到新的sst‘’0...k,並存儲在Level1中。
4、刪除sst0...n和sst'0...m。
major compact Level0 -> Level1
不同於Level0,Level1和Level2中各個SSTable之間並不存在key重疊的現象,因此Level1 -> Level2的合並會稍微簡單些。
Level1 -> Level2的合並步驟如下:
1、選中Level1中的最老的SSTable sst0。
2、在Level2中,選取所有與 sst0存在key重疊的SSTable sst'0...m。
3、對sst0和sst'0...m采用多路歸並排序算法進行合並,得到新的sst''0...k,並存儲在Level2中。
4、刪除sst0和sst'0...m。
major compact Level1 -> Level2
Full Compact
full compact指的是對Level0、Level1、Level2中所有的SSTable進行compact操作,同樣也是采用多路歸並排序算法。
full compact
通常full compact耗時較多,所以一般都會選擇在流量較少的時刻進行。
優化LSM樹
為SSTable引入block
到目前為止,對於在一個SSTable中查找一個key,我們首先會根據min key和max key判斷該key是否屬於該SSTable所屬的范圍,如果屬於,則對SSTable采用二分查找法進行搜索。二分查找之所以在LsmKvDb中行得通,是因為這是一個簡單的SSTable實現 —— 數據按string存儲和\n分隔。在實際的運用中,為了盡可能地利用磁盤空間,SSTable中數據通常都是以字節碼的形式存儲,也不會以\n分隔每條record,這種情況下采用二分查找的實現就比較復雜了,而且效率也會太高。
一個常見的優化方法是,在SSTable中對record按照一定的size組織成多個block,並以block為單位進行壓縮。為了能夠快速找到某個key所屬的block,需要在內存中維護每個block的起始key對應在SSTable中的offset(一個稀疏的Hash索引)。
按block存儲的SSTable
在查找key的步驟如下:
1、根據索引定位到key所屬的block。
2、將該block加載到內存中,並解壓。
3、對內存中的數據采用二分查找。
在設計block的大小時,應該利用磁盤的空間局部性原理,使得系統能夠只花費一次磁盤I/O就能將整個block加載到內存中。
為SSTable引入Bloom Filter
其實當目標key屬於某個SSTable的key范圍時,該key也不一定會存在於該SSTable中。但是到目前為止,只要目標key在某個SSTable的范圍內,LsmKvDb都會進行查找操作。隨着系統中的SSTable數目的增多,查詢效率必然會隨之下降。
一個常見的優化方法時,為SSTable引入布隆過濾器Bloom Filter。
Bloom Filter是保存在內存中的一種數據結構,可以用來告訴你 “某樣東西一定不存在或者可能存在”。它由一個超長的二進制位數組和一系列的Hash函數組成。二進制位數組初始全部為0,當有元素加入集合時,這個元素會被一系列Hash函數計算映射出一系列的值,所有的值在位數組的偏移量處置為1。如果需要判斷某個元素是否存在於集合當中,只需判斷該元素被Hash后的值在數組中的值,如果存在為0的,則該元素一定不存在;如果全為1,則可能存在,這種情況可能有誤判。
Bloom Filter
通過Bloom Filter,我們可以很快就能判斷目標key是否不存在於SSTable中,從而提升了讀性能。
Google的Guava庫就提供了一個BloomFilter的實現,並且可以按需來設置誤判率。
總結
本文承接上《從Hash索引到LSM樹(一)》,主要介紹了LSM樹的基本原理,並且在原來append-only log的基礎上實現了一個簡單的基於LSM樹的key-value數據庫LsmKvDb。LSM樹主要由MemTable、Immutable MemTable、SSTable組成,其中MemTable和Immutable MemTable在內存中維護,而SSTable則存儲在磁盤中。SSTable的有序性使得LSM樹在無需Hash索引的情況下具有不錯的讀取性能,而且支持區間查詢;而Memable則使得LSM樹具備很高的寫入性能。
本系列文章,我們從一個最簡單的key-value數據庫起步,一步步通過Hash索引、LSM樹、布隆過濾器等技術手段對其進行了優化,從中也深入分析了各個技術點的實現原理。但數據庫的索引技術遠不止這些,比如最常用到的B/B+樹,也是非常值得深入學習的,以后有機會再對其進行深入分析~