Ceph RBD 效能及 IO 模型統計追蹤功能設計與實現

語言: CN / TW / HK

作者|新浪雲端儲存平臺 - 姚國濤

本篇文章是我們做 RBD 客戶端效能、IO 模式統計功能的設計實現方案,在這裡整理出來,文中內容僅代表個人理解,可能有誤,歡迎大家指正和探討。

背景

對於分散式儲存系統來說,除了資料可靠性、可伸縮性、可維護性等硬指標之外,效能也是一大考量指標。儲存系統的效能指標可以從 Throughput、IOPS、以及 Latency 三方面來衡量。Throughput、IOPS 可以比較直觀的統計出來,但是 Latency 如果以平均值來統計的話,誤差可能就比較大,尤其是對於分散式系統來說,長尾延遲比較明顯,更加劇了這種誤差。

為什麼說平均值誤差可能比較大,因為每次測試結果不總是完全一樣,而是有高低之分,如果高低值的差值很大,比如測試了 10 次,9 次的 latency 是 1ms,1 次是 100ms,latency 的平均值就為 10.9ms,但這個平均值完全沒有反應出測試的真實情況,100ms 的那次資料可能是一個噪點,總之我們需要通過其他方式來儘可能的反應真實的測試情況。

這樣就引入了百分位數統計,也就是我們常見的 P50、P90、P99 等統計結果。以上面的 10 次測試為例,P90 的 latency 為 1ms,我們能夠更準確的看到的絕大部分請求是在 1ms 內完成的,有個別請求延遲較大。

在實際工作中,對於我們的分散式儲存系統,長尾延遲具體情況如何?業務統計的 Latency 和後端儲存的實際 Latency 能否匹配上?

分位數定義

分位數是指用分割點將一個隨機變數的概率分佈範圍分為幾個具有相同概率的連續區間。常用的有中位數(二分位數)、百分位數。

百分位數:將一組資料從小到大排序,並計算相應的累計百分點,則某百分點所對應資料的值,即為這個百分點的百分位數,用 Pk 表示第 k 百分位數。

分位數統計演算法

理論上的百分位計算應該是一個精確的值,比如 90 分位,表示資料經過排序後,90% 位置上的數值。但實際上在大量資料計算時,全部資料排序是非常耗時、低效的。所以百分位統計又分精確計算和類似計算兩種方案。

分位數精確計算

一個比較簡單的實現是,劃定一個固定的時間視窗,比如一分鐘,將這一分鐘的請求響應時間記錄下來,並對其進行排序,計算出每分鐘的百分比資料。這個演算法需要相對多的 CPU 和記憶體成本,在一些比較簡單的場景中使用沒問題,在一些高吞吐、高 IOPS 的場景中,效率就比較低了。

分位數近似計算

分位數近似演算法有很多種,比如 HdrHistogram 演算法、q-digest 演算法、GK 演算法、CKMS 演算法、T-Digest 演算法等,其中 HdrHistogram 演算法和 T-Digest 演算法在軟體系統中使用的比較多,T-Digest 演算法用於 ElasticSearch、Kylin 等系統中,HdrHistogram 的簡化版用於 Prometheus 中。下面我們簡單介紹一下這兩種常用演算法:

靜態分桶

思想:將整個儲存區域以規律性的區間劃分為多個桶,整個規律性的區間可以是線性增長,也可以是指數增長。每個桶只記錄落在該區間的取樣數量,計算分位數時,會假設每個區間也是線性分佈,從而計算出具體的百分位點的數值。這樣通過犧牲小部分精度,達到減小空間佔用,並且統計結果大致準確的結果。

典型的實現是: https://github.com/HdrHistogram/HdrHistogram 。所以後續也稱之為 Histogram 演算法。

缺點:統計範圍有限,需要預先確定,不能改變。

示例:

假設延遲我們的服務響應時間基本在 1ms 到 50ms 之間,我可以把桶數量設定為 5 個,每個桶區間以 10ms 線性增長,就會有如下的桶:

假設第一個請求響應時間為 25ms,上圖中第三個桶中的資料就會累加 1;第二個請求響應時間為 15ms,上圖中第二個桶中資料會累加 1。依次類推,每次請求響應後都會更新上面的桶,桶中資料只做請求數的累加。最終形成如下的桶:

那怎麼計算百分位數值呢?假設計算 P90 的延遲:

  1. 計算請求總量:總的請求數量為 800

  1. 計算第百分位數個請求數:800 * 0.9=720

  1. 計算第 720 個請求所在的桶:處於第 4 個桶中(從小到大依次計算,檢查是否在該桶中)

  1. 計算處於第四個桶的具體位置:第 4 個桶的第 120 個

  1. 將第 4 個桶的區間(30-40)按照該桶的請求數量(120)等分:10/120 = 0.083

  1. 求第 4 個桶第 120 個數的具體耗時:30+0.083*120= 39.96

通過上面的計算 P90 的延遲為 39.96ms。

從上面的理論分析來看,這種演算法的百分位數精準度依賴於對取樣點範圍有一定的瞭解,以及桶數量的選取,桶間距過大的話,而落在該區間的數量又過少,誤差就比較大。桶間距越小,誤差越小,當然帶來的也是 CPU、記憶體成本增大,計算效率降低。

動態分桶

T-Digest 演算法

思想:使用近似演算法 Sketch,也就是素描,用一部分資料來描繪整體資料集的特徵。T-Digest 將資料集進行分組,相鄰的資料為一組,用平均數(mean)和個數(weight)來代替這一組數,我們將這兩個數合稱為質心數(centroid)。T-Digest 演算法會形成如下的質心數:

計算百分位數方法如下:

  1. 根據百分位比 q 和所有資料的總個數計算出第 N 個數為要計算的數

  1. 找出和第 N 個數相鄰的兩個質心數

  1. 根據兩個質心數的平均數(mean)和個數(weight)使用線性插值的方式來計算出百分位數。

從上圖中可以看出,最終百分位數結果的精準性依賴於質心數的個數值,質心數中的個數越多,包含的資料範圍越大,越不精準,但太小的質心數又會引起質心數數量增多,增加 CPU、記憶體成本。T-Digest 通過百分位數來控制質心數代表的資料多少,在首尾兩側,質心數較小,精準度更高,而在中間的質心數則較大,以此達到 1%、99% 這些日常業務中更關注的資料的精確度高的效果。

開源實現為 t-digest: https://github.com/tdunning/t-digest

t-digest 使用了兩種演算法來實現:buffer-and-merge 演算法和 AVL 樹的聚類演算法。

buffer-and-merge 演算法:將取樣資料插入到 tmp buffer 中,當 tmp buffer 滿了或者需要計算百分位數的時候,將 tmp buffer 中的資料和已經 merge 的質心數進行排序合併,生成最新的質心數。合併時如果 weight 超過了上限,就會建立新的質心數,否則只修改當前質心數的平均值和個數。

AVL 樹聚類演算法:和 buffer-and-merge 演算法相比,多了一步通過 AVL 平衡二叉樹搜尋資料最靠近質心數的步驟,也就是取樣資料插入時,就會通過 AVL 演算法搜尋所屬的質心數,並進行 merge。

兩種演算法對比

Histogram 演算法在 Ceph 中的應用

再看 ceph 程式碼,發現 ceph 的 perf counters 也實現了 perf histogram。我們簡單看看 ceph 的 perf histogram 實現:

Ceph 的 PerfHistogram 類實現了 Histogram 演算法,但是標準的 Histogram 演算法的擴充套件,標準的 Histogram 演算法只追蹤一個維度的資料,ceph 的 PerfHistogram 實現了二維的資料追蹤記錄,比如一個維度記錄請求大小,另一個維度記錄處理時間,我們就能清晰的看到某個請求大小的處理時間是多少,這樣就把兩個維度關聯起來。如果我們只關注其中的一個維度,也很簡單,直接把不關注的那個維度所有資料求和即可。

下面我們就以 OSD 相關程式碼為例,看一下 ceph 的 PerfHistogram 的使用方法。

在 src/osd/osd_perf_counters.cc 檔案中,初始化了通過 perf counter 和 perf histogram 追蹤的效能指標。

PerfCounters *build_osd_logger(CephContext *cct) {PerfCountersBuilder osd_plb(cct, "osd", l_osd_first, l_osd_last);
// Latency axis configuration for op histograms, values are in nanosecondsPerfHistogramCommon::axis_config_d op_hist_x_axis_config{"Latency (usec)",PerfHistogramCommon::SCALE_LOG2, ///< Latency in logarithmic scale0, ///< Start at 0100000,///< Quantization unit is 100usec32, ///< Enough to cover much longer than slow requests};
// Op size axis configuration for op histograms, values are in bytesPerfHistogramCommon::axis_config_d op_hist_y_axis_config{"Request size (bytes)",PerfHistogramCommon::SCALE_LOG2, ///< Request size in logarithmic scale0, ///< Start at 0512, ///< Quantization unit is 512 bytes32, ///< Enough to cover requests larger than GB};
...
osd_plb.add_u64_counter_histogram(l_osd_op_r_lat_outb_hist,"op_r_latency_out_bytes_histogram",op_hist_x_axis_config, op_hist_y_axis_config,"Histogram of operation latency (including queue time) + data read");
osd_plb.add_u64_counter_histogram(l_osd_op_w_lat_inb_hist,"op_w_latency_in_bytes_histogram",op_hist_x_axis_config, op_hist_y_axis_config,"Histogram of operation latency (including queue time) + data written");...}

複製程式碼

首先,分別定義了 X 軸、Y 軸,按照 axis_config_d 結構體中成員變數的初始化順序,座標軸的相關資訊包含:座標軸、座標值增長演算法、起始座標值、座標值單元、座標值數量。

上面程式碼中的 op_hist_x_axis_config,定義了 X 軸,記錄的是延遲資料,座標值增長演算法以指數增長,最小延遲為 0,座標增長單元為 100us,一共有 32 個座標值。

op_his_y_axis_config 定義了 Y 軸,記錄的是請求大小,也是成對數級增長,最小請求為 0,座標增長單位為 512 位元組,一共有 32 個座標值。

Ceph perf histogram 提供兩種資料增長演算法:Linear 和 Log2,Linear 是線性增長,適合對百分位數精度要求比較高,而且資料範圍比較小的場景。Log2 是指數增長,適合對百分位數精度要求相對低,而且總的資料範圍跨度較大的場景。當然精度大小還依賴於座標增長單元。

然後通過 add_u64_counter_histogram 函式將統計項(l_osd_op_r_lat_outb_hist、l_osd_op_w_lat_inb_hist 此類統計指標)加入到 PerfCounters 例項中,後續就可以更新該指標的具體數值了。

在 PrimaryLogPG 類的 log_op_stats 函式中,更新了這些指標的數值:

void PrimaryLogPG::log_op_stats(const OpRequest& op,const uint64_t inb,const uint64_t outb){auto m = op.get_req<MOSDOp>();const utime_t now = ceph_clock_now();
const utime_t latency = now - m->get_recv_stamp();const utime_t process_latency = now - op.get_dequeued_time();
...else if (op.may_read()) {osd->logger->inc(l_osd_op_r);osd->logger->inc(l_osd_op_r_outb, outb);osd->logger->tinc(l_osd_op_r_lat, latency);osd->logger->hinc(l_osd_op_r_lat_outb_hist, latency.to_nsec(), outb);osd->logger->tinc(l_osd_op_r_process_lat, process_latency);} else if (op.may_write() || op.may_cache()) {osd->logger->inc(l_osd_op_w);osd->logger->inc(l_osd_op_w_inb, inb);osd->logger->tinc(l_osd_op_w_lat, latency);osd->logger->hinc(l_osd_op_w_lat_inb_hist, latency.to_nsec(), inb);osd->logger->tinc(l_osd_op_w_process_lat, process_latency);}...}

複製程式碼

在上面的程式碼中,PrimaryLogPG::log_op_stats 函式是 osd 中請求處理完成後回撥到的,如果是讀請求,使用 PerfCounters::hinc 函式更新 l_osd_op_r_lat_outb_hist 指標的延遲,同時還傳了讀請求大小的引數。

上面就是 ceph perf histogram 的使用方法。

我們繼續跟一下 PerfCounters::hinc 的實現,具體看看 Histogram 演算法實現。hinc 函式具體實現是在 PerfHistogram::inc 函式實現:

/// Increase counter for given axis values by onetemplate <typename... T>void inc(T... axis) {auto index = get_raw_index_for_value(axis...);m_rawData[index]++;}
/// Calculate m_rawData index from axis valuestemplate <typename... T>int64_t get_raw_index_for_value(T... axes) const {static_assert(sizeof...(T) == DIM, "Incorrect number of arguments");return get_raw_index_internal<0>(get_bucket_for_axis, 0, axes...);}
template <int level = 0, typename F, typename... T>int64_t get_raw_index_internal(F bucket_evaluator, int64_t startIndex,int64_t value, T... tail) const {static_assert(level + 1 + sizeof...(T) == DIM,"Internal consistency check");auto ∾ = m_axes_config[level];auto bucket = bucket_evaluator(value, ac);return get_raw_index_internal<level + 1>(bucket_evaluator, ac.m_buckets * startIndex + bucket, tail...);}
template <int level, typename F>int64_t get_raw_index_internal(F, int64_t startIndex) const {static_assert(level == DIM, "Internal consistency check");return startIndex;}

複製程式碼

上面的程式碼看著是不是有些晦澀?其實 inc 函式目的就是根據當前資料找到對應的直方圖 bucket,並對這個 bucket 的 count 數累加。只不過這裡使用了 C++11 的特性 -- 可變模版引數,它對引數進行了高度泛化,能表示 0 到任意個數、任意型別的引數。

這裡不針對可變模版引數的詳細的展開描述,感興趣的同學自行搜尋學習。我們只結合上面的程式碼看看可變模版引數怎麼使用。使用可變模版引數的關鍵是如何展開引數包,程式碼中使用了可變模版引數的函式,採用遞迴的方式展開引數包,需要一個引數包展開的函式(第一個 get_raw_index_internal 函式就是展開函式)和一個遞迴終止函式用來終止遞迴(第二個 get_raw_index_internal 函式就是遞迴終止函式)。

第一個 get_raw_index_internal 函式會按 tail 引數包的順序逐個遞迴呼叫自己,每呼叫一次,引數包 tail 中的引數就會少一個,直到所有 tail 引數包沒有引數,此時就呼叫到了第二個 get_raw_index_internal 函式返回,並終止遞迴過程。

在當前的程式碼場景中,引數包中包含兩個引數:x、y 兩個數值,分別代表 latency、request size。第一個 get_raw_index_internal 函式的形參 value 就是可變引數展開後的具體引數值,呼叫過程如下:

get_raw_index_internal(get_bucket_for_axis(latency, x_config), 0, latency, request_size)

get_raw_index_internal(get_bucket_for_axis(latency, x_config), startIndex, request_size)

get_raw_index_internal(get_bucket_for_axis(latency, x_config), startIndex)

通過上面的呼叫,最終計算出此刻的(latency,request_size)對應的 buckets 索引。

在這裡還有一個 static_assert 函式,是靜態斷言,在編譯期間進行斷言,能夠在編譯期間發現錯誤,終止編譯。另外,sizeof...(T) 計算的是可變引數的個數。

RBD 效能資料統計追蹤現狀

經過上面的分析,我們搞清楚了 ceph perf histogram 的使用方法。但是 librbd 程式碼中,librbd 目前只使用了 perf counter 追蹤了效能資料,比如 latency 只有平均值。

另外,現在的 ceph-mgr prometheus 模組收集到了 rbd 的效能資料,而且通過 rbd perf image iostat 也可以看到 image 的效能資料,包括讀寫 IOPS、讀寫吞吐、讀寫延遲。但是我們發現這個讀寫延遲和應用程式看到的 latency 相差挺多。

我們大致看看這套 rbd client 效能資料怎麼拿到的?

在 rbd image 效能收集、計算方面,主要涉及 OSD、MGR 兩大模組,OSD 類在建構函式中例項化了 MgrClient,然後在 init 函式中註冊了兩個函式:set_perf_queries 函式和 get_perf_reports 函式,set_perf_queries 函式是設定 perf 指標,get_perf_reports 是獲取 perf 資料,這兩個函式後續都會在 Mgr cleint 中呼叫的。

Mgr client 有定時器呼叫 send_report 函式發給 mgr server,send_report 就會呼叫前面的註冊函式 get_perf_reports 來收集 osd 效能資料。Osd 的效能資料最終會從 PrimaryLogPG::log_op_stats 函式獲取資料,這個函式前面提過,就不贅述了。

Mgr server 收到 mgr client 報告的效能後,按 perf 指標進行分類,儲存在記憶體結構 Counters 中。

當使用命令列 rbd perf image iostat 檢視 rbd 的效能資料時,rbd 程序會通過 mgr client 將命令請求到 mgr server,mgr server 的 rbd_support 模組來處理該命令,它會從 mgr server 上獲取當前的 Counters 結構,解析出資料後返回給 client。

Mgr prometheus 模組是一個 prometheus exporter,也是定時收集資料,處理 rbd perf 資料時,和 rbd_support 基本一致,也是從 mgr server 解析當前的 Counters 結構。

看到這裡,我們還是有疑問,osd 收集到的資料是具體 op 的效能資料,怎麼和具體的 rbd image 關聯起來。是因為 osd 收集到 op 的資料時,包含了 object id,而 object id 就是按 rbd image 進行區分的,這樣一來,只需要在 rbd_support 或 prometheus 來對 osd 的資料按照 rbd image 進行分類解析,從而形成具體 rbd image 的效能資料。

到這裡也就知道上層業務監控的 latency 和我們的 latency 有不小的差距的問題所在了,是因為當前在 ceph prometheus 中看到的只是 osd 處理過程的 latency。

RBD 效能及 IO 模型統計功能改造

既然當前的 mgr prometheus 監控的 rbd perf 資料不是 ceph 全 IO 路徑的資料,也沒有我們更關注的百分位數資料,那我們就用 ceph perf histogram 來追蹤統計 rbd 效能資料,而且除此之外,是不是還可以統計到一些 IO 模式相關的資料。

現在再來梳理一下,我們想從 rbd 中拿到的相關指標,我們分為兩類:

效能資料類:

  1. IOPS、Throughput(區分讀寫);

  1. 具體請求大小的處理時間及其百分位資料(比如:4K 大小的請求延遲情況);

  1. 整體請求的處理時間及其百分位資料。

IO 模式類:

  1. 請求大小及其百分位資料;

  1. 讀寫位置及其百分位資料;

  1. 讀寫比例如何。

通過前面所有的分析,基本的架構也比較清楚了,我們採用如下的方式來進行監控:

在 librbd 程式碼中,我們追蹤兩個二維的 histogram 資料,一個是 latency 和 request size,另一個是 offset 和 request size。當前的 librbd 程式碼中沒有記錄 request size 和 offset 的資訊,我在 AioCompletion 類中增加了 offset 和 request size 兩個變數,在 read、write 等介面建立 AioCompletion 回撥類後,使用 set 方法設定這兩個變數,最後在 IO 完成後,回撥 AioCompletion::complete 函式的時候,根據讀寫型別,分別通過 PerfCounters::hinc 函式來更新統計資料。

在宿主機上部署 prometheus exporter,該 exporter 負責如下幾件事:

  1. 根據宿主機上所有的 ceph client admin socket 檔案,來獲取其 perf histogram 資料;

  1. 實現了通過 Histogram 計算百分位數的演算法,將 ceph perf histogram 吐出的資料計算成我們關注的百分位數。該演算法參考的是 Prometheus 的 Histogram 函式,其是基於靜態桶的實現,實現起來比較簡單。

總結

我們希望通過 rbd client 端的效能資料的統計,瞭解我們的系統所能提供的能力,同時也為未來系統優化的方向提供資料支撐。通過 IO 模式資料的統計,來了解我們業務的 IO 模式,以此作為參考提供更優的儲存方案。

參考連結:

https://blog.bcmeng.com/post/tdigest.html

https://caorong.github.io/2020/08/03/quartile-%20algorithm/

https://cloud.tencent.com/developer/article/1815080