本文是閱讀了DDIA的第三章後整理的讀書筆記,總結了什麼是LSM儲存引擎,以及在實現上的一些細節優化。
1 什麼是LSM?
LSM一詞最早來自於Partrick O'Neil et al.發表的文章[1],全稱為"Log-Structured Merged-Tree"。後來,Google發表的Bigtable論文[2]將其發揚光大。
目前所有基於該思想實現的儲存引擎,我們都可以稱之為“LSM儲存引擎”,例如:
- LevelDB。Google開源的單機版Bigtable實現,使用C++編寫。
- RocksDB。來自Facebook,基於LevelDB改良而來,使用C++編寫。
- HBase。最接近的BigTable開源實現,使用Java編寫,是Hadoop的重要組成部分。
- Cassandra。來自Facebook的分散式資料庫系統,同樣借鑑了BigTable設計思想,使用Java編寫。
- 其它等等。
不同於傳統的基於B+樹的資料庫儲存引擎,基於LSM的引擎尤其適合於寫多讀少的場景。
我們先從一個最簡單的儲存引擎示例出發,然後再描述LSM引擎的基本原理。
2 最基本的儲存引擎
一個最基本的儲存引擎,需要支援下面兩個操作:
- Put(Key, Value)
- Get(Key)
加快寫操作
我們知道,磁碟特別是機械硬碟,其隨機寫的速度是非常慘不忍睹的。但是,如果我們是順序寫磁碟的話,那速度跟寫記憶體是相當的:因為減少了尋道時間和旋轉時間。而且順序寫的情況下,還能將資料先放到buffer,等待數量達到磁碟的一頁時,再落盤,能進一步減少磁碟IO次數。
所以,這裡我們規定,每一次寫資料都追加到資料檔案的末尾。
#!/bin/sh
# usage: ./Put key value
echo "$1:$2" >> simpledb.data
複製程式碼
注:如果對同一個key寫多次,最終以最後一次的值為準(即對於讀請求,應返回最後一次寫入的值)。
加快讀操作
要從資料檔案裡面查詢:
# usage: ./Get key
grep "^$1:" simpledb.data | sed -e 's/^://g' | tail -n 1
複製程式碼
直接從資料檔案查詢的效率是很低的:我們需要遍歷整個資料檔案。
這時候就需要“index”來加快讀操作了。我們可以在記憶體中儲存一個key到檔案偏移量的對映關係(雜湊表索引):在查詢時直接根據雜湊表得到偏移量,再去讀檔案即可。
當然,加了索引表也相應地增加了寫操作的複雜度:寫資料時,在追加寫資料檔案的同時,也要更新索引表。
這樣的建索引的方式有個缺點:因為索引map必須常駐記憶體,所以它沒法處理資料量很大的情況。當記憶體無法載入完整索引資料時,就無法工作了。
防止資料檔案無止盡的增長
我們再來看看另外一個問題:傳統的B+樹,每個key只會存一份值,佔用的磁碟空間是跟資料量嚴格對應的。但是在追加寫的方案中,磁碟空間是永無止盡的,只要這個系統線上上執行,產生寫請求,檔案體積就會增加。
解決檔案無限增長的方法就是 compaction:
- 將資料檔案分段(segment)。每個segment檔案最大不超過多少位元組,當segment滿時建立一個新的segment。當前寫入的segment稱為活躍segment。同一時刻只有一個活躍segment。
- 後臺定期將舊的segment檔案合併壓縮:每個key只保留最新的值。
以上就是一個簡單的基於記憶體索引+檔案分段並定期壓縮的儲存引擎。可以看到,它能夠提供很好的寫入效能,但是無法應對資料量過大的場景。
3 LSM儲存引擎
首先看看怎麼解決記憶體索引過大的問題。
假定現在我們要儲存N
對key-value
,那麼我們同樣需要在索引裡面儲存N
對key-offset
。但是,如果資料檔案本身是按序存放的,我們就沒必要對每個key建索引了。我們可以將key劃分成若干個block
,只索引每個block
的start_key
。對於其它key,根據大小關係找到它存在的block
,然後在block
內部做順序搜尋即可。
在LSM裡面,我們把按序組織的資料檔案稱為SSTable
(Sorted String Table)。只儲存block
起始key的offset的索引,我們稱為“稀疏索引”(Sparse Index)。
而且,有了block
的概念之後,我們可以以block
為單位將資料進行壓縮,以達到減少磁碟IO吞吐量。
那怎麼生成和維護SSTable
呢?
我們可以在記憶體裡面維護一個平衡二叉樹(例如AVL樹或者紅黑樹)。每當有Put(Key, Value)
請求時,先將資料寫入二叉樹,保證其順序性。當二叉樹達到既定規模時,我們將其按序寫入到磁碟,轉換成SSTable
儲存下來。
在LSM裡面,我們把記憶體裡的二叉樹稱為memtable
。
注意,這裡的
memtable
雖然也是存在記憶體中的,但是它跟上面說的稀釋索引不一樣。對每一個SSTable
,我們都會為它維護一個稀疏的記憶體索引;但是memtable
只是用來生成新的SSTable
。
再來看看如何compaction
對SSTable
,我們同樣是通過 segment + compaction 來解決磁碟佔用的問題。
分segment
在memtable
轉SSTable
的時候就已經做了。
compaction
則依賴後臺執行緒定期執行了。但是對於有序的的SSTable
,我們可以使用歸併排序的思路來合併和壓縮檔案:
故障恢復
如果在將memtable
轉存SSTable
時,程式掛掉了,怎麼保證未寫入SSTable
的資料不丟失呢?
參考資料庫的redo log
,我們也可以搞一個log
記錄當前memtable
的寫操作。在有Put
請求過來時,除了寫入memtable,還將操作追加到log。當memtable
成功轉成SSTable
之後,它對應的log
檔案就可以刪除了。在下次啟動時,如果發現有殘留的log
檔案,先通過它恢復上次的memtable
。
Bloom Filter
對於查詢那些不存在的key,我們需要搜尋完memtable
和所有的SSTable
,才能確定地說它不存在。
在資料量不大的情況下,這不是個問題。但是當資料量達到一定的量級後,這會對系統效能造成非常嚴重的問題。
我們可以藉助Bloom Filter(布隆過濾器)來快速判斷一個key是否存在。
布隆過濾器的特點是,它可能會把一個不存在的key判定為存在;但是它絕不會把一個存在的key判定為不存在。這是可以接受的,因為對於極少數誤判為存在的key,只是多幾次搜尋而已,只要不會將存在的key誤判為不存在就行。而且它帶來的好處是顯而易見的:可以節省大量的對不存在的key的搜尋時間。
合併策略
上文已經提到,我們需要對SSTables
做合併:將多個SSTable
檔案合併成一個SSTable
檔案,並對同一個key,只保留最新的值。
那這裡討論的合併策略(Compaction Strategy)又是什麼呢?
A compaction strategy is what determines which of the sstables will be compacted, and when.
也就是說,合併策略是指:1)選擇什麼時候做合併;2)哪些SSTable
會合併成一個SSTable
。
目前廣泛應用的策略有兩種:size-tiered
策略和leveled
策略。
- HBase採用的是
size-tiered
策略。 - LevelDB和RocksDB採用的是
leveled
策略。 - Cassandra兩種策略都支援。
這裡簡要介紹下兩種策略的基本原理。後面研究LevelDB
原始碼時再詳細描述leveled
策略。
size-tiered
策略
簡稱STCS(Size-Tiered Compaction Strategy)。其基本原理是,每當某個尺寸的SSTable
數量達到既定個數時,合併成一個大的SSTable
,如下圖所示:
它的優點是比較直觀,實現簡單,但是缺點是合併時的空間放大效應(Space Amplification)比較嚴重,具體請參考Scylla’s Compaction Strategies Series: Space Amplification in Size-Tiered Compaction。
空間放大效應,比如說資料本身只佔用2GB,但是在合併時需要有額外的8G空間才能完成合並,那空間放大就是4倍。
leveled
策略
STCS
策略之所以有嚴重的空間放大問題,主要是因為它需要將所有SSTable檔案合併成一個檔案,只有在合併完成後才能刪除小的SSTable檔案。那如果我們可以每次只處理小部分SSTable
檔案,就可以大大改善空間放大問題了。
leveled
策略,簡稱LCS(Leveled Compaction Strategy),核心思想就是將資料分成互不重疊的一系列固定大小(例如 2 MB)的SSTable
檔案,再將其分層(level)管理。對每個Level
,我們都有一份清單檔案記錄著當前Level
內每個SSTable
檔案儲存的key的範圍。
Level和Level的區別在於它所儲存的SSTable
檔案的最大數量:Level-L
最多隻能儲存 10 L 個SSTable
檔案(但是Level 0
是個例外,後面再說)。
注:上圖中,"run of"就表示一個系列,這些檔案互不重疊,共同組成該
level
的所有資料。Level 1
有10個檔案;Level 2
有100個檔案;依此類推。
下面對照著上圖再詳細描述下LCS
壓縮策略:
先來看一下當Level >= 1
時的合併策略。以Level 1
為例,當Level 1
的SSTable
數量超過10個時,我們將多餘的SSTable
轉存到Level-2
。為了不破壞Level-2
本身的互不重疊性,我們需要將Level-2
內與這些待轉存的SSTable
有重疊的SSTable
挑出來,然後將這些SSTable
檔案重新合併去重,形成新的一組SSTable
檔案。如果這組新的SSTable
檔案導致Level-2
的總檔案數量超過100個,再將多餘的檔案按照同樣的規則轉存到Level-3
。
再來看看Level 0
。Level 0
的SSTable
檔案是直接從memtable
轉化來的:你沒法保證這些SSTable
互不重疊。所以,我們規定Level 0
數量不能超過4個:當達到4個時,我們將這4個檔案一起處理:合併去重,形成一組互不重疊的SSTable
檔案,再將其按照上一段描述的策略轉存到Level 1
。
4 引用
[1] Patrick O’Neil, Edward Cheng, Dieter Gawlick, and Elizabeth O’Neil: “The Log- Structured Merge-Tree (LSM-Tree),” Acta Informatica, volume 33, number 4, pages 351–385, June 1996. doi:10.1007/s002360050048
[2] Fay Chang, Jeffrey Dean, Sanjay Ghemawat, et al.: “Bigtable: A Distributed Storage System for Structured Data,” at 7th USENIX Symposium on Operating System Design and Implementation (OSDI), November 2006.