LSM Tree與go-leveldb

LSM Tree,即日志結構合并樹(Log-StructuredMerge-Tree)。LSM tree 之所以有效是基于以下事實:磁盤或內存的連續讀寫性能遠高于隨機讀寫性能,有時候這種差距可以達到三個數量級之高。這種現象不僅對傳統的機械硬盤成立,對 SSD 硬盤也同樣成立。如下圖:


連續訪問vs隨機訪問

LSM tree 是許多 key-value 型或日志型數據庫所依賴的核心數據結構,例如 BigTableHBaseCassandraLevelDBSQLiteScyllaRocksDB 等。

LSM tree 在工作過程中盡可能避免隨機讀寫,充分發揮了磁盤連續讀寫的性能優勢。

1、LSM樹的核心思想

組成部分

如上圖所示,LSM樹有以下三個重要組成部分:

1) MemTable

MemTable是在內存中的數據結構,用于保存最近更新的數據,會按照Key有序地組織這些數據,LSM樹對于具體如何組織有序地組織數據并沒有明確的數據結構定義,例如Hbase使跳躍表來保證內存中key的有序。

因為數據暫時保存在內存中,內存并不是可靠存儲,如果斷電會丟失數據,因此通常會通過WAL(Write-ahead logging,預寫式日志)的方式來保證數據的可靠性。

2) Immutable MemTable

當 MemTable達到一定大小后,會轉化成Immutable MemTable。Immutable MemTable是將轉MemTable變為SSTable的一種中間狀態。寫操作由新的MemTable處理,在轉存過程中不阻塞數據更新操作。

3) SSTable(Sorted String Table)

有序鍵值對集合,是LSM樹組在磁盤中的數據結構。為了加快SSTable的讀取,可以通過建立key的索引以及布隆過濾器來加快key的查找。

sstable索引
lsm工作流程

2、寫入數據

LSM tree 的所有寫操作均為連續寫,因此效率非常高。但由于外部數據是無序到來的,如果無腦連續寫入到 segment,顯然是不能保證順序的。對此,LSM tree 會在內存中構造一個有序數據結構(就是 memtable)。每條新到達的數據都插入到該紅黑樹中,從而始終保持數據有序。當寫入的數據量達到一定閾值時,將觸發紅黑樹的 flush 操作,把所有排好序的數據一次性寫入到硬盤中(該過程為連續寫),生成一個新的 segment。而之后紅黑樹便從零開始下一輪積攢數據的過程。


寫入數據

3、刪除數據

如果是在內存中,刪除某塊數據通常是將它的引用指向 NULL,那么這塊內存就會被回收。但現在的情況是,數據已經存儲在硬盤中,要從一個 segment 文件中間抹除一段數據必須要覆寫其之后的所有內容,這個成本非常高。LSM tree 所采用的做法是設計一個特殊的標志位,稱為 tombstone(墓碑),刪除一條數據就是把它的 value 置為墓碑,如下圖所示:


刪除數據

這個例子展示了刪除 segment 2 中的 dog 之后的效果。注意,此時 segment 1 中仍然保留著 dog 的舊數據,如果我們查詢 dog,那么應該返回空,而不是 52。因此,刪除操作的本質是覆蓋寫,而不是清除一條數據,這一點初看起來不太符合常識。墓碑會在 compact 操作中被清理掉,于是置為墓碑的數據在新的 segment 中將不復存在。

4、讀取/查詢數據

如何從 SSTable 中查詢一條特定的數據呢?一個最簡單直接的辦法是掃描所有的 segment,直到找到所查詢的 key 為止。通常應該從最新的 segment 掃描,依次到最老的 segment,這是因為越是最近的數據越可能被用戶查詢,把最近的數據優先掃描能夠提高平均查詢速度。

當掃描某個特定的 segment 時,由于該 segment 內部的數據是有序的,因此可以使用二分查找的方式,在
的時間內得到查詢結果。但對于二分查找來說,要么一次性把數據全部讀入內存,要么在每次二分時都消耗一次磁盤 IO,當 segment 非常大時(這種情況在大數據場景下司空見慣),這兩種情況的代價都非常高。一個簡單的優化策略是,在內存中維護一個稀疏索引(sparse index),其結構如下圖:

image.png

稀疏索引是指將有序數據切分成(固定大小的)塊,僅對各個塊開頭的一條數據做索引。與之相對的是全量索引(dense index),即對全部數據編制索引,其中的任意一條數據發生增刪均需要更新索引。兩者相比,全量索引的查詢效率更高,達到了理論極限值
,但寫入和刪除效率更低,因為每次數據增刪時均需要因為更新索引而消耗一次 IO 操作。通常的關系型數據庫,例如 MySQL 等,其內部采用 B tree 作為索引結構,這便是一種全量索引。

有了稀疏索引之后,可以先在索引表中使用二分查找快速定位某個 key 位于哪一小塊數據中,然后僅從磁盤中讀取這一塊數據即可獲得最終查詢結果,此時加載的數據量僅僅是整個 segment 的一小部分,因此 IO 代價較小。以上圖為例,假設我們要查詢 dollar 所對應的 value。首先在稀疏索引表中進行二分查找,定位到 dollar 應該位于 dog 和 downgrade 之間,對應的 offset 為 17208~19504。之后去磁盤中讀取該范圍內的全部數據,然后再次進行二分查找即可找到結果,或確定結果不存在。

稀疏索引極大地提高了查詢性能,然而有一種極端情況卻會造成查詢性能驟降:當要查詢的結果在 SSTable 中不存在時,我們將不得不依次掃描完所有的 segment,這是最差的一種情況。有一種稱為布隆過濾器(bloom filter)的數據結構天然適合解決該問題。布隆過濾器是一種空間效率極高的算法,能夠快速地檢測一條數據是否在數據集中存在。我們只需要在寫入每條數據之前先在布隆過濾器中登記一下,在查詢時即可斷定某條數據是否缺失。

布隆過濾器的內部依賴于哈希算法,當檢測某一條數據是否見過時,有一定概率出現假陽性(False Positive),但一定不會出現假陰性(False Negative)。也就是說,當布隆過濾器認為一條數據出現過,那么該條數據很可能出現過;但如果布隆過濾器認為一條數據沒出現過,那么該條數據一定沒出現過。這種特性剛好與此處的需求相契合,即檢驗某條數據是否缺失。

這里需要關注一個重點,LSM樹(Log-Structured-Merge-Tree)正如它的名字一樣,LSM樹會將所有的數據插入、修改、刪除等操作記錄(注意是操作記錄)保存在內存之中,當此類操作達到一定的數據量后,再批量地順序寫入到磁盤當中。這與B+樹不同,B+樹數據的更新會直接在原數據所在處修改對應的值,但是LSM數的數據更新是日志式的,當一條數據更新是直接append一條更新記錄完成的。這樣設計的目的就是為了順序寫,不斷地將Immutable MemTable flush到持久化存儲即可,而不用去修改之前的SSTable中的key,保證了順序寫。

因此當MemTable達到一定大小flush到持久化存儲變成SSTable后,在不同的SSTable中,可能存在相同Key的記錄,當然最新的那條記錄才是準確的。這樣設計的雖然大大提高了寫性能,但同時也會帶來一些問題:

1)冗余存儲,對于某個key,實際上除了最新的那條記錄外,其他的記錄都是冗余無用的,但是仍然占用了存儲空間。因此需要進行Compact操作(合并多個SSTable)來清除冗余的記錄。
2)讀取時需要從最新的倒著查詢,直到找到某個key的記錄。最壞情況需要查詢完所有的SSTable,這里可以通過前面提到的索引/布隆過濾器來優化查找速度。

5、文件合并(Compaction)

隨著數據的不斷積累,SSTable 將會產生越來越多的 segment,導致查詢時掃描文件的 IO 次數增多,效率降低,因此需要有一種機制來控制 segment 的數量。對此,LSM tree 會定期執行文件合并(compaction)操作,將多個 segment 合并成一個較大的 segment,隨后將舊的 segment 清理掉。由于每個 segment 內部的數據都是有序的,合并過程類似于歸并排序,效率很高,只需要
的時間復雜度。


compaction

在上圖的示例中,segment 1 和 2 中都存在 key 為 dog 的數據,這時應該以最新的 segment 為準,因此合并后的值取 84 而不是 52,這實現了類似于字典/HashMap 中“覆蓋寫”的語義。

go-leveldb

go-leveldb是一個對leveldb的golang實現。

db, err := leveldb.OpenFile("path/to/db", nil)

// get
data, err := db.Get([]byte("key"), nil)
// 新增/修改
err = db.Put([]byte("key"), []byte("value"), nil)

// 刪除
err = db.Delete([]byte("key"), nil)


// 批量操作
batch := new(leveldb.Batch)
batch.Put([]byte("foo"), []byte("value"))
batch.Put([]byte("bar"), []byte("another value"))
batch.Delete([]byte("baz"))
err = db.Write(batch, nil)

// 使用布隆過濾器
o := &opt.Options{
    Filter: filter.NewBloomFilter(10),
}
db, err := leveldb.OpenFile("path/to/db", o)

defer db.Close()

參考:https://zhuanlan.zhihu.com/p/181498475https://www.qtmuniao.com/2022/04/16/ddia-reading-chapter3-part1/

https://dev.to/justinethier/log-structured-merge-trees-1jha

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容