TiKV 原始碼解析系列文章(三)Prometheus(上)

PingCAP發表於2019-03-11

作者:Breezewish

本文為 TiKV 原始碼解析系列的第三篇,繼續為大家介紹 TiKV 依賴的周邊庫 rust-prometheus,本篇主要介紹基礎知識以及最基本的幾個指標的內部工作機制,下篇會介紹一些高階功能的實現原理。

rust-prometheus 是監控系統 Prometheus 的 Rust 客戶端庫,由 TiKV 團隊實現。TiKV 使用 rust-prometheus 收集各種指標(metric)到 Prometheus 中,從而後續能再利用 Grafana 等視覺化工具將其展示出來作為儀表盤監控皮膚。這些監控指標對於瞭解 TiKV 當前或歷史的狀態具有非常關鍵的作用。TiKV 提供了豐富的監控指標資料,並且程式碼中也到處穿插了監控指標的收集片段,因此瞭解 rust-prometheus 很有必要。

感興趣的小夥伴還可以觀看我司同學在 FOSDEM 2019 會議上關於 rust-prometheus 的技術分享

基礎知識

指標類別

Prometheus 支援四種指標:Counter、Gauge、Histogram、Summary。rust-prometheus 庫目前還只實現了前三種。TiKV 大部分指標都是 Counter 和 Histogram,少部分是 Gauge。

Counter

Counter 是最簡單、常用的指標,適用於各種計數、累計的指標,要求單調遞增。Counter 指標提供基本的 inc()inc_by(x) 介面,代表增加計數值。

在視覺化的時候,此類指標一般會展示為各個時間內增加了多少,而不是各個時間計數器值是多少。例如 TiKV 收到的請求數量就是一種 Counter 指標,在監控上展示為 TiKV 每時每刻收到的請求數量圖表(QPS)。

Gauge

Gauge 適用於上下波動的指標。Gauge 指標提供 inc()dec()add(x)sub(x)set(x) 介面,都是用於更新指標值。

這類指標視覺化的時候,一般就是直接按照時間展示它的值,從而展示出這個指標按時間是如何變化的。例如 TiKV 佔用的 CPU 率是一種 Gauge 指標,在監控上所展示的直接就是 CPU 率的上下波動圖表。

Histogram

Histogram 即直方圖,是一種相對複雜但同時也很強大的指標。Histogram 除了基本的計數以外,還能計算分位數。Histogram 指標提供 observe(x) 介面,代表觀測到了某個值。

舉例來說,TiKV 收到請求後處理的耗時就是一種 Histogram 指標,通過 Histogram 型別指標,監控上可以觀察 99%、99.9%、平均請求耗時等。這裡顯然不能用一個 Counter 儲存耗時指標,否則展示出來的只是每時每刻中 TiKV 一共花了多久處理,而非單個請求處理的耗時情況。當然,機智的你可能想到了可以另外開一個 Counter 儲存請求數量指標,這樣累計請求處理時間除以請求數量就是各個時刻平均請求耗時了。

實際上,這也正是 Prometheus 中 Histogram 的內部工作原理。Histogram 指標實際上最終會提供一系列時序資料:

  • 觀測值落在各個桶(bucket)上的累計數量,如落在 (-∞, 0.1](-∞, 0.2](-∞, 0.4](-∞, 0.8](-∞, 1.6](-∞, +∞) 各個區間上的數量。
  • 觀測值的累積和。
  • 觀測值的個數。

bucket 是 Prometheus 對於 Histogram 觀測值的一種簡化處理方式。Prometheus 並不會具體記錄下每個觀測值,而是隻記錄落在配置的各個 bucket 區間上的觀測值的數量,這樣以犧牲一部分精度的代價大大提高了效率。

Summary

SummaryHistogram 類似,針對觀測值進行取樣,但分位數是在客戶端進行計算。該型別的指標目前在 rust-prometheus 中沒有實現,因此這裡不作進一步詳細介紹。大家可以閱讀 Prometheus 官方文件中的介紹瞭解詳細情況。感興趣的同學也可以參考其他語言 Client Library 的實現為 rust-prometheus 貢獻程式碼。

標籤

Prometheus 的每個指標支援定義和指定若干組標籤(Label),指標的每個標籤值獨立計數,表現了指標的不同維度。例如,對於一個統計 HTTP 服務請求耗時的 Histogram 指標來說,可以定義並指定諸如 HTTP Method(GET / POST / PUT / ...)、服務 URL、客戶端 IP 等標籤。這樣可以輕易滿足以下型別的查詢:

  • 查詢 Method 分別為 POST、PUT、GET 的 99.9% 耗時(利用單一 Label)
  • 查詢 POST /api 的平均耗時(利用多個 Label 組合)

普通的查詢諸如所有請求 99.9% 耗時也能正常工作。

需要注意的是,不同標籤值都是一個獨立計數的時間序列,因此應當避免標籤值或標籤數量過多,否則實際上客戶端會向 Prometheus 服務端傳遞大量指標,影響效率。

與 Prometheus Golang client 類似,在 rust-prometheus 中,具有標籤的指標被稱為 Metric Vector。例如 Histogram 指標對應的資料型別是 Histogram,而具有標籤的 Histogram 指標對應的資料型別是 HistogramVec。對於一個 HistogramVec,提供它的各個標籤取值後,可獲得一個 Histogram 例項。不同標籤取值會獲得不同的 Histogram 例項,各個 Histogram 例項獨立計數。

基本用法

本節主要介紹如何在專案中使用 rust-prometheus 進行各種指標收集。使用基本分為三步:

  1. 定義想要收集的指標。

  2. 在程式碼特定位置呼叫指標提供的介面收集記錄指標值。

  3. 實現 HTTP Pull Service 使得 Prometheus 可以定期訪問收集到的指標,或使用 rust-prometheus 提供的 Push 功能定期將收集到的指標上傳到 Pushgateway

注意,以下樣例程式碼都是基於本文釋出時最新的 rust-prometheus 0.5 版本 API。我們目前正在設計並實現 1.0 版本,使用上會進一步簡化,但以下樣例程式碼可能在 1.0 版本釋出後過時、不再工作,屆時請讀者參考最新的文件。

定義指標

為了簡化使用,一般將指標宣告為一個全域性可訪問的變數,從而能在程式碼各處自由地操縱它。rust-prometheus 提供的各個指標(包括 Metric Vector)都滿足 Send + Sync,可以被安全地全域性共享。

以下樣例程式碼藉助 lazy_static 庫定義了一個全域性的 Histogram 指標,該指標代表 HTTP 請求耗時,並且具有一個標籤名為 method

#[macro_use]
extern crate prometheus;

lazy_static! {
   static ref REQUEST_DURATION: HistogramVec = register_histogram_vec!(
       "http_requests_duration",
       "Histogram of HTTP request duration in seconds",
       &["method"],
       exponential_buckets(0.005, 2.0, 20).unwrap()
   ).unwrap();
}
複製程式碼

記錄指標值

有了一個全域性可訪問的指標變數後,就可以在程式碼中通過它提供的介面記錄指標值了。在“基礎知識”中介紹過,Histogram 最主要的介面是 observe(x),可以記錄一個觀測值。若想了解 Histogram 其他介面或其他型別指標提供的介面,可以參閱 rust-prometheus 文件

以下樣例在上段程式碼基礎上展示瞭如何記錄指標值。程式碼模擬了一些隨機值用作指標,裝作是使用者產生的。在實際程式中,這些當然得改成真實資料 :)

fn thread_simulate_requests() {
   let mut rng = rand::thread_rng();
   loop {
       // Simulate duration 0s ~ 2s
       let duration = rng.gen_range(0f64, 2f64);
       // Simulate HTTP method
       let method = ["GET", "POST", "PUT", "DELETE"].choose(&mut rng).unwrap();
       // Record metrics
       REQUEST_DURATION.with_label_values(&[method]).observe(duration);
       // One request per second
       std::thread::sleep(std::time::Duration::from_secs(1));
   }
}
複製程式碼

Push / Pull

到目前為止,程式碼還僅僅是將指標記錄了下來。最後還需要讓 Prometheus 服務端能獲取到記錄下來的指標資料。這裡一般有兩種方式,分別是 Push 和 Pull。

  • Pull 是 Prometheus 標準的獲取指標方式,Prometheus Server 通過定期訪問應用程式提供的 HTTP 介面獲取指標資料。
  • Push 是基於 Prometheus Pushgateway 服務提供的另一種獲取指標方式,指標資料由應用程式主動定期推送給 Pushgateway,然後 Prometheus 再定期從 Pushgateway 獲取。這種方式主要適用於應用程式不方便開埠或應用程式生命週期比較短的場景。

以下樣例程式碼基於 hyper HTTP 庫實現了一個可以供 Prometheus Server pull 指標資料的介面,核心是使用 rust-prometheus 提供的 TextEncoder 將所有指標資料序列化供 Prometheus 解析:

fn metric_service(_req: Request<Body>) -> Response<Body> {
   let encoder = TextEncoder::new();
   let mut buffer = vec![];
   let mf = prometheus::gather();
   encoder.encode(&mf, &mut buffer).unwrap();
   Response::builder()
       .header(hyper::header::CONTENT_TYPE, encoder.format_type())
       .body(Body::from(buffer))
       .unwrap()
}
複製程式碼

對於如何使用 Push 感興趣的同學可以自行參考 rust-prometheus 程式碼內提供的 Push 示例,這裡限於篇幅就不詳細介紹了。

上述三段樣例的完整程式碼可參見這裡

內部實現

以下內部實現都基於本文釋出時最新的 rust-prometheus 0.5 版本程式碼,該版本主幹 API 的設計和實現 port 自 Prometheus Golang client,但為 Rust 的使用習慣進行了一些修改,因此介面上與 Golang client 比較接近。

目前我們正在開發 1.0 版本,API 設計上不再主要參考 Golang client,而是力求提供對 Rust 使用者最友好、簡潔的 API。實現上為了效率考慮也會和這裡講解的略微有一些出入,且會去除一些目前已被拋棄的特性支援,簡化實現,因此請讀者注意甄別。

Counter / Gauge

Counter 與 Gauge 是非常簡單的指標,只要支援執行緒安全的數值更新即可。讀者可以簡單地認為 Counter 和 Gauge 的核心實現都是 Arc<Atomic>。但由於 Prometheus 官方規定指標數值需要支援浮點數,因此我們基於 std::sync::atomic::AtomicU64 和 CAS 操作實現了 AtomicF64,其具體實現位於 src/atomic64/nightly.rs。核心片段如下:

impl Atomic for AtomicF64 {
   type T = f64;

   // Some functions are omitted.

   fn inc_by(&self, delta: Self::T) {
       loop {
           let current = self.inner.load(Ordering::Acquire);
           let new = u64_to_f64(current) + delta;
           let swapped = self
               .inner
               .compare_and_swap(current, f64_to_u64(new), Ordering::Release);
           if swapped == current {
               return;
           }
       }
   }
}
複製程式碼

另外由於 0.5 版本釋出時 AtomicU64 仍然是一個 nightly 特性,因此為了支援 Stable Rust,我們還基於自旋鎖提供了 AtomicF64 的 fallback,位於 src/atomic64/fallback.rs

注:AtomicU64 所需的 integer_atomics 特性最近已在 rustc 1.34.0 stabilize。我們將在 rustc 1.34.0 釋出後為 Stable Rust 也使用上原生的原子操作從而提高效率。

Histogram

根據 Prometheus 的要求,Histogram 需要進行的操作是在獲得一個觀測值以後,為觀測值處在的桶增加計數值。另外還有總觀測值、觀測值數量需要累加。

注意,Prometheus 中的 Histogram 是累積直方圖,其每個桶的含義是 (-∞, x],因此對於每個觀測值都可能要更新多個連續的桶。例如,假設使用者定義了 5 個桶邊界,分別是 0.1、0.2、0.4、0.8、1.6,則每個桶對應的數值範圍是 (-∞, 0.1](-∞, 0.2](-∞, 0.4](-∞, 0.8](-∞, 1.6](-∞, +∞),對於觀測值 0.4 來說需要更新(-∞, 0.4](-∞, 0.8](-∞, 1.6](-∞, +∞) 四個桶。

一般來說 observe(x) 會被頻繁地呼叫,而將收集到的資料反饋給 Prometheus 則是個相對很低頻率的操作,因此用陣列實現“桶”的時候,我們並不將各個桶與陣列元素直接對應,而將陣列元素定義為非累積的桶,如 (-∞, 0.1)[0.1, 0.2)[0.2, 0.4)[0.4, 0.8)[0.8, 1.6)[1.6, +∞),這樣就大大減少了需要頻繁更新的資料量;最後在上報資料給 Prometheus 的時候將陣列元素累積,得到累積直方圖,這樣就得到了 Prometheus 所需要的桶的資料。

當然,由此可見,如果給定的觀測值超出了桶的範圍,則最終記錄下的最大值只有桶的上界了,然而這並不是實際的最大值,因此使用的時候需要多加註意。

Histogram 的核心實現見 src/histogram.rs

pub struct HistogramCore {
   // Some fields are omitted.
   sum: AtomicF64,
   count: AtomicU64,
   upper_bounds: Vec<f64>,
   counts: Vec<AtomicU64>,
}

impl HistogramCore {
   // Some functions are omitted.

   pub fn observe(&self, v: f64) {
       // Try find the bucket.
       let mut iter = self
           .upper_bounds
           .iter()
           .enumerate()
           .filter(|&(_, f)| v <= *f);
       if let Some((i, _)) = iter.next() {
           self.counts[i].inc_by(1);
       }

       self.count.inc_by(1);
       self.sum.inc_by(v);
   }
}

#[derive(Clone)]
pub struct Histogram {
   core: Arc<HistogramCore>,
}
複製程式碼

Histogram 還提供了一個輔助結構 HistogramTimer,它會記錄從它建立直到被 Drop 的時候的耗時,將這個耗時作為 Histogram::observe() 介面的觀測值記錄下來,這樣很多時候在想要記錄 Duration / Elapsed Time 的場景中,就可以使用這個簡便的結構來記錄時間:

#[must_use]
pub struct HistogramTimer {
   histogram: Histogram,
   start: Instant,
}

impl HistogramTimer {
   // Some functions are omitted.

   pub fn observe_duration(self) {
       drop(self);
   }

   fn observe(&mut self) {
       let v = duration_to_seconds(self.start.elapsed());
       self.histogram.observe(v)
   }
}

impl Drop for HistogramTimer {
   fn drop(&mut self) {
       self.observe();
   }
}
複製程式碼

HistogramTimer 被標記為了 must_use,原因很簡單,作為一個記錄流逝時間的結構,它應該被存在某個變數裡,從而記錄這個變數所處作用域的耗時(或稍後直接呼叫相關函式提前記錄耗時),而不應該作為一個未使用的臨時變數被立即 Drop。標記為 must_use 可以在編譯期杜絕這種明顯的使用錯誤。

TiKV 原始碼解析系列文章(三)Prometheus(上)

相關文章