Rocksdb 的优秀代码(一) -- 工业级分桶算法实现分位数p50,p99,p9999
文章目錄
- 基本概念
- 普通的分位數(shù)計算
- Rocksdb中的應(yīng)用
- rocksdb中的分桶算法結(jié)果展示
- rocksdb 分桶算法實現(xiàn)
- 一些總結(jié) 和 相關(guān)論文
我們知道一個完整的監(jiān)控系統(tǒng)必須存在p99/p999等分位數(shù)指標(biāo),作為系統(tǒng)可用性的評判標(biāo)準(zhǔn)之一。而像開源監(jiān)控系統(tǒng)中做的很不錯的grafana和prometheus一定需要工業(yè)級的分位數(shù)算法。
本文中涉及到的rocksdb源代碼都是基于rocksdb 6.4.6版本
基本概念
所謂分位數(shù)(quantile),比如p99,表示percent of 99,即99% 的數(shù)據(jù)小于當(dāng)前p99的指標(biāo)。使用分位數(shù)來統(tǒng)計系統(tǒng)的長尾延時,也是系統(tǒng)可用性的一種衡量指標(biāo)。
由分位數(shù)的基本描述中我們能夠看到分位數(shù)的結(jié)果計算是需要有排序的過程,我們想要知道99%的指標(biāo)小于某一個值,即需要針對當(dāng)前數(shù)據(jù)集進(jìn)行從小到大的排序,取到第99%的數(shù)據(jù)才能拿到p99的指標(biāo)。
所謂工業(yè)級 ,由以上針對p99的計算過程我們知道這個過程需要排序,同時肯定也需要占用存儲空間,當(dāng)系統(tǒng)吞吐達(dá)到數(shù)十萬級更高量級 ,分位數(shù)的精確度 和 其計算過程中的空間占用 trade off 也需要重點關(guān)注。那么一個高性能,節(jié)省空間,以及精確度較高的分位數(shù)實現(xiàn)算法就需要仔細(xì)揣摩。
普通的分位數(shù)計算
我們常見的分位數(shù)為p50,p99,p999,p9999 ,在計算不同分位數(shù)數(shù)值的過程一般分位三步:
- 將數(shù)據(jù)從小到大排列
- 確定p 分位數(shù)位置
- 確定p 分位數(shù)的具體數(shù)值
數(shù)據(jù)從小達(dá)到的排序就是我們正常的排序算法。
確定p分位數(shù)的位置,比如p99,則確定整個數(shù)組中99%的那個元素所處的位置,一般通過
pos = 1 + (n - 1) * p
為了保證得到的是第99%個元素,將計算時的元素位置前移 并 在最后的結(jié)果+1。
確定分位數(shù)的具體數(shù)值則通過如下公式:
在數(shù)據(jù)已經(jīng)完成從小到大的排列之后,通過如下公式完成計算。
p分位數(shù)位置的值 = 位于p分位數(shù)取整后位置的值 + (位于p分位數(shù)取整下一位位置的值 - 位于p分位數(shù)取整后位置的值)*(p分位數(shù)位置 - p分位數(shù)位置取整)
Rocksdb中的應(yīng)用
作為高性能單機引擎,rocksdb內(nèi)部有數(shù)量龐大的metrics,也有對應(yīng)的分位數(shù)。在單機引擎數(shù)十萬的qps下,針對請求耗時的分位數(shù)計算 必須滿足工業(yè)級需求。既需要較高的性能和精確度,又需要較少的空間占用。
如果按章上文普通方式的算法完成分位數(shù)的計算:
- 空間占用的消耗 :需要保存所有的指標(biāo)數(shù)據(jù),假如每秒鐘產(chǎn)生20w的qps,那么就需要保存20w對應(yīng)的uint64_t 的指標(biāo)數(shù)據(jù)。而且,rocksdb中指標(biāo)數(shù)十甚至上百個,每個指標(biāo) 每秒都需要保存20w的指標(biāo)數(shù)據(jù),這對內(nèi)存是一個巨大的消耗。
- 計算資源的消耗: 因為分位數(shù)的獲取是由用戶來指定時間的,也就是在用戶指定獲取分位數(shù)之前所有的指標(biāo)都需要累加,如果這一段累計的時間較長,那么獲取的時候進(jìn)行數(shù)據(jù)全排的計算代價就非常大了。
接下來我們一起看看工業(yè)級的rocksdb是如何解決以上兩方面的問題的。
rocksdb中的分桶算法結(jié)果展示
先簡單展示一下rocksdb的一秒鐘level0 讀耗時的分位數(shù)統(tǒng)計結(jié)果指標(biāo):
** Level 0 read latency histogram (micros):
Count: 360578 Average: 2.8989 StdDev: 116.15
Min: 1 Median: 1.6243 Max: 38033
Percentiles: P50: 1.62 P75: 1.95 P99: 3.95 P99.9: 16.61 P99.99: 290.83
------------------------------------------------------
[ 0, 1 ] 6994 1.940% 1.940%
( 1, 2 ] 277573 76.980% 78.920% ###############
( 2, 3 ] 70608 19.582% 98.502% ####
( 3, 4 ] 1884 0.522% 99.024%
( 4, 6 ] 454 0.126% 99.150%
( 6, 10 ] 2110 0.585% 99.735%
( 10, 15 ] 507 0.141% 99.876%
( 15, 22 ] 380 0.105% 99.981%
( 22, 34 ] 20 0.006% 99.987%
( 34, 51 ] 6 0.002% 99.988%
( 110, 170 ] 4 0.001% 99.989%
( 170, 250 ] 1 0.000% 99.990%
( 250, 380 ] 3 0.001% 99.991%
( 380, 580 ] 2 0.001% 99.991%
( 580, 870 ] 4 0.001% 99.992%
( 870, 1300 ] 5 0.001% 99.994%
( 1300, 1900 ] 4 0.001% 99.995%
( 1900, 2900 ] 6 0.002% 99.996%
( 2900, 4400 ] 5 0.001% 99.998%
( 9900, 14000 ] 3 0.001% 99.999%
( 14000, 22000 ] 3 0.001% 99.999%
( 22000, 33000 ] 1 0.000% 100.000%
( 33000, 50000 ] 2 0.001% 100.000%
非常清晰得看到 各個層級的分位數(shù)指標(biāo),包括p50,p75,p99,p99.9,p99.99
在Percentiles之下 總共有四列(這里將做括號和右方括號算作一列,是一個hash桶)
- 第一列 : 看作一個hash桶,這個hash桶表示一個耗時區(qū)間,單位是us
- 第二列:一秒內(nèi)產(chǎn)生的請求耗時命中當(dāng)前耗時區(qū)間的有多少個
- 第三列:一秒內(nèi)產(chǎn)生的請求耗時命中當(dāng)前耗時區(qū)間的個數(shù)占總請求個數(shù)的百分比
- 第四列:累加之前所有請求的百分比
通過hash桶完整得展示了 耗時主體命中在了(1,2]us的耗時區(qū)間,占了整個耗時比例的78.9%。
以上僅僅是L0的一個分位數(shù)統(tǒng)計,rocksdb為每一層都維護(hù)了類似這樣的耗時桶。同時實際測試過程中,累加每秒的耗時統(tǒng)計結(jié)果即上面的Count統(tǒng)計, 和實際的每秒的qps進(jìn)行對比發(fā)現(xiàn)并沒有太大的差異,也就是這里的耗時統(tǒng)計和實際的請求統(tǒng)計接近,且并沒有太多的資源消耗。(打開opstions.statistics和關(guān)閉這個指標(biāo),系統(tǒng)CPU資源的消耗并沒有太大的差異),也就是說單從rocksdb實現(xiàn)的分位數(shù)算法的計算資源消耗角度來看已經(jīng)滿足工業(yè)級的標(biāo)準(zhǔn)了。
rocksdb 分桶算法實現(xiàn)
按照我們之前描述的分位數(shù)計算的三步來看rocksdb的代碼
- 將數(shù)據(jù)從小到達(dá)排列
- 確定p 分位數(shù)位置
- 計算p 分位數(shù)的值
第一步,數(shù)據(jù)從小到大進(jìn)行排列。在rocksdb中,我們打開statistics選項之后相關(guān)的耗時指標(biāo)會動態(tài)增加,也就是分位數(shù)計算的數(shù)據(jù)集是在不斷增加且動態(tài)變化的。
rocksdb的數(shù)據(jù)集中元素的增加邏輯如下:
void HistogramStat::Add(uint64_t value) {// This function is designed to be lock free, as it's in the critical path// of any operation. Each individual value is atomic and the order of updates// by concurrent threads is tolerable.const size_t index = bucketMapper.IndexForValue(value); // 通過hash桶計算value所處的 耗時范圍assert(index < num_buckets_);// index 所在的hash桶 bucket_[index]元素個數(shù)自增buckets_[index].store(buckets_[index].load(std::memory_order_relaxed) + 1,std::memory_order_relaxed);uint64_t old_min = min();if (value < old_min) {min_.store(value, std::memory_order_relaxed);}uint64_t old_max = max();if (value > old_max) {max_.store(value, std::memory_order_relaxed);}num_.store(num_.load(std::memory_order_relaxed) + 1,std::memory_order_relaxed);sum_.store(sum_.load(std::memory_order_relaxed) + value,std::memory_order_relaxed);sum_squares_.store(sum_squares_.load(std::memory_order_relaxed) + value * value,std::memory_order_relaxed);
}
以上添加的過程整體的邏輯如下幾步
- 計算該value 代表的耗時 所處hash桶的范圍。假如傳入的是2.5us, 則該耗時處于上文中打印出來的耗時范圍(2,3],返回該范圍代表的索引號
[ 0, 1 ] 6994 1.940% 1.940% ( 1, 2 ] 277573 76.980% 78.920% ############### ( 2, 3 ] 70608 19.582% 98.502% #### ( 3, 4 ] 1884 0.522% 99.024% ( 4, 6 ] 454 0.126% 99.150% - 拿到索引號,更新該hash桶的元素個數(shù)。 即bucket_自增
- 更新當(dāng)前層的當(dāng)前讀耗時其他指標(biāo):最小值,最大值,值的總和,值平方的總和
也就是整個添加過程并不會將新的value數(shù)據(jù)保存下來,而是維護(hù)該value所處的bucket_大小,這個bucket_一個std::atomic_uint_fast64_t的數(shù)組,初始化整個hash桶的過程就已經(jīng)完成了整個hash桶耗時范圍的映射。
其中計算索引的邏輯如下:
主要是通過變量valueIndexMap_來查找的,這個變量在HistogramBucketMapper構(gòu)造函數(shù)中進(jìn)行的初始化。是一個map類型,key-value代表的是一個個已經(jīng)完成初始化的耗時區(qū)間。
在IndexForValue函數(shù)中拿著當(dāng)前耗時數(shù)據(jù)value 在valueIndexMap_中查找到第一個小于等于(lower_bound)當(dāng)前指標(biāo)的索引位置。
size_t HistogramBucketMapper::IndexForValue(const uint64_t value) const {if (value >= maxBucketValue_) {return bucketValues_.size() - 1;} else if ( value >= minBucketValue_ ) {std::map<uint64_t, uint64_t>::const_iterator lowerBound =valueIndexMap_.lower_bound(value);if (lowerBound != valueIndexMap_.end()) {return static_cast<size_t>(lowerBound->second);} else {return 0;}} else {return 0;}
}
到此,類似于計數(shù)排序的方式,通過范圍hash桶動態(tài)維護(hù)了一個個元素所處的bucket_ size,而非整個元素。
hash桶 以map方式實現(xiàn),本事有序,key-value代表范圍,保證相鄰的桶的耗時區(qū)間不會重疊,從而達(dá)到統(tǒng)計的過程中在范圍之間是有序的。
這個時候很多同學(xué)會有疑惑,返回之間有序,當(dāng)分位數(shù)的某一個指標(biāo)比如p20落在了一個擁有數(shù)萬個元素的范圍之間。按照當(dāng)前的計算方式,其實已經(jīng)無法精確計算p20這樣的指標(biāo)了。之前也說了,rocksdb實現(xiàn)的是工業(yè)級的分位數(shù)計算,也就是我們通過p99即更高的分位數(shù)指標(biāo)作為系統(tǒng)可用性的評判標(biāo)準(zhǔn),那當(dāng)前的分桶算法實現(xiàn)的分位數(shù)計算也就能夠理解了。
正如我們打印的分桶過程:
[ 0, 1 ] 9230 3.422% 3.422% #
( 1, 2 ] 174704 64.771% 68.193% #############
( 2, 3 ] 50114 18.580% 86.773% ####
( 3, 4 ] 13030 4.831% 91.604% #
( 4, 6 ] 8827 3.273% 94.877% #
( 6, 10 ] 9314 3.453% 98.330% #
( 10, 15 ] 1893 0.702% 99.032%
( 15, 22 ] 969 0.359% 99.391%
( 22, 34 ] 708 0.262% 99.653%
( 34, 51 ] 571 0.212% 99.865%
( 51, 76 ] 324 0.120% 99.985%
( 76, 110 ] 35 0.013% 99.998%
( 110, 170 ] 3 0.001% 99.999%
可以看到在更高層分桶區(qū)間內(nèi),命中的指標(biāo)越來越少,也就是像p999,p9999這樣的高分位數(shù)的統(tǒng)計將更加精確。
當(dāng)然,也可以有完全精確的計算統(tǒng)計方法,那就是需要通過空間以及計算資源來換取精確度了,這個代價并不是一個很好的trade off方式。rocksdb的分桶同樣可以控制精確度,我們可以手動調(diào)整初始化桶的精度,來讓精度區(qū)間更小。
接下來我們看看后面兩步:
- 確定p 分位數(shù)的位置
- 計算p 分位數(shù)的值
這兩步的實現(xiàn)主要在如下函數(shù)中:
通過傳入分位數(shù)指標(biāo)50, 99,99.9,99.99等來進(jìn)行對應(yīng)的計算。
double HistogramStat::Percentile(double p) const {double threshold = num() * (p / 100.0); // 分位數(shù)所在總請求的位置uint64_t cumulative_sum = 0;for (unsigned int b = 0; b < num_buckets_; b++) {uint64_t bucket_value = bucket_at(b);cumulative_sum += bucket_value;if (cumulative_sum >= threshold) { // 持續(xù)累加bucket_請求數(shù),直到達(dá)到分位數(shù)所在總請求個數(shù)的位置// Scale linearly within this bucketuint64_t left_point = (b == 0) ? 0 : bucketMapper.BucketLimit(b-1);uint64_t right_point = bucketMapper.BucketLimit(b);uint64_t left_sum = cumulative_sum - bucket_value;uint64_t right_sum = cumulative_sum;double pos = 0;uint64_t right_left_diff = right_sum - left_sum;if (right_left_diff != 0) {pos = (threshold - left_sum) / right_left_diff; // 計算p 分位數(shù)的位置}double r = left_point + (right_point - left_point) * pos; // 計算分位數(shù)的值uint64_t cur_min = min();uint64_t cur_max = max();if (r < cur_min) r = static_cast<double>(cur_min);if (r > cur_max) r = static_cast<double>(cur_max);return r;}}return static_cast<double>(max());
}
以上函數(shù)關(guān)于分位數(shù)位置和值的計算基本和下面公式接近:
p分位數(shù)位置的值 = 位于p分位數(shù)取整后位置的值 + (位于p分位數(shù)取整下一位位置的值 - 位于p分位數(shù)取整后位置的值)*(p分位數(shù)位置 - p分位數(shù)位置取整)
rocksdb的計算方式是通過取分位數(shù)所處總請求的位置,該請求所在的hash桶范圍內(nèi)取第pos個位置的指標(biāo),作為當(dāng)前分位數(shù)的值。
通過這樣圖就比較清晰的展示計算threshold的精確percentile的值了。
- left_point hash桶區(qū)間左端點
- right_point 代表右端點
- threshold 代表分位數(shù)所處該區(qū)間的位置的比例(并不是一個精確的位置)
- left_sum 達(dá)到左端點的之前所有hash桶請求總個數(shù)
- right_sum 達(dá)到右端點的之前所有hash桶請求總個數(shù)
threshold所在的pos 計算如下:pos = (right_sum - threshold) / (right_sum - left sum)
整個pos代表的值的計算如下:r = left_point + pos * (right_point - left_point) ,即可得到當(dāng)前hash桶所代表的耗時區(qū)間內(nèi)分位數(shù)所處的精確位置,當(dāng)前這里的精確肯定不是100%,也是一個概率性的數(shù)值。
一些總結(jié) 和 相關(guān)論文
工業(yè)界的實現(xiàn)總是在成本和收益之間進(jìn)行取舍,最低的成本換來最大的收益。分位數(shù)的價值是說能夠用最低的計算和存儲成本換來系統(tǒng)可用性的評判,從而更有針對性的進(jìn)行系統(tǒng)優(yōu)化,從而產(chǎn)生更大的價值。
分位數(shù)的工業(yè)界算法實現(xiàn)有很多篇可以參考的論文:
1. 隨機算法- Random Sampling with a Reservoir
2.靜態(tài)分桶算法 - HdrHistogram
3.二叉樹實現(xiàn)的靜態(tài)分桶算法 - Medians and Beyond: New Aggregation Techniques
for Sensor Networks
4. 動態(tài)分桶 GK算法
歡迎感興趣的同學(xué)一起討論~
總結(jié)
以上是生活随笔為你收集整理的Rocksdb 的优秀代码(一) -- 工业级分桶算法实现分位数p50,p99,p9999的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 如果科比和乔丹巅峰时PK谁等分能力更猛?
- 下一篇: 磁盘I:O 性能指标 以及 如何通过 f