前言
這篇從半個月前就開始寫,斷斷續續寫到現在,終于能發了(被簡書吞了好幾次),不容易。
最近筆者正在補習與RocksDB底層相關的細節,因為:
- 次要原因——當前所有Flink實時任務的狀態后端都是RocksDB;
- 主要原因——將來會利用TiDB搭建HTAP服務。TiDB與我們現有的MySQL可以無縫銜接,并且它的基礎正是RocksDB。
RocksDB與筆者多次講過的HBase一樣,都屬于基于LSM樹的存儲引擎,只不過前者偏向嵌入式用途,更輕量級而已。看官可以先食用這篇文章獲得關于LSM樹的前置知識。
下面先粗略看看RocksDB的讀寫流程,其實與HBase是很像的。
RocksDB讀寫簡介
直接畫圖說明。這張圖取自Flink PMC大佬Stefan Richter在Flink Forward 2018演講的PPT,筆者重畫了一下。
RocksDB的寫緩存(即LSM樹的最低一級)名為memtable,對應HBase的MemStore;讀緩存名為block cache,對應HBase的同名組件。
執行寫操作時,先同時寫memtable與預寫日志WAL。memtable寫滿后會自動轉換成不可變的(immutable)memtable,并flush到磁盤,形成L0級sstable文件。sstable即有序字符串表(sorted string table),其內部存儲的數據是按key來排序的,后文將其簡稱為SST。
執行讀操作時,會首先讀取內存中的數據(根據局部性原理,剛寫入的數據很有可能被馬上讀取),即active memtable→immutable memtable→block cache。如果內存無法命中,就會遍歷L0層sstable來查找。如果仍未命中,就通過二分查找法在L1層及以上的sstable來定位對應的key。
隨著sstable的不斷寫入,系統打開的文件就會越來越多,并且對于同一個key積累的數據改變(更新、刪除)操作也就越多。由于sstable是不可變的,為了減少文件數并及時清理無效數據,就要進行compaction操作,將多個key區間有重合的sstable進行合并。本文暫無法給出"compaction"這個詞的翻譯,個人認為把它翻譯成“壓縮”(compression?)或者“合并”(merge?)都是片面的。
通過上面的簡介,我們會更加認識到,LSM樹是一種以讀性能作為trade-off換取寫性能的結構,并且RocksDB中的flush和compaction操作正是LSM思想的核心。下面來介紹LSM-based存儲中通用的兩種compaction策略,即size-tiered compaction和leveled compaction。
通用compaction策略
size-tiered compaction與空間放大
size-tiered compaction的思路非常直接:每層允許的SST文件最大數量都有個相同的閾值,隨著memtable不斷flush成SST,某層的SST數達到閾值時,就把該層所有SST全部合并成一個大的新SST,并放到較高一層去。下圖是閾值為4的示例。
size-tiered compaction的優點是簡單且易于實現,并且SST數目少,定位到文件的速度快。當然,單個SST的大小有可能會很大,較高的層級出現數百GB甚至TB級別的SST文件都是常見的。它的缺點是空間放大比較嚴重,下面詳細說說。
所謂空間放大(space amplification),就是指存儲引擎中的數據實際占用的磁盤空間比數據的真正大小偏多的情況。例如,數據的真正大小是10MB,但實際存儲時耗掉了25MB空間,那么空間放大因子(space amplification factor)就是2.5。
為什么會出現空間放大呢?很顯然,LSM-based存儲引擎中數據的增刪改都不是in-place的,而是需要等待compaction執行到對應的key才算完。也就是說,一個key可能會同時對應多個value(刪除標記算作特殊的value),而只有一個value是真正有效的,其余那些就算做空間放大。另外,在compaction過程中,原始數據在執行完成之前是不能刪除的(防止出現意外無法恢復),所以同一份被compaction的數據最多可能膨脹成原來的兩倍,這也算作空間放大的范疇。
下面用Cassandra的size-tiered compaction策略舉兩個例子,以方便理解。每層SST個數的閾值仍然采用默認值4。
- 以約3MB/s的速度持續插入新數據(保證unique key),時間與磁盤占用的曲線圖如下。
圖中清晰可見有不少毛刺,這就是compaction過程造成的空間放大。注意在2000s~2500s之間還有一個很高的尖峰,原數據量為6GB,但在一瞬間增長到了12GB,說明Cassandra在做大SST之間的compaction,大SST的缺陷就顯現出來了。盡管這只是暫時的,但是也要求我們必須預留出很多不必要的空閑空間,增加成本。
- 重復寫入一個400萬條數據的集合(約1.2GB大,保證unique key),共重復寫入15次來模擬數據更新,時間與磁盤占用的曲線圖如下。
這種情況更厲害,最高會占用多達9.3GB磁盤空間,放大因子為7.75。雖然中途也會觸發compaction,但是最低只能壓縮到3.5GB左右,仍然有近3倍的放大。這是因為重復key過多,就算每層compaction過后消除了本層的空間放大,但key重復的數據仍然存在于較低層中,始終有冗余。只有手動觸發了full compaction(即圖中2500秒過后的最后一小段),才能完全消除空間放大,但我們也知道full compaction是極耗費性能的。
接下來介紹leveled compaction,看看它是否能解決size-tiered compaction的空間放大問題。
leveled compaction與寫放大
leveled compaction的思路是:對于L1層及以上的數據,將size-tiered compaction中原本的大SST拆開,成為多個key互不相交的小SST的序列,這樣的序列叫做“run”。L0層是從memtable flush過來的新SST,該層各個SST的key是可以相交的,并且其數量閾值單獨控制(如4)。從L1層開始,每層都包含恰好一個run,并且run內包含的數據量閾值呈指數增長。
下圖是假設從L1層開始,每個小SST的大小都相同(在實際操作中不會強制要求這點),且數據量閾值按10倍增長的示例。即L1最多可以有10個SST,L2最多可以有100個,以此類推。
隨著SST不斷寫入,L1的數據量會超過閾值。這時就會選擇L1中的至少一個SST,將其數據合并到L2層與其key有交集的那些文件中,并從L1刪除這些數據。仍然以上圖為例,一個L1層SST的key區間大致能夠對應到10個L2層的SST,所以一次compaction會影響到11個文件。該次compaction完成后,L2的數據量又有可能超過閾值,進而觸發L2到L3的compaction,如此往復,就可以完成Ln層到Ln+1層的compaction了。
可見,leveled compaction與size-tiered compaction相比,每次做compaction時不必再選取一層內所有的數據,并且每層中SST的key區間都是不相交的,重復key減少了,所以很大程度上緩解了空間放大的問題。重復一遍上一節做的兩個實驗,曲線圖分別如下。
持續寫入實驗,尖峰消失了。
持續更新實驗,磁盤占用量的峰值大幅降低,從原來的9.3GB縮減到了不到4GB。
但是魚與熊掌不可兼得,空間放大并不是唯一掣肘的因素。仍然以size-tiered compaction的第一個實驗為例,寫入的總數據量約為9GB大,但是查看磁盤的實際寫入量,會發現寫入了50個G的數據。這就叫寫放大(write amplification)問題。
寫放大又是怎么產生的呢?下面的圖能夠說明。
可見,這是由compaction的本質決定的:同一份數據會不斷地隨著compaction過程向更高的層級重復寫入,有多少層就會寫多少次。但是,我們的leveled compaction的寫放大要嚴重得多,同等條件下實際寫入量會達到110GB,是size-tiered compaction的兩倍有余。這是因為Ln層SST在合并到Ln+1層時是一對多的,故重復寫入的次數會更多。在極端情況下,我們甚至可以觀測到數十倍的寫放大。
寫放大會帶來兩個風險:一是更多的磁盤帶寬耗費在了無意義的寫操作上,會影響讀操作的效率;二是對于閃存存儲(SSD),會造成存儲介質的壽命更快消耗,因為閃存顆粒的擦寫次數是有限制的。在實際使用時,必須權衡好空間放大、寫放大、讀放大三者的優先級。
RocksDB的混合compaction策略
由于上述兩種compaction策略都有各自的優缺點,所以RocksDB在L1層及以上采用leveled compaction,而在L0層采用size-tiered compaction。下面分別來看看。
leveled compaction
當L0層的文件數目達到level0_file_num_compaction_trigger
閾值時,就會觸發L0層SST合并到L1。
L1層及以后的compaction過程完全符合前文所述的leveled compaction邏輯,如下圖所示,很容易理解。
多個compaction過程是可以并行進行的,如下圖所示。最大并行數由max_background_compactions
參數來指定。
前面說過,leveled compaction策略中每一層的數據量是有閾值的,那么在RocksDB中這個閾值該如何確定呢?需要分兩種情況來討論。
-
參數
level_compaction_dynamic_level_bytes
為false
這種情況下,L1層的大小閾值直接由參數max_bytes_for_level_base
決定,單位是字節。各層的大小閾值會滿足如下的遞推關系:
target_size(Lk+1) = target_size(Lk) * max_bytes_for_level_multiplier * max_bytes_for_level_multiplier_additional[k]
其中,max_bytes_for_level_multiplier
是固定的倍數因子,max_bytes_for_level_multiplier_additional[k]
是第k層對應的可變倍數因子。舉個例子,假設max_bytes_for_level_base = 314572800,max_bytes_for_level_multiplier = 10,所有max_bytes_for_level_multiplier_additional[k]都為1,那么就會形成如下圖所示的各層閾值。
可見,這與上文講leveled compaction時的示例是一個意思。
-
參數
level_compaction_dynamic_level_bytes
為true
這種情況比較特殊。最高一層的大小不設閾值限制,亦即target_size(Ln)就是Ln層的實際大小,而更低層的大小閾值會滿足如下的倒推關系:
target_size(Lk-1) = target_size(Lk) / max_bytes_for_level_multiplier
可見,max_bytes_for_level_multiplier
的作用從乘法因子變成了除法因子。特別地,如果出現了target_size(Lk) < max_bytes_for_level_base / max_bytes_for_level_multiplier的情況,那么這一層及比它低的層就都不會再存儲任何數據。
舉個例子,假設現在有7層(包括L0),L6層已經存儲了276GB的數據,并且max_bytes_for_level_base = 1073741824,max_bytes_for_level_multiplier = 10,那么就會形成如下圖所示的各層閾值,亦即L5~L1的閾值分別是27.6GB、2.76GB、0.276GB、0、0。
可見,有90%的數據都落在了最高一層,9%的數據落在了次高一層。由于每個run包含的key都是不重復的,所以這種情況比上一種更能減少空間放大。
universal compaction
universal compaction是RocksDB中size-tiered compaction的別名,專門用于L0層的compaction,因為L0層的SST的key區間是幾乎肯定有重合的。
前文已經說過,當L0層的文件數目達到level0_file_num_compaction_trigger
閾值時,就會觸發L0層SST合并到L1。universal compaction還會檢查以下條件。
空間放大比例
假設L0層現有的SST文件為(R1, R1, R2, ..., Rn),其中R1是最新寫入的SST,Rn是較舊的SST。所謂空間放大比例,就是指R1~Rn-1文件的總大小除以Rn的大小,如果這個比值比max_size_amplification_percent / 100
要大,那么就會將L0層所有SST做compaction。相鄰文件大小比例
有一個參數size_ratio
用于控制相鄰文件大小比例的閾值。如果size(R2) / size(R1)的比值小于1 + size_ratio / 100,就表示R1和R2兩個SST可以做compaction。接下來繼續檢查size(R3) / size(R1 + R2)是否小于1 + size_ratio / 100,若仍滿足,就將R3也加入待compaction的SST里來。如此往復,直到不再滿足上述比例條件為止。
當然,如果上述兩個條件都沒能觸發compaction,該策略就會線性地從R1開始合并,直到L0層的文件數目小于level0_file_num_compaction_trigger
閾值。
The End
還是寫的很亂,但就這樣吧。
困了。明天加班,民那晚安。