為什麼從 Metrics 談起#

監控(Monitoring)是橫跨多個世代、用來確保系統穩定運作的維運技術,而 Metrics 一直都是它的核心關注。對 Observability 來說,Metrics 同樣是不可或缺的基石。

特別是在實踐 SRE(Site Reliability Engineering)時,常見的概念都圍繞著 Metrics 在打轉:

  • SLI(Service-Level Indicator):服務水準指標。
  • SLO(Service-Level Objective):服務水準目標。
  • SLA(Service-Level Agreement):服務水準協議。

這些概念協助我們定義「什麼是重要的指標」、「指標要達到什麼水準才算合格」、「怎麼對指標做出承諾」。彼得・杜拉克的名言「If you can’t measure it, you can’t manage it」放在這裡再貼切不過。

一個指標包含哪些要素#

以「量體溫」這個動作為例,思考指標通常需要包含哪些元素:

  • 指標名稱:例如「體溫」。
  • 數值:實際量到的數字。
  • 時間:什麼時候量的;同一天可能會多次測量。
  • 補充資訊:被測量者的姓名、組別等屬性。

把多次量測串起來,就能得到「跨時間」的體溫變化。這種隨時間累積的資料就是時間序列資料(Time Series Data)。

時間序列資料通常會存放在專為它設計的時序資料庫(Time Series Database, TSDB)中。相較於一般資料庫,TSDB 針對寫入與時間範圍查詢進行了優化,因為這類資料幾乎不會被修改,主要的存取行為就是「寫入」與「依時間範圍讀取」。

Prometheus Metrics 的四個要素#

本系列在討論 Metrics 時主要以 Prometheus Metrics 為例。一筆 Prometheus 指標由四個部分組成:

  • 指標名稱:說明指標內容,常見會把單位帶進名稱中,例如 process_timememory_usage_byte
  • 時間:以 Unix timestamp 表示,單位為毫秒。
  • 數值:以 Float64 表示。
  • 補充資訊:多組 Key-Value Pair Label,例如 os=ubuntuip=1.1.1.1

應用程式可透過 Prometheus Client Library 自行生成 Metrics;對於沒辦法直接埋點的服務或機器,可以使用對應的 Exporter 採集後轉成 Prometheus 格式。

收集則分為 Pull Model(Prometheus 定期抓取 HTTP Endpoint)與 Push Model(透過 Pushgateway 中介),詳細運作與儲存、查詢部分留到下一章 Prometheus 中說明。

Cardinality 與 Label 設計#

前一節提到 Prometheus 指標可以掛上多組 Label。Label 是 Metrics 的維度(Dimension),讓你可以依 osmethodstatus_code 切片查詢。但 Label 也是 Metrics 系統最容易「爆炸」的地方,根源在於一個概念:基數(Cardinality)。

在時序資料庫(TSDB)的世界裡,每一組唯一的「指標名稱 + Label 值組合」就是一條獨立的時間序列(Time Series)。系統的負擔不是看你打了多少個 Metrics,而是看你產生了多少條時間序列:

$$ \text{時間序列總數} = \sum_{i}\left(\text{指標}_i \text{的所有 Label 值組合數}\right) $$

對單一指標而言,若各 Label 的取值彼此獨立,組合數近似於各 Label 基數的乘積:

$$ N_{\text{series}} \approx \prod_{j=1}^{k} |L_j| $$

其中 $|L_j|$ 是第 $j$ 個 Label 的不同取值數量。乘法的可怕之處在於,多加一個高基數 Label,序列數不是加上去、而是乘上去。

一個會爆炸的例子#

假設一個 HTTP 服務的請求數指標 http_requests_total,原本掛三個低基數 Label:

Label取值數量說明
method5GET / POST / PUT / DELETE …
status10200 / 404 / 500 …
endpoint20路由樣板(已正規化)

此時序列數約為 $5 \times 10 \times 20 = 1{,}000$ 條,完全在可控範圍。

現在有人為了「方便追查特定使用者」,加上一個 user_id Label,而系統有 100 萬名活躍使用者:

$$ 5 \times 10 \times 20 \times 1{,}000{,}000 = 10^{9} \text{ 條時間序列} $$

一個原本只有一千條序列的指標,瞬間膨脹到十億條。這就是典型的基數爆炸(Cardinality Explosion)

警告:永遠不要把無界(Unbounded)或高基數的識別碼放進 Label。 最常見的三大地雷是 user_idrequest_idtrace_id——它們的取值幾乎等於請求數,等於把「每一筆請求」都變成一條獨立序列。 後果是 TSDB 的記憶體與磁碟被序列數量拖垮,查詢與告警評估全面變慢,最終整個 Metrics 後端可能 OOM 崩潰。

高基數的三重成本#

高基數 Label 同時在三個維度上付出代價:

  • 儲存成本:每條序列都需要獨立的索引與資料區塊(Chunk),序列數量直接決定磁碟用量。
  • 查詢成本:PromQL 查詢需要先比對 Label 索引找出符合的序列,序列越多、索引越大,查詢越慢。
  • 記憶體成本:Prometheus 對活躍序列(Active Series)維護記憶體中的索引與 Head Block,這是基數爆炸最先壓垮的環節,常以 OOM 收場。

什麼算「高基數」#

基數沒有絕對門檻,但有實用的經驗線:

  • 低基數(安全):取值穩定且有界,通常數十個以內,例如 methodstatusregion
  • 中基數(留意):數百到一兩千,例如正規化後的 endpointpod 名稱——可接受,但要留意它與其他 Label 相乘的結果。
  • 高基數(危險):約 >100–1000 個取值,且會隨流量持續增長而沒有上界,例如 user_idemailsession_idrequest_idtrace_id、完整 URL(含 query string)。

判斷一個 Label 該不該存在,可以走這條決策路徑:

flowchart TD
    A["想新增一個 Label"] --> B{"取值是否有界?<br/>(能列舉、不隨流量無限成長)"}
    B -- "否(無界)" --> X["不要放進 Label<br/>改用 Log 欄位或 Trace 屬性"]
    B -- "是(有界)" --> C{"取值數量大致 < 100~1000?"}
    C -- "否" --> X
    C -- "是" --> D{"乘上既有 Label 後<br/>序列總數仍可接受?"}
    D -- "否" --> X
    D -- "是" --> Y["可以作為 Label"]
    X --> Z["若需要靠它跳轉到單筆請求,<br/>改用 Exemplar 橋接 trace_id"]

用 Exemplar 橋接,而不是把 trace_id 變成 Label#

最常見的需求是:「我看到延遲尖峰,想直接跳到那筆慢請求的 Trace。」直覺做法是把 trace_id 加進 Label——但前面已經說明,這會直接引爆基數。

正確做法是使用 Exemplar。Exemplar 是附加在某個指標樣本上的「範例參照」,典型用途就是夾帶一筆代表性請求的 trace_id。它不會成為時間序列的一部分,因此不增加基數,卻能讓你從 Metrics 圖表上的某個資料點,一鍵跳轉到對應的 Trace。

  • trace_id 當 Label:序列數 × 請求數,基數爆炸。
  • trace_id 當 Exemplar:序列數不變,仍可從圖上點進該筆 Trace。

這正是 Metrics 與 Traces 之間的橋樑,Histogram 類型的指標(如延遲分佈)特別適合掛 Exemplar。對應的查詢與關聯細節,留待 05 應用篇的 Signal Correlation 展開。

下面用 PromQL 觀察自己系統的基數狀況,這是排查 Metrics 後端壓力時的常備工具:

# 每個指標名稱目前各有多少條時間序列(找出膨脹的「重災區」指標)
topk(10, count by (__name__)({__name__=~".+"}))

# 估算某個指標掛了哪些 Label、各自的基數有多大
count by (endpoint) (http_requests_total)

# 全系統活躍序列總數(Prometheus 自我監控指標)
prometheus_tsdb_head_series

# 對 Histogram 指標查詢時帶出 Exemplar(trace_id 即藏在其中)
# 需在 Grafana 面板開啟 Exemplars,或 API 帶上 ?exemplars=true
histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))
延伸:高基數真的完全不能要嗎?

並非所有高基數需求都該被否決,而是該被「放對地方」:

  • 如果你需要依高基數維度(如 user_id)做聚合分析,但不需要每個值各一條長期序列,可考慮支援高基數的後端或事件型分析系統,而非塞進 Prometheus Label。
  • 如果你需要的是單筆請求的可追溯性,那它本來就屬於 Logs(結構化欄位)或 Traces(Span 屬性)的職責範圍,用 Exemplar 把三者串起來即可。
  • Mimir / Cortex / Thanos 等水平擴展方案能撐住更高的序列規模,但它們解決的是「容量」問題,不會讓糟糕的 Label 設計變正確——基數紀律仍是第一道防線。

換句話說:高基數資訊不是不能存,而是不該存在「會被乘進時間序列總數」的 Label 維度上。

小結#

本章只是把 Metrics 與 Prometheus 的整體輪廓拉出來。後續章節會逐一深入:

  • Prometheus 本身的設計與 PromQL。
  • 各式 Exporter 怎麼補齊「沒辦法自己埋點」的監控對象。
  • Mimir / Cortex / Thanos 等長期儲存方案。
  • 兩個在 Metrics 領域深耕已久的工具:StatsD 與 Zabbix。

原文出處#

  • 原書/iThome:https://ithelp.ithome.com.tw/articles/10321021