Rocksdb 的優秀程式碼(二)-- 工業級 打點系統 實現分享
文章目錄
前言
一個完善的分散式系統一定是需要完善的打點統計,不論是對系統核心 還是 對系統使用者都是十分必要的。系統的客戶需要直觀得看到這個系統的效能相關的指標來決定是否使用以及如何最大化使用系統,同樣 系統的開發者也需要直觀的看到系統各個元件的各項指標,來進行效能調優 以及 穩定性保障(運維監控,failover)。
而打點 主要 是耗時相關 以及 請求數相關的統計,這一些統計需要對系統程式碼侵入即需要消耗系統的執行時間。而 分散式系統對效能的極致需求肯定是不允許額外增加核心程式碼之外的執行耗時的(計算平均值,最大值,不同的分位數),所以打點程式碼需要儘可能降低對系統本身效能的影響,這裡面就需要有足夠精巧的程式碼設計。
回到今天分享的Rocksdb 的打點設計,Rocksdb作為單機引擎,被廣泛應用在了各個分散式系統之中,而引擎的效能是上層分佈系統性能的基礎。本身外部系統對引擎效能有極致需求 且 Rocksdb本身也在不斷追求卓越的效能,這個時候rocksdb自己的打點系統就非常有借鑑意義,其已經經過無數系統 和 卓越的開發者們反覆驗證,是一個非常有參考價值的打點程式碼設計。
本次分享會從兩方面開看Rocksdb 的打點設計,也是最傳統的打點型別:
- 耗時統計(各個維度的耗時資訊)
- 請求統計(各個維度的請求個數相關的資訊)
以下描述的打點主要是全域性統計的,就是通過開啟options.statistics 引數獲取的;對於perf_context和iostats_contxt這一些thread local指標並不提及;
資料結構選型
打點的目的希望對內(研發者)以及對外(使用者)提供系統性能的參考 以及 系統問題的報警,這一些資訊會涉及到計算。舉一個例子,比如分位數,像p50,p99,p999 這樣的指標是監控系統必不可少的,用來展示系統的延時/長尾延時情況,這一些指標的計算是需要對抓取的請求儲存並排序才能取到的。對於一個叢集百萬/千萬級每秒的請求,每次取分位數,都需要對百萬級資料進行排序?顯然不可能。
所以這裡 對打點資料的儲存需要有合理的資料結構才行,rocksdb 提供瞭如下細節:
-
儲存各個指標都需要的資料結構
struct HistogramData { // 從media -- perceile99的 都是分位數
-
計算分位數的核心資料結構,比如P999 表示到目前為止所有請求中從小到大 第0.1% 個請求的耗時指標。
而這個從小到大 不一定需要真正通過排序演算法排序,第0.1% 也不一定精確的第0.1%個,可以在其上下10個請求內浮動。
所以這裡針對分位數 的計算是維護了一個有序hash表,將統計的請求新增到對應所處範圍的hash桶內,後續取P999這樣的指標時排序只需統計從小桶的請求數到滿足0.1%時請求數目 的桶 ,再進行更細粒度的取值。分位數演算法細節比較多,有興趣的可以看Rocksdb 的優秀程式碼(一) – 工業級分桶演算法實現分位數p50,p99,p9999
總之,不需要排序,只需要新增到一個map裡自動排序,維護每個map 元素bucket的請求數即可。
class HistogramBucketMapper { public: HistogramBucketMapper(); // 初始化耗時值和整個耗時區間 // converts a value to the bucket index. size_t IndexForValue(uint64_t value) const; // number of buckets required. size_t BucketCount() const { return bucketValues_.size(); } uint64_t LastValue() const { return maxBucketValue_; } uint64_t FirstValue() const { return minBucketValue_; } uint64_t BucketLimit(const size_t bucketNumber) const { assert(bucketNumber < BucketCount()); return bucketValues_[bucketNumber]; } private: std::vector<uint64_t> bucketValues_; // 初始化耗時值 uint64_t maxBucketValue_; // 耗時最大 uint64_t minBucketValue_; // 耗時最小 // 初始化耗時區間,以[bucketValues_[i-1],bucketValues_[i]] std::map<uint64_t, uint64_t> valueIndexMap_; };
-
儲存每個指標的各個維度的data資料,比如請求總數,最大,最小,上面資料結構中 valueIndexMap_各個區間的請求數(方便計算分位數)等
struct HistogramStat { ...... // 計算統計資訊的程式碼,最大,最小等 void Clear(); bool Empty() const; void Add(uint64_t value); void Merge(const HistogramStat& other); inline uint64_t min() const { return min_.load(std::memory_order_relaxed); } inline uint64_t max() const { return max_.load(std::memory_order_relaxed); } inline uint64_t num() const { return num_.load(std::memory_order_relaxed); } inline uint64_t sum() const { return sum_.load(std::memory_order_relaxed); } inline uint64_t sum_squares() const { return sum_squares_.load(std::memory_order_relaxed); } inline uint64_t bucket_at(size_t b) const { return buckets_[b].load(std::memory_order_relaxed); } // 計算分位數相關的程式碼,p50,平均值,標準差 double Median() const; double Percentile(double p) const; double Average() const; double StandardDeviation() const; // 通過這個函式,將獲取到的各個資料給到上文最開始的資料結構HistogramData // 暴露給使用者。 void Data(HistogramData* const data) const; std::string ToString() const; // To be able to use HistogramStat as thread local variable, it // cannot have dynamic allocated member. That's why we're // using manually values from BucketMapper std::atomic_uint_fast64_t min_; std::atomic_uint_fast64_t max_; std::atomic_uint_fast64_t num_; std::atomic_uint_fast64_t sum_; std::atomic_uint_fast64_t sum_squares_; std::atomic_uint_fast64_t buckets_[109]; // 109==BucketMapper::BucketCount() const uint64_t num_buckets_; };
到現在,基礎的資料結構就這麼多,接下來通過兩種維度的資料來看一下rocksdb 如何利用以上資料結構,完成自己的打點程式碼設計的。
打點程式碼設計
以如下兩個指標為例:
-
讀請求耗時統計:rocksdb.db.get.micros
rocksdb.db.get.micros P50 : 0.000000 P95 : 0.000000 P99 : 0.000000 P99.9 : 0.000000 P99.99 : 0.000000 P100 : 0.000000 COUNT : 0 SUM : 0
這是讀指標資料,其中p99.9 和 p99.99是自己加的,原來並沒有,可以看到總體的指標資料就是我們上文中資料結構中的指標。
-
Block_cache命中數統計:rocksdb.block.cache.hit
rocksdb.block.cache.hit COUNT : 0
請求數相關的指標就是單純的個數統計,不會有分位數的統計,畢竟分位數只在有延時需求的場景才會有用。
耗時打點
針對rocksdb.db.get.micros
指標,維護了一個直方圖變數
enum Histograms : uint32_t {
DB_GET = 0, // 讀耗時
DB_WRITE, // 寫耗時
COMPACTION_TIME, // compaction 耗時
COMPACTION_CPU_TIME,
SUBCOMPACTION_SETUP_TIME,
...
}
按照我們的理解,一個函式的執行耗時 是 在函式開始時獲取一個時間,函式執行結束後再獲取一個時間,兩個時間的差值就是這個函式的執行耗時,很簡答。
而rocksdb 獲取讀耗時指標的程式碼如下:
Status DBImpl::GetImpl(const ReadOptions& read_options,
ColumnFamilyHandle* column_family, const Slice& key,
PinnableSlice* pinnable_val, bool* value_found,
ReadCallback* callback, bool* is_blob_index) {
assert(pinnable_val != nullptr);
PERF_CPU_TIMER_GUARD(get_cpu_nanos, env_); // perf context的統計
StopWatch sw(env_, stats_, DB_GET); // 統計讀耗時
......
}
後面再沒有其他的耗時計算了。是不是有點詫異,也就是rocksdb 通過讀請求函式開始StopWatch
物件的初始化,完成了整個讀函式的耗時統計。
StopWatch
的程式碼如下,大家就能夠看到Rocksdb 程式碼的設計精妙了。
構造StopWatch物件需要傳入 基本的三個引數:
- env_ , rocksdb維護的全域性共享的環境變數
- Stats_ ,statistics ,將獲取到的時間新增到上文的三種資料結構中,參與運算
- DB_GET, 直方圖變數,表示讀請求的指標;類似的還有DB_WRITE等
// 初始化程式碼如下
StopWatch(Env* const env, Statistics* statistics, const uint32_t hist_type,
uint64_t* elapsed = nullptr, bool overwrite = true,
bool delay_enabled = false)
: env_(env), // 全域性環境變數,用來提供一個便捷的函式,後續主要用來呼叫時間函式 NowMicros()
statistics_(statistics), // 需要通過options.statistics初始化,否則預設為空,就不開啟rocksdb的打點系統了
hist_type_(hist_type), // 直方圖變數,DB_GET, DB_WRITE...
elapsed_(elapsed),
overwrite_(overwrite),
stats_enabled_(statistics &&
statistics->get_stats_level() >=
StatsLevel::kExceptTimers &&
statistics->HistEnabledForType(hist_type)),
delay_enabled_(delay_enabled),
total_delay_(0),
delay_start_time_(0),
start_time_((stats_enabled_ || elapsed != nullptr) ? env->NowMicros()// 起始時間
: 0) {}
也就是在StopWatch物件初始化完成時記錄下了起始時間:
start_time_((stats_enabled_ || elapsed != nullptr) ? env->NowMicros()// 起始時間
因為StopWatch 是在GetImpl函式中建立的,屬於函式區域性變數,那想要獲取到結束時間,只要這個函式退出,StopWatch的解構函式會被自動呼叫,也就是隻需要在解構函式中呼叫獲取結束時間即可。
~StopWatch() {
......
if (stats_enabled_) {
//計算結束時間,並將DB_GET和結束時間新增到直方圖中
statistics_->reportTimeToHistogram(
hist_type_, (elapsed_ != nullptr)
? *elapsed_
: (env_->NowMicros() - start_time_));
}
}
到此已經拿到了Get請求的準確耗時了,簡潔且優雅!!!
接下來就是拿著耗時,和請求型別新增到直方圖中即可。
先看一下直方圖中的原始資料形態:
** Level 0 read latency histogram (micros):
Count: 1805800 Average: 1.4780 StdDev: 8.70
Min: 0 Median: 0.8399 Max: 4026
Percentiles: P50: 0.84 P75: 1.40 P99: 2.32 P99.9: 5.10 P99.99: 9.75
------------------------------------------------------
[ 0, 1 ] 1075022 59.532% 59.532% ############
( 1, 2 ] 706790 39.140% 98.672% ########
( 2, 3 ] 18280 1.012% 99.684%
( 3, 4 ] 2601 0.144% 99.828%
( 4, 6 ] 2357 0.131% 99.958%
......
橫線之上的指標很明顯,之下的指標簡單說一下,它就是我們資料結構選型中的HistogramBucketMapper
資料結構。
在Percentiles
之下 總共有四列(這裡將做括號和右方括號算作一列,是一個hash桶)
- 第一列 : 看作一個hash桶,這個hash桶表示一個耗時區間,單位是us
- 第二列:一秒內產生的請求耗時命中當前耗時區間的有多少個
- 第三列:一秒內產生的請求耗時命中當前耗時區間的個數佔總請求個數的百分比
- 第四列:累加之前所有請求的百分比
耗時打點會簡化輸出如下:
回到我們拿著DB_GET和time 彙報到直方圖中,通過如下函式
virtual void reportTimeToHistogram(uint32_t histogramType, uint64_t time) {
//表示禁止開啟直方圖,也就是上文中說的options.statistics引數未初始化,使用預設的。
if (get_stats_level() <= StatsLevel::kExceptTimers) {
return;
}
// 新增直方圖
recordInHistogram(histogramType, time);
}
最終呼叫到StatisticsImpl::recordInHistogram
函式, 更新直方圖中HistogramStat
資料結構中的各個指標。
void StatisticsImpl::recordInHistogram(uint32_t histogramType, uint64_t value) {
assert(histogramType < HISTOGRAM_ENUM_MAX);
if (get_stats_level() <= StatsLevel::kExceptHistogramOrTimers) {
return;
}
// 將指標新增到histogram_中,並計算該時間在直方圖所屬bucket,將bucket計數自增。
// 除了新增到直方圖bucket,還會更新總時間,總請求數等指標。
per_core_stats_.Access()->histograms_[histogramType].Add(value);
if (stats_ && histogramType < HISTOGRAM_ENUM_MAX) {
// 留給使用者態的介面,如果使用者不繼承實現針對該指標的處理,則不會做任何事情。
stats_->recordInHistogram(histogramType, value);
}
}
如果使用者想要自己做一些請求統計,比如統計總共的打點次數。可以通過如下方式,使用者態繼承statistics類即可:
class DummyOldStats : public Statistics { public: ...... void measureTime(uint32_t /*histogram_type*/, uint64_t /*count*/) override { num_mt++; } ... }
再看一下per_core_stats_.Access()->histograms_[histogramType].Add(value);
中的Add函式,很簡單的指標更新。
這個函式處於所有指標呼叫的必經路徑,可以看到這裡設計的時無鎖方式執行邏輯。也就是認為 併發Get場景下的直方圖更新,其實順序性並沒有那麼重要,因為耗時會置放到它所屬的時間bucket中,請求數自增,並不是嚴格排序方式獲取分位數指標的。
void HistogramStat::Add(uint64_t value) {
// 獲取value-time 以及 耗時時間所處的直方圖索引
// 拿著index,更新對應的buckets的個數
const size_t index = bucketMapper.IndexForValue(value);
assert(index < num_buckets_);
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);
}
到此,一次Get請求的耗時資訊已經新增到了直方圖中。需要注意的是,此時並沒有計算對應的分位數指標,僅僅更新了buckets_。
當呼叫 輸出直方圖的函式時,會進行分位數的計算,DBImpl::PrintStaistics()
函式中
void DBImpl::PrintStatistics() {
auto dbstats = immutable_db_options_.statistics.get();
if (dbstats) {
// 列印直方圖
ROCKS_LOG_INFO(immutable_db_options_.info_log, "STATISTICS:\n %s",
dbstats->ToString().c_str());
}
}
主要是就是直方圖的ToString函式中,計算分位數,並將計算的結果填充到HistogramData
資料結構中,後續直接列印。
std::string StatisticsImpl::ToString() const {
MutexLock lock(&aggregate_lock_);
......
// 列印所有指標的耗時資料
for (const auto& h : HistogramsNameMap) {
assert(h.first < HISTOGRAM_ENUM_MAX);
char buffer[kTmpStrBufferSize];
HistogramData hData;
// 計算分位數
getHistogramImplLocked(h.first)->Data(&hData);
// don't handle failures - buffer should always be big enough and arguments
// should be provided correctly
int ret =
snprintf(buffer, kTmpStrBufferSize,
"%s P50 : %f P95 : %f P99 : %f P100 : %f COUNT : %" PRIu64
" SUM : %" PRIu64 "\n",
h.second.c_str(), hData.median, hData.percentile95,
hData.percentile99, hData.max, hData.count, hData.sum);
if (ret < 0 || ret >= kTmpStrBufferSize) {
assert(false);
continue;
}
res.append(buffer);
}
if (event_tracer_) res.append(event_tracer_->ToString());
res.shrink_to_fit();
return res;
}
其中getHistogramImplLocked(h.first)->Data(&hData);
計算分位數,並將結果新增到HistogramData
資料結構。
void HistogramStat::Data(HistogramData * const data) const {
assert(data);
data->median = Median();
data->percentile95 = Percentile(95);
data->percentile99 = Percentile(99);
data->max = static_cast<double>(max());
data->average = Average();
data->standard_deviation = StandardDeviation();
data->count = num();
data->sum = sum();
data->min = static_cast<double>(min());
}
關於分位數計算 細節可以看 上文中提到的Rocksdb分位數實現連結 。Rocksdb 的優秀程式碼(一) – 工業級分桶演算法實現分位數p50,p99,p9999
請求計數打點
這裡就比較簡單了,針對計數打點,同樣維護了一個直方圖列舉型別:
enum Tickers : uint32_t {
// total block cache misses
// REQUIRES: BLOCK_CACHE_MISS == BLOCK_CACHE_INDEX_MISS +
// BLOCK_CACHE_FILTER_MISS +
// BLOCK_CACHE_DATA_MISS;
BLOCK_CACHE_MISS = 0,
// total block cache hit
// REQUIRES: BLOCK_CACHE_HIT == BLOCK_CACHE_INDEX_HIT +
// BLOCK_CACHE_FILTER_HIT +
// BLOCK_CACHE_DATA_HIT;
BLOCK_CACHE_HIT,
// # of blocks added to block cache.
BLOCK_CACHE_ADD,
......
}
其中BLOCK_CACHE_HIT屬於其中計數型別的一種,主要是在更新Cache的程式碼中使用RecordTick
函式來更新請求計數。
void BlockBasedTable::UpdateCacheHitMetrics(BlockType block_type,
GetContext* get_context,
size_t usage) const {
......
// 命中BlockCache,這裡進行BLOCK_CACHE_HIT 更新指標
// 預設使用RecordTick,即使使用者配置了get_context,也會用RecordTick來更新指標
if (get_context) {
++get_context->get_context_stats_.num_cache_hit;
get_context->get_context_stats_.num_cache_bytes_read += usage;
} else {
RecordTick(statistics, BLOCK_CACHE_HIT);
RecordTick(statistics, BLOCK_CACHE_BYTES_READ, usage);
}
void StatisticsImpl::recordTick(uint32_t tickerType, uint64_t count) {
assert(tickerType < TICKER_ENUM_MAX);
// 對直方圖變數中的型別使用 鬆散記憶體序 進行自增
per_core_stats_.Access()->tickers_[tickerType].fetch_add(
count, std::memory_order_relaxed);
if (stats_ && tickerType < TICKER_ENUM_MAX) {
stats_->recordTick(tickerType, count);
}
}
後續列印的話也是使用類似耗時列印的StatisticsImpl::ToString()
函式進行列印。
以上除了關鍵路徑的耗時以及請求統計,後續的直方圖相關的指標的計算都是通過後臺thread_dump_stats_ 進行非同步更新。
打點總結
Rocksdb 的打點系統 中核心是耗時打點,使用了巧妙的類的構造和析構 完成輕量耗時統計,並通過非同步執行緒完成直方圖的計算和更新。
尤其是分位數的計算,使用hash桶方式僅僅統計 耗時時間段的請求計數 來完成分位數的預估。整個打點系統經歷過大量工業級應用的錘鍊,可以說是非常優雅的系統程式碼設計,值得學習借鑑。