Dostoevsky: 一種更好的平衡 LSM 空間和效能的方式

weixin_33686714發表於2018-10-13

最近看了一篇 Paper,Dostoevsky: Better Space-Time Trade-Offs for LSM-Tree Based Key-Value Stores via Adaptive Removal of Superfluous Merging,讓我覺得受益匪淺。裡面作者詳細的用公式列出來不同的 Compaction 策略對不同的操作的 I/O 影響,以及空間佔用,從而指導作者做了相關的優化,構建了 Dostoevsky。

因為論文寫得非常詳細,我覺得也有必要好好的整理一下,順帶讓自己重新學習下 RocksDB 相關的程式碼。不過在開始之前,還是先吐槽下這個 KV 的名字,竟然叫做 Dostoevsky,也就是大名鼎鼎的陀思妥耶夫斯基,也不覺得念出來多繞口。

Tiered Compaction vs Leveled Compaction

大家應該都知道,對於 LSM 來說,它會將寫入先放到一個 memtable 裡面,然後在後臺 flush 到磁碟,形成一個 SST 檔案,這個對寫入其實是比較友好的,但讀取的時候,很可能會遍歷所有的 SST 檔案,這個開銷就很大了。同時,LSM 是多版本機制,一個 key 可能會被頻繁的更新,那麼它就會有多個版本留在 LSM 裡面,佔用空間。

為了解決這兩個問題,LSM 會在後臺進行 compaction,也就是將 SST 檔案重新整理,提升讀取的效能,釋放掉無用版本的空間,通常,LSM 有兩種 Compaction 方式,一個就是 Tiered,而另一個則是 Leveled。

2224-376c64e499629fd8.png

上圖是兩種 compaction 的區別,當 Level 0 刷到 Level 1,讓 Level 1 的 SST 檔案達到設定的閾值,就需要進行 compaction。對於 Tiered 來說,我們會將所有的 Level 1 的檔案 merge 成一個 Level 2 SST 放在 Level 2。也就是說,對於 Tiered 來說,compaction 其實就是將上層的所有小的 SST merge 成下層一個更大的 SST 的過程。

而對於 Leveled 來說,不同 Level 裡面的 SST 大小都是一致的,Level 1 裡面的 SST 會跟 Level 2 一起進行 merge 操作,最終在 Level 2 形成一個有序的 SST,而各個 SST 不會重疊。

上面僅僅是一個簡單的介紹,大家可以參考 ScyllaDB 的兩篇文章 Write Amplification in Leveled CompactionSpace Amplification in Size-Tiered Compaction,裡面詳細的說明了這兩種 compaction 的區別。

Compaction Analyzing

無論是 Tiered 還是 Leveled,它們都各有優劣,我們也需要根據實際情況進行選擇,直觀來說,Leveled compaction 會有寫放大問題,而 Tiered compaction 則會有空間放大問題。在 Dostoevsky 裡面,作者定量分析了不同的 compaction 在不同 case 情況下面的 I/O 開銷,以及兩種 compaction 的空間佔用情況。

術語 定義 單位
N 總的 entries 個數 entries
L 總的 Level 層數 levels
Lmax 最大的 level 層數 levels
B 在一個儲存 block 裡面的 entries 個數 entries
P 一個 block 的 buffer size blocks
T 相鄰兩個 level 之間的 size 比
Tlim Level L 相對於 Level 1 的 size 比
M 最大的給 Bloom filters 分配的記憶體 bits
Pi 在 Level i Bloom filters 的失敗率 %
s 對於 range 查詢的可選擇率 %
R 沒有結果的點查開銷 I/Os
V 有結果的點查開銷 I/Os
Q range 查詢的開銷 I/Os
W 更新開銷 I/Os
K 從 Level 1 到 Level L - 1 的 runs 個數範圍 runs
Z Level L 的 runs 個數範圍 runs
μ 順序讀相比隨機讀速度差異
φ 寫入相比讀取的開銷差異

這裡說下 runs 的定義,根據 Wiki Log-structured merge-tree,可以知道一個 runs 就是一個或者多個有序不重疊的 SST 檔案。從 Level 1 層開始,Leveled 的 runs 就是 1,而 Tiered 則可能是 T - 1。

對於 Level 0 來說,一個 buffer 包含 B * P 個 entries,通常來說,Level i 就有 B * P * T ^ i 個 entries。而大的 Level 擇優 N * (T - 1) / T 個 entries。L 則是 ㏒T(N / (B * P) * (T - 1) / T) )

對於兩種不同的 compaction,下圖列出了不同情況下面的 I/O 開銷

2224-11821636fd422242.png

Updates

對於 Update 來說,一個 entry 的開銷其實是依賴於後臺的 compaction merge 操作。對於最壞的情況,也就是 entry 在最大的 Level 上面有更新,那麼只有這個 entry 新的版本到達了最大 Level,老的版本才會被刪除。

對於 Tiered 來說,每次 merge 可以認為是 O(1) 的開銷,那麼總共就是 O(L) 的開銷,因為每次 merge 我們不可能只移動一個 entry,而是會批量的處理 B 個 entries,所以總的開銷可以認為是 O(L / B)

而對於 Leveled 來說,因為 Level i 的一個 run 可能會跟上一層的 i + 1 T 個 runs 一起merge。所以,一個 entry 的開銷可以認為是 O(T),總共的開銷就是 O(L * T / B)

Point Lookups

對於點查來說,最壞的情況就是資料不存在,我們會在每層都進行檢查,而每層的 Bloom filter 都返回 false,這樣對於 Leveled 來說,開銷就是 O(L),而 Tiered 則是 O(L * T)

通常,我們都給每個 entry 使用 10 bits 來作為 Bloom filter,這樣 false positive rate (FPR)則接近 1%,實際中,每層的 Bloom filter 的 bits 都是一樣的,所以最大層的 Bloom filter 會佔用最多的記憶體,而最大層的 FPR Pi 則是 O(e ^ (-M / N)),因為其他所有的層的 FPRs 都有同樣的 Pi,所以對於 Leveled 來說,它的開銷是 O(e ^ (-M / N) * L),而 Tiered 則是 O(e ^ (-M / N)) * L * T

不過,現在已經有一些 Paper 指出,不一定每層的 Bloom filter bits 需要一致,譬如 Monkey 這篇 paper 就提到給不同的層設定不同的 Bloom filter bits,能有效的減少 I/O 開銷,能將 Leveled 的減少到 O(e ^ (-M / N)), Tiered 減少到 O(e ^ (-M / N)) * T,這個後續會分析 Monkey。

而對於 RocksDB 來說,它提供了一個引數可以在最大層不使用 Bloom filter,對於一些業務來說,如果我們能確定要查詢的 key 一定在 LSM 裡面,那麼最大層不使用 Bloom filter 可以有效的節省記憶體,這樣我們也可以增大 bits,或者讓 block cache 裡面存放更多的 entries。

Range lookups

在 Dostoevsky 裡面,long range lookups 的定義就是如果訪問的 blocks 數量滿足 s / B > 2 * Lmax,那麼就認為是 long。

通常來說,range lookups 是不能用 Bloom filter 的,所以對於 Leveled 來說,short 總共需要 O(L) 的開銷,而 Tiered 則是 O(L * T)。而對於 long 來說,Leveled 則是 O(s / B),而 Tiered 則是 O(T * s / B)

Space Amplification

對於空間放大,我們定義如下,amp = N / unq - 1unq 就是 unique entries 的數量。

2224-c961e3c8c8999657.png

對於 Leveled 來說,最壞情況就是從 Level 1 到 L - 1,一個 entry 都有更新,那麼在 L 層,就可能會有 1 / T 個廢棄的 entry,所以總的空間放大就是 O(1 / T),而對於 Tiered 來說,最壞情況就是從 Level 1 到 L - 1 的所有更新都 merge 到了 Level L,也就是 L 層包含所有的 entries 資料,這時候的放大就是 O(T)

小結

上面簡單的列出來兩種不同的 Compaction 在不同的 case 下面的 I/O 開銷,以及空間放大問題。簡單來說,Leveled 會有寫放大問題,而 Tiered 則會有讀放大以及空間放大問題。對於 Dostoevsky 來說,它並沒有單純的採用 Leveled 或者 Tiered,而是採用了一種更加巧妙的方式。

Lazy Leveling

首先就是 Lazy Leveling,原理非常簡單,也就是混合了 Tiered 以及 Leveled,在最大層使用 Leveled,而其它層使用 Tiered。這樣最大層的 runs 就是 1,而其他層的則是 T - 1。另外,因為小的 Level 現在使用的是 Tiered,為了加速點查,Dostoevsky 為不同的 Level 的 Bloom filter 使用了不同的記憶體。

2224-8cdc2880738b0182.png

上圖列出了使用 Lazy Leveling 跟其他兩種 compaction 方式的對比,可以看到,在不同 case 下面的最壞開銷其實還是挺不錯的。譬如對於 update 來說,在 Level 1 到 L - 1 層,開銷都是 O(T),而 L 層則是 O(L),那麼總的開銷就是 O((T + L) / B)。而對於空間放大來說,因為最大層有最多的 entries,所以整體的開銷仍然接近於 O(1 / T)

但是,雖然 Lazy Leveling 能在很多方面有折中,但在特定場景下面仍然趕不上 Tiered 或者 Leveled compaction,所以這世界並沒有銀彈,實際並不是只有一個單一的 compaction 策略。

Fluid LSM-Tree

在 Lazy Leveling 基礎上面,Dostoevsky 引入了 Fluid LSM-Tree,其實原理也很簡單,相比於 Lazy Leveling 最大層是 Leveled,其它層是 Tiered,Fluid 使用了一個可調解的方式,在最大層使用最多 Z runs,而其它層最多使用 K runs。

可以發現:

  1. Z = 1, K = 1,就是 Leveled Compaction
  2. Z = T - 1, K = T - 1,就是 Tiered Compaction
  3. Z = 1, K = T - 1,就是 Lazy Leveling
2224-2be346c87b65f4d4.png

上圖是使用 Fluid 模型之後不同 case 的開銷,公式太複雜就不解釋了。既然有了 Fluid,下一個問題就顯而易見了,我們如何去確定 Z 和 K,這就需要 tuning 了。這方面 Dostoevsky 貌似也沒有啥黑科技,就是不斷調整 Z,K 和 T,在不同的應用場景去測試,從而找到一個比較優的配置。

結語

LSM 雖然是現今主流的一種儲存引擎實現方式,但它仍然有一些不足,而業界也一直在對它進行優化,用以適配更多的場景。在 TiKV,我們也在基於 RocksDB 做另一款儲存引擎,如果你對這方面感興趣,歡迎聯絡我 tl@pingcap.com

相關文章