作者:吳毅 王遠立
TiKV 底層使用了 RocksDB 作為儲存引擎,然而 RocksDB 配置選項很多,很多情況下只能通過反覆測試或者依靠經驗來調優,甚至連 RocksDB 的開發者都自嘲,他們沒辦法弄清楚每個引數調整對效能的影響。如果有一個自動 tuning 的方案就可以大大減少調優的人力成本,同時也可能在調優的過程中,發現一些人工想不到的資訊。我們從 AutoML 中得到啟發,希望能用 Automated Hyper-parameter Tuning 中的一些方法來對資料庫引數進行自動調優。
常用的 Automated Hyper-parameter Tuning 方式大體上有以下三種:
- 隨機搜尋,或者說叫啟發式搜尋。包括 GridSearch 和 RandomSearch。這種方法的改進空間主要體現在使用不同的取樣方法生成配置,但本質上仍然是隨機試驗不同的配置,沒有根據跑出來的結果來反饋指導取樣過程,效率比較低。
- Multi-armed Bandit。這種方法綜合考慮了“探索”和“利用”兩個問題,既可以配置更多資源(也就是取樣機會)給搜尋空間中效果更優的一部分,也會考慮嘗試儘量多的可能性。Bandit 結合貝葉斯優化,就構成了傳統的 AutoML 的核心。
- 深度強化學習。強化學習在 AutoML 中最著名的應用就是 NAS,用於自動生成神經網路結構。另外它在 深度學習引數調優 中也有應用。它的優點是從“從資料中學習”轉變為“從動作中學習”(比如 knob 中的 cache size 從小調到大),既可以從效能好的樣本中學習,也可以從效能壞的樣本中學習。但強化學習的坑也比較多,體現在訓練可能比較困難,有時結果比較難復現。
目前學術界針對 auto-tune 資料庫的研究也有很多,採用的方法大多集中在後面兩種。其中一個比較有名的研究是 OtterTune 。我們受 OtterTune 的啟發,開發了 AutoTiKV,一個用於對 TiKV 資料庫進行自動調優的工具。專案啟動三個月以來,AutoTiKV 在 TiKV 內部測試和調參的環節起到了較好的效果,有了一個很好的開始。後續我們還會針對生產環境上的一些特點,對它進行繼續探索和完善。
專案地址:https://github.com/tikv/auto-tikv
設計目標
整個調優過程大致如下圖:
整個過程會迴圈跑 200 個 round(可以使用者自定義),或者也可以定義成到結果收斂為止。
AutoTiKV 支援在修改引數之後重啟 TiKV(如果不需要也可以選擇不重啟)。需要調節的引數和需要檢視的 metric 可以在 controller.py 裡宣告。
一開始的 10 輪(具體大小可以調節)是用隨機生成的 knob 去 benchmark,以便收集初始資料集。之後的都是用 ML 模型推薦的引數去 benchmark。
ML 模型
AutoTiKV 使用了和 OtterTune 一樣的高斯過程迴歸(Gaussian Process Regression,以下簡稱 GP)來推薦新的 knob[1],它是基於高斯分佈的一種非引數模型。高斯過程迴歸的好處是:
- 和神經網路之類的方法相比,GP 屬於無引數模型,演算法計算量相對較低,而且在訓練樣本很少的情況下表現比 NN 更好。
- 它能估計樣本的分佈情況,即
X
的均值m(X)
和標準差s(X)
。若X
周圍的資料不多,則它被估計出的標準差s(X)
會偏大(表示這個樣本X
和其他資料點的差異大)。直觀的理解是若資料不多,則不確定性會大,體現在標準差偏大。反之,資料足夠時,不確定性減少,標準差會偏小。這個特性後面會用到。
但 GP 本身其實只能估計樣本的分佈,為了得到最終的預測值,我們需要把它應用到貝葉斯優化(Bayesian Optimization)中。貝葉斯優化演算法大致可分為兩步:
- 通過 GP 估計出函式的分佈情況。
- 通過採集函式(Acquisition Function)指導下一步的取樣(也就是給出推薦值)。
採集函式(Acquisition Function)的作用是:在尋找新的推薦值的時候,平衡探索(exploration)和利用(exploitation)兩個性質:
- exploration:在目前資料量較少的未知區域探索新的點。
- exploitation:對於資料量足夠多的已知區域,利用這些資料訓練模型進行估計,找出最優值。
在推薦的過程中,需要平衡上述兩種指標。exploitation 過多會導致結果陷入區域性最優值(重複推薦目前已知的最好的點,但可能還有更好的點沒被發現),而 exploration 過多又會導致搜尋效率太低(一直在探索新區域,而沒有對當前比較好的區域進行深入嘗試)。而平衡二者的核心思想是:當資料足夠多時,利用現有的資料推薦;當缺少資料時,我們在點最少的區域進行探索,探索最未知的區域能給我們最大的資訊量。
貝葉斯優化的第二步就可以幫我們實現這一思想。前面提到 GP 可以幫我們估計 X
的均值 m(X)
和標準差 s(X)
,其中均值 m(x)
可以作為 exploitation 的表徵值,而標準差 s(x)
可以作為 exploration 的表徵值。這樣就可以用貝葉斯優化方法來求解了。
使用置信區間上界(Upper Confidence Bound)作為採集函式。假設我們需要找 X
使 Y
值儘可能大,則 U(X) = m(X) + k*s(X)
,其中 k > 0
是可調的係數。我們只要找 X
使 U(X)
儘可能大即可。
- 若
U(X)
大,則可能m(X)
大,也可能s(X)
大。 - 若
s(X)
大,則說明X
周圍資料不多,需要探索未知區域新的點。 - 若
m(X)
大,說明估計的Y
值均值大, 則需要利用已知資料找到效果好的點。 - 其中係數
k
影響著探索和利用的比例,k
越大,越鼓勵探索新的區域。
在具體實現中,一開始隨機生成若干個 candidate knobs,然後用上述模型計算出它們的 U(X)
,找出 U(X)
最大的那一個作為本次推薦的結果。
資料庫引數
workload
測試中我們使用了 YCSB 來模擬 write heavy、long range scan、short range scan 和 point-lookup 四種典型 workload。資料庫大小都是 80GB。[2]
knobs
我們試驗瞭如下引數:
Options | Expected behavior | valid range/value set |
---|---|---|
write-buffer-size | point-lookup, range-scan: larger the better | [64MB, 1GB] |
max-bytes-for-level-base | point-lookup, range-scan: larger the better | [512MB, 4GB] |
target-file-size-base | point-lookup, range-scan: larger the better | {8M, 16M, 32M, 64M, 128M} |
disable-auto-compactions | write-heavy: turn on is better point-lookup, range-scan: turn off is better | {1, 0} |
block-size | point-lookup: smaller the better, range-scan: larger the better | {4k,8k,16k,32k,64k} |
bloom-filter-bits-per-key | point-lookup, range-scan: larger the better | [5,10,15,20] |
optimize-filters-for-hits | point-lookup, range-scan: turn off is better | {1,0} |
這些引數的含義如下:
-
block-size
:RocksDB 會將資料存放在 data block 裡面,block-size 設定這些 block 的大小,當需要訪問某一個 key 的時候,RocksDB 需要讀取這個 key 所在的整個 block。對於點查,更大的 block 會增加讀放大,影響效能,但是對於範圍查詢,更大的 block 能夠更有效的利用磁碟頻寬。 -
disable-auto-compactions
:定義是否關閉 compaction。compaction 會佔用磁碟頻寬,影響寫入速度。但如果 LSM 得不到 compact, level0 檔案會累積,影響讀效能。其實本身 compaction 也是一個有趣的 auto-tuning 的方向。 -
write-buffer-size
:單個 memtable 的大小限制(最大值)。理論上說更大的 memtable 會增加二分查詢插入位置的消耗,但是之前的初步試驗發現這個選項對 writeheavy 影響並不明顯。 -
max-bytes-for-level-base
:LSM tree 裡面level1
的總大小。在資料量固定的情況下,這個值更大意味著其實 LSM 的層數更小,對讀有利。 -
target-file-size-base
:假設target-file-size-multiplier=1
的情況下,這個選項設定的是每個 SST 檔案的大小。這個值偏小的話意味著 SST 檔案更多,會影響讀效能。 -
bloom-filter-bits-per-key
:設定 Bloom Filter 的位數。對於讀操作這一項越大越好。 -
optimize-filters-for-hits
:True 表示關閉 LSM 最底層的 bloom filter。這個選項主要是因為最底層的 bloom filter 總大小比較大,比較佔用 block cache 空間。如果已知查詢的 key 一定在資料庫中存,最底層 bloom filter 其實是沒有作用的。
metrics
我們選擇瞭如下幾個 metrics 作為優化指標。
- throughput:根據具體 workload 不同又分為 write throughput、get throughput、scan throughput
- latency:根據具體 workload 不同又分為 write latency、get latency、scan latency
- store_size
- compaction_cpu
其中 throughput 和 latency 通過 go-ycsb 的輸出結果獲得,store_size 和 compaction_cpu 通過 tikv-ctl 獲得。
實驗測試結果
測試平臺
AMD Ryzen5-2600 (6C12T),32GB RAM,512GB NVME SSD,Ubuntu 18.04,tidb-ansible 用的 master 版本。
所有的實驗都是前 10 輪用隨機生成的配置,後面使用模型推薦的配置:
workload=writeheavy knobs={disable-auto-compactions, block-size} metric=write_latency
實驗效果如下:
這個實驗中推薦結果是啟用 compaction、同時 block size 設為 4KB。
雖然一般來說寫入時需要關閉 compaction 以提升效能,但分析後發現由於 TiKV 使用了 Percolator 進行分散式事務,寫流程也涉及讀操作(寫衝突檢測),所以關閉 compaction 也導致寫入效能下降。同理更小的 block size 提高點查效能,對 TiKV 的寫流程效能也有提升。
接下來用 point lookup 這一純讀取的 workload 進行了試驗:
workload=pntlookup80 knobs={'bloom-filter-bits-per-key', 'optimize-filters-for-hits', 'block-size', 'disable-auto-compactions'} metric=get_latency
實驗效果如下:
推薦結果為:bloom-filter-bits-per-key==20,block-size==4K,不 disable auto compaction。而 optimize-filters-for-hits 是否啟用影響不大(所以會出現這一項的推薦結果一直在搖擺的情況)。
推薦的結果都挺符合預期的。關於 optimize-filter 這一項,應該是試驗裡面 block cache 足夠大,所以 bloom filter 大小對 cache 效能影響不大;而且我們是設定 default CF 相應的選項(關於 TiKV 中對 RocksDB CF 的使用,可以參考 《TiKV 是如何存取資料的》),而對於 TiKV 來說查詢 default CF 之前我們已經確定相應的 key 肯定存在,所以是否有 filter 並沒有影響。之後的試驗中我們會設定 writeCF 中的 optimize-filters-for-hits(defaultCF 的這一項預設就是 0 了);然後分別設定 defaultCF 和 writeCF 中的 bloom-filter-bits-per-key,把它們作為兩個 knob。
為了能儘量測出來 bloom filter 的效果,除了上述改動之外,我們把 workload 也改了一下:把 run phase 的 recordcount 設成 load phase 的兩倍大,這樣強制有一半的查詢對應的 key 不存在,這樣應該會測出來 write CF 的 optimize-filters-for-hits 必須關閉。改完之後的 workload 如下:
workload=pntlookup80 knobs={rocksdb.writecf.bloom-filter-bits-per-key, rocksdb.defaultcf.bloom-filter-bits-per-key, rocksdb.writecf.optimize-filters-for-hits, rocksdb.defaultcf.block-size, rocksdb.defaultcf.disable-auto-compactions} metric=get_throughput
這次的實驗效果如下(發現一個很出乎意料的現象):
測出來發現推薦配置基本集中在以下兩種:
- {3,1,1,0,0}
rocksdb.writecf.bloom-filter-bits-per-key ['rocksdb', 'writecf'] bloom-filter-bits-per-key 20
rocksdb.defaultcf.bloom-filter-bits-per-key ['rocksdb', 'defaultcf'] bloom-filter-bits-per-key 10
rocksdb.writecf.optimize-filters-for-hits ['rocksdb', 'writecf'] optimize-filters-for-hits True
rocksdb.defaultcf.block-size ['rocksdb', 'defaultcf'] block-size 4KB
rocksdb.defaultcf.disable-auto-compactions ['rocksdb', 'defaultcf'] disable-auto-compactions False
- {2,2,0,0,0}
rocksdb.writecf.bloom-filter-bits-per-key ['rocksdb', 'writecf'] bloom-filter-bits-per-key 15
rocksdb.defaultcf.bloom-filter-bits-per-key ['rocksdb', 'defaultcf'] bloom-filter-bits-per-key 15
rocksdb.writecf.optimize-filters-for-hits ['rocksdb', 'writecf'] optimize-filters-for-hits False
rocksdb.defaultcf.block-size ['rocksdb', 'defaultcf'] block-size 4KB
rocksdb.defaultcf.disable-auto-compactions ['rocksdb', 'defaultcf'] disable-auto-compactions False
分析了一下,感覺是因為 write CF 比較小,當 block cache size 足夠大時,bloom filter 的效果可能就不很明顯了。
如果仔細看一下結果,比較如下兩個 sample,會發現一個現象:
- 30 , 2019-08-23 03:03:42 , [3. 1. 1. 0. 0.] , [4.30542000e+04 1.18890000e+04 8.68628124e+10 5.10200000e+01]
- 20 , 2019-08-22 16:09:26 , [3. 1. 0. 0. 0.] , [4.24397000e+04 1.20590000e+04 8.68403016e+10 5.07300000e+01]
它們 knob 的唯一區別就是 30 號關閉了底層 bloom filter(optimize-filters-for-hits==True),20 號啟用了底層 bloom filter(optimize-filters-for-hits==False)。結果 20 號的 throughput 比 30 還低了一點,和預期完全不一樣。於是我們開啟 Grafana 琢磨了一下,分別擷取了這兩個 sample 執行時段的圖表:
(兩種場景 run 時候的 block-cache-size 都是 12.8GB)
圖中粉色豎線左邊是 load 階段,右邊是 run 階段。可以看出來這兩種情況下 cache hit 其實相差不大,而且 20 號還稍微低一點點。這種情況是因為 bloom filter 本身也是佔空間的,如果本來 block cache size 夠用,但 bloom filter 佔空間又比較大,就會影響 cache hit。這個一開始確實沒有預料到。其實這是一個好事情,說明 ML 模型確實可以幫我們發現一些人工想不到的東西。
接下來再試驗一下 short range scan。這次要優化的 metric 改成 scan latency:
workload=shortscan knobs={'bloom-filter-bits-per-key', 'optimize-filters-for-hits', 'block-size', 'disable-auto-compactions'} metric=scan_latency
實驗結果如下:
由於篇幅有限我們先看前 45 輪的結果。這個推薦結果還沒有完全收斂,但基本上滿足 optimize-filters-for-hits==False,block-size==32KB 或者 64KB,disable-auto-compactions==False,這三個也是對結果影響最明顯的引數了。根據 Intel 的 SSD 白皮書,SSD 對 32KB 和 64KB 大小的隨機讀效能其實差不多。bloom filter 的位數對 scan 操作的影響也不大。這個實驗結果也是符合預期了。
與 OtterTune 的不同點
我們的試驗場景和 OtterTune 還是有一些區別的,主要集中在以下幾點3:
- AutoTiKV 直接和 DB 執行在同一臺機器上,而不是像 OtterTune 一樣設定一個集中式的訓練伺服器。但其實這樣並不會佔用很多資源,還避免了不同機器配置不一樣造成資料不一致的問題。
- 省去了 workload mapping(OtterTune 加了這一步來從 repository 中挑出和當前 workload 最像的訓練樣本,而我們目前預設 workload 型別只有一種)。
- 要調的 knobs 比較少,省去了 identity important knobs(OtterTune 是通過 Lasso Regression 選出 10 個最重要的 knob 進行調優)。
- 另外我們重構了 OtterTune 的架構,減少了對具體資料庫系統的耦合度。更方便將整個模型和 pipeline 移植到其他系統上(只需修改 controller.py 中具體運算元據庫系統的語句即可,其它都不用修改),也更適合比起 SQL 更加輕量的 KV 資料庫。
- 最後我們解決了 OtterTune 中只能調整 global knob,無法調節不同 session 中同名 knob 的問題。
總結
一個複雜的系統需要很多環節的取捨和平衡,才能使得總體執行效果達到最好。這需要對整個系統各個環節都有很深入的理解。而使用機器學習演算法來做引數組合探索,確實會起到很多意想不到的效果。在我們的實驗過程中,AutoTiKV 推薦的配置有些就和人工預期的情況不符,進而幫助我們發現了系統的一些問題:
- 有些引數對結果的影響並沒有很大。比如這個引數起作用的場景根本沒有觸發,或者說和它相關的硬體並沒有出現效能瓶頸。
- 有些引數直接動態調整是達不到效果的,或者需要跑足夠長時間的 workload 才能看出效果。例如 block cache size 剛從小改大的一小段時間肯定是裝不滿的,必須要等 workload 足夠把它填滿之後,才能看出大快取對總體 cache hit 的提升效果。
- 有些引數的效果和預期相反,分析了發現該引數其實是有副作用的,在某些場景下就不大行了(比如上面的 bloom filter 那個例子)。
- 有些 workload 並不是完全的讀或者寫,還會摻雜一些別的操作。而人工判斷預期效果的時候很可能忽略這一點(比如上面的 writeheavy)。特別是在實際生產環境中,DBA 並不能提前知道會遇到什麼樣的 workload。這大概也就是自動調優的作用吧。
後續我們還會對 AutoTiKV 繼續進行改進,方向集中在以下幾點:
- 動態適應不斷變化的 workload(比如一會讀一會寫),以及之前沒有出現過的不同業務特徵的 workload。
- 有時 ML 模型有可能陷入區域性最優(嘗試的 knob 組合不全,限於若干個當前效果還不錯的 knob 迴圈推薦了)。
- 借鑑 AutoML 中的思路,嘗試更多不同的 ML 模型來提高推薦效果,減少推薦所需時間。
參考資料[1] https://mp.weixin.qq.com/s/y8VIieK0LO37SjRRyPhtrw
[2] https://github.com/brianfrankcooper/YCSB/wiki/Core-Properties
原文閱讀:https://pingcap.com/blog-cn/autotikv/