自動調優 RocksDB
最近看到一篇 Paper, LTEXT.pdf?sequence=1" target="_blank" rel="nofollow,noindex">Auto-tuning RocksDB ,頓時兩眼放光。RocksDB 以配置多,難優化而著稱,據傳 RocksDB 配置多到連 RocksDB 自己的開發者都沒法提供出一個好的配置,所以很多時候,我們都只能大概給一個比較優的配置,在根據使用者實際的 workload 調整。所以這時候真的希望能有一個自動 tuning 的方案。
對於資料庫來說,auto tuning 是當前一個非常熱門的研究領域,譬如 CMU 知名的 Peloton 專案,但這些專案通常都會關注特別多的配置,使用 TensorFlow 等技術進行機器學習,靠人工智慧來調優。這個當然也能用到 RocksDB 上面,不過對作者來說,這些都太複雜了(其實對我們也一樣,雖然人工智慧誘惑很大,但坑很多)。所以,作者主要關注的是如何更好的提升寫入效能。而基本原理也很簡單,在寫入負載高的時候關掉 compaction,而在寫入負載低的時候開啟 compaction。那麼自然要考慮的就是,如何去實現一個 compaction auto-tuner 了。
RocksDB 介紹
因為 RocksDB 在之前的文章中已經介紹了太多了,這裡就稍微簡單介紹一下。RocksDB 是基於 LSM-Tree 的,大概如下

雖然大部分讀者對於 LSM 已經非常熟悉了, 但這裡還是簡單的介紹一下。首先,任何的寫入都會先寫到 WAL,然後在寫入 Memory Table(Memtable)。當然為了效能,也可以不寫入 WAL,但這樣就可能面臨崩潰丟失資料的風險。Memory Table 通常是一個能支援併發寫入的 skiplist,但 RocksDB 同樣也支援多種不同的 skiplist,使用者可以根據實際的業務場景進行選擇。
當一個 Memtable 寫滿了之後,就會變成 immutable 的 Memtable,RocksDB 在後臺會通過一個 flush 執行緒將這個 Memtable flush 到磁碟,生成一個 Sorted String Table(SST) 檔案,放在 Level 0 層。當 Level 0 層的 SST 檔案個數超過閾值之後,就會通過 Compaction 策略將其放到 Level 1 層,以此類推。
這裡關鍵就是 Compaction,如果沒有 Compaction,那麼寫入是非常快的,但會造成讀效能降低,同樣也會造成很嚴重的空間放大問題。為了平衡寫入,讀取,空間這些問題,RocksDB 會在後臺執行 Compaction,將不同 Level 的 SST 進行合併。但 Compaction 並不是沒有開銷的,它也會佔用 I/O,所以勢必會影響外面的寫入和讀取操作。
對於 RocksDB 來說,他有三種 Compaction 策略,一種就是預設的 Leveled Compaction,另一種就是 Universal Compaction,也就是常說的 Size-Tired Compaction,還有一種就是 FIFO Compaction。在之前介紹Dostoevsky 的文章裡面,已經詳細的介紹了 Leveled 和 Tired,這裡就不在重新說明了。對於 FIFO 來說,它的策略非常的簡單,所有的 SST 都在 Level 0,如果超過了閾值,就從最老的 SST 開始刪除,其實可以看到,這套機制非常適合於儲存時序資料。
實際對於 RocksDB 來說,它其實用的是一種 Hybrid 的策略,在 Level 0 層,它其實是一個 Size-Tired 的,而在其他層就是 Leveled 的。
這裡在聊聊幾個放大因子,對於 LSM 來說,我們需要考慮寫放大,讀放大和空間放大,讀放大可以認為是 RA = number of queries * disc reads
,譬如使用者要讀取一個 page,但實際下面讀取了 3 個 pages,那麼讀放大就是 3。而寫放大則是 WA = data writeen to disc / data written to database
,譬如使用者寫入了 10 位元組,但實際寫到磁碟的有 100 位元組,那麼寫放大就是 10。而對於空間放大來說,則是 SA = size of database files / size of databases used on disk
,也就是資料庫可能是 100 MB,但實際佔用了 200 MB 的空間,那麼就空間放大就是 2。
這裡簡單的聊了聊 RocksDB 相關的一些知識,下面就來說說作者是如何做 Auto tuning 的。
Statistics
因為關注的目標是寫入壓力情況下面的 compaction 優化,所以自然我們需要關注的是 RocksDB 的 compaction 統計。RocksDB 會定期將很多統計資訊給寫入到日誌裡面,所以我們只需要分析日誌就行了了。
我們需要關注的 RocksDB 日誌如下:
Cumulative compaction: 2.09 GB write, 106.48 MB/s write, 1.19 GB read, 60.66 MB/s read, 14.4 seconds Interval compaction: 1.85 GB write, 130.27 MB/s write, 1.19 GB read, 83.86 MB/s read, 13.2 seconds Cumulative writes: 10K writes, 10K keys, 10K commit groups, 1.0 writes per commit group, ingest: 0.93 GB, 47.57 MB/s Cumulative WAL: 10K writes, 0 syncs, 10000.00 writes per sync, written: 0.93 GB, 47.57 MB/s Cumulative stall: 00:00:0.000 H:M:S, 0.0 percent Interval writes: 7201 writes, 7201 keys, 7201 commit groups, 1.0 writes per commit group, ingest: 686.97 MB, 47.36 MB/s Interval WAL: 7201 writes, 0 syncs, 7201.00 writes per sync, written: 0.67 MB, 47.36 MB/s Interval stall: 00:00:0.000 H:M:S, 0.0 percent
具體的分析指令碼在 這裡 ,這個指令碼會提取相應的欄位,然後繪製成圖表,這樣我們就能直觀的看實際的 I/O 量了。
Compaction Tuner
要控制 auto compaction,RocksDB 有一個 disable_auto_compactions
引數,當設定為 false 的時候,就會停止 compaction,但這時候需要將 Level 0 的 slowdown 引數也設定大,不然就會出現 write stall 問題。
RocksDB 自身提供了一個 SetOptions
的函式,方便外面動態的去調整引數,但這樣其實就需要自己在外面顯示的維護 RocksDB 例項。另一種方式就是給 RocksDB 傳一個共享的 environment,通過這個來控制幾個引數的修改。權衡之後,作者決定使用共享 env 的方式,因為容易實現,同時也能更方便的去訪問到 database 的內部。
所以作者定製了一個 env,提供了 Enable 和 Disable 兩個函式,在 Disable 裡面,將 level0_file_num_compaction_trigger
設定成了 (1<<30)
,這個也是 RocksDB PrepareForBulkLoad
函式裡面的值。
bool disable_auto_compactions; int prev_level0_file_num_compaction_trigger; int level0_file_num_compaction_trigger; void DisableCompactions() { if (!disable_auto_compactions) { prev_level0_file_num_compaction_trigger = level0_file_num_compaction_trigger; disable_auto_compactions = true; level0_file_num_compaction_trigger = (1<<30); } }; void EnableCompactions() { if (disable_auto_compactions) { disable_auto_compactions = false; level0_file_num_compaction_trigger = prev_level0_file_num_compaction_trigger; } }
RocksDB 的 compaction 控制在 ColumnFamilyData 類裡面,通過函式 RecalculateWriteStallConditions
來計算的,但 ColumnFamilyData 並沒有 env,所以作者擴充套件了一下,給 ColumnFamilyData 的建構函式加了個 env 變數:
ColumnFamilyData* new_cfd = new ColumnFamilyData( id, name, dummy_versions, table_cache_, write_buffer_manager_, options, *db_options_, env_options_, this, Env::Default());
然後在改了下 RecalculateWriteStallConditions
,讓其能接受 env 的引數來控制。
-WriteStallCondition ColumnFamilyData::RecalculateWriteStallConditions( -const MutableCFOptions& mutable_cf_options) { +WriteStallCondition ColumnFamilyData::RecalculateWriteStallConditions() { auto write_stall_condition = WriteStallCondition::kNormal; +if (current_ != nullptr) { +if (mutable_cf_options_.atuo_tuned_compaction) { +mutable_cf_options_.level0_file_num_compaction_trigger = env_->level0_file_num_compaction_trigger; +mutable_cf_options_.disable_auto_compations = env_disable_auto_compations; +} +} +const MutableCFOptions& mutable_cf_options = mutable_cf_options_;
Rate Limiter
在 RocksDB 裡面,我們也可以通過 Rate Limiter 來 控制 I/O,通常有幾個引數:
-
rate_limit_bytes_per_sec
:控制 compaction 和 flush 每秒總的寫入量 -
refill_period_us
:控制 tokens 多久再次填滿,譬如rate_limit_bytes_per_sec
是 10MB/s,而refill_period_us
是 100ms,那麼每 100ms 的流量就是 1MB/s。 -
fairness
:用來控制 high 和 low priority 的請求,防止 low priority 的請求餓死。
另外,RocksDB 還提供了一個 Auto-tuned Rate Limiter,它使用了一個 Multiplicative Increase Multiplicative Decrease(MIMD) 演算法,auto-tuned 發生條件如下:
if (auto_tuned_) { static const int kRefillsPerTune = 100; std::chrono::microseconds now(NowMicrosMonotonic(env_)); if (now - tuned_time_ >= kRefillsPerTune * std::chrono::microseconds(refill_period_us_)) { Tune(); } }
Auto-tuned RateLimiter 裡面已經有很高效的 I/O 判斷了,但是這個 I/O 包含的是 flush 和 compaction 的請求的,作者需要區分兩種不同的請求。這個在 RocksDB 裡面很容易,因為 compaction 和 low priority 請求,而 flush 是 high priority 的。作者把 GenericRateLimiter::Request
裡面計算 num_drain_
的方式改了下,引入了 num_high_drains_
和 num_low_drains_
兩個變數,然後得到 num_drains
,如下: num_drains_ = num_high_drains_ + num_low_drains_;
。
有了 high 和 low 的 drains 變數,就可以直接來控制 compaction 了,作者新增了一個 TuneCompaction
函式,類似原來的 Tune
:
Status GenericRateLimiter::TuneCompaction(Statistics* stats) { const int kLowWatermarkPct = 50; const int kHighWatermarkPct = 90; std::chrono::microseconds prev_tuned_time = tuned_time_; tuned_time_ = std::chrono::microseconds(NowMicrosMonotonic(env_)); int64_t elapsed_intervals = (tuned_time_ - prev_tuned_time + std::chrono::microseconds(refill_period_us_) - std::chrono::microseconds(1)) / std::chrono::microseconds(refill_period_us_); // We tune every kRefillsPerTune intervals, so the overflow and division by // zero conditions should never happen. assert(num_drains_ - prev_num_drains_ <= port::kMaxInt64 / 100); assert(elapsed_intervals > 0); int64_t drained_high_pct = (num_high_drains_ - prev_num_high_drains_) * 100 / elapsed_intervals; int64_t drained_low_pct = (num_low_drains_ - prev_num_low_drains_) * 100 / elapsed_intervals; int64_t drained_pct = drained_high_pct + drained_low_pct; if (drained_pct == 0) { // Nothing } else if (drained_pct <= kHighWatermarkPct && drained_high_pct < kLowWatermarkPct) { env_->EnableCompactions(); } else if (drained_pct >= kHighWatermarkPct && drained_high_pct >= kLowWatermarkPct) { env_->DisableCompactions(); RecordTick(stats, COMPACTION_DISABLED_COUNT, 1); } num_low_drains_ = prev_num_low_drains_; num_high_drains_ = prev_num_high_drains_; num_drains_ = prev_num_drains_; return Status::OK(); }
觸發規則也比較容易,如果 flush I/O 高於 50%,而總的 I/O 超過了 90%,就關掉 compaction,反之則開啟 compaction。
DB bench
準備好了所有東西,下一步自然是測試,驗證 tuning 能否有效了。作者在 RocksDB 官方的 db_bench
上面加入了一種 Sine Wave 模式,也就是讓寫入滿足如下規則:

這個模式現在已經加入了 db_bench
裡面,後面我們也可以嘗試一下。然後就是確定下 RocksDB 的一些引數,開始測試了。這裡具體不說了,反正就是改引數,做實驗,得到一個比較優的配置的過程。然後作者對比了 RocksDB 預設開啟 compaction,不開啟 compaction 以及使用自己的 Auto-tuner 的情況,一些結果:

可以看到,資料還是很不錯的。詳細的資料可以看作者的 Paper。
總結
總的來說,作者實現的 Auto-tuner 通過控制 compaction,取得了比較好的效果,後面對我們的引數調優也有很好的借鑑意義。另外,RocksDB team 也一直在致力於 I/O 的優化,我還是很堅信 RocksDB 會越來越快的。現在我們也在進行 TiKV 的 tuning 工作,會分析 TiKV 當前的 workload 來調整 RocksDB 的引數,如果你對這方面感興趣,歡迎聯絡我 pingcap.com" target="_blank" rel="nofollow,noindex">[email protected] 。