Prometheus 的資料模型只有一句話:
一條時序 = 指標名稱 + 一組 label 的每一個唯一組合。
這句話看起來平淡無奇,但它是整章的起點——因為「每一個唯一組合」是乘法。上一章說過高基數的東西不能放進 metrics 的 label,這一章要把那句規則變成具體的數字,以及一場把 Prometheus 從 4GB 吃到 30GB 的事故。
而在那之前,還有幾個必須先弄對的基礎,因為它們每一個都有一個很常見的誤用:
counter 與 gauge 的差別大家都知道,但
summary 與 histogram 選錯,會讓你的 p99 從此無法跨機器聚合(ch01 那個「分位數不能平均」的伏筆)。_total、_seconds、le 這些後綴不是風格問題,PromQL 的函式會依賴它們。sample_limit 與 metric_relabel_configs。這一章的乘法思維,跟 db ch03 的 JOIN 是同一種:成本不是「多加一個東西」,而是「乘上一個係數」。認得出這個形狀,就能在寫下那行埋點程式碼的當下,預見它三週後的樣子。
Prometheus 儲存的東西只有一種:時序(time series)。而一條時序的身分由這個組合唯一決定:
指標名稱{label1="值", label2="值", ...}
http_requests_total{method="GET", status="200", service="order"}
http_requests_total{method="GET", status="500", service="order"}
↑ 同一個指標名稱,但這是兩條完全獨立的時序每條時序底下掛的是一串 (timestamp, float64) 的樣本點。「指標」在 Prometheus 裡不是一個東西,是一個家族——真正被儲存、被計費、被吃記憶體的單位是時序。
一個 http_requests_total 計數器,每秒被打 10 次和每秒被打 10 萬次,儲存成本完全相同——因為它就是一條時序,每次抓取只存一個數字。這就是 ch01 說的「metrics 已經被聚合過了」。
method 5 種(GET/POST/PUT/DELETE/PATCH)
status 8 種
service 20 種
→ 5 × 8 × 20 = 800 條時序再加一個只有 3 種取值的 region?2,400 條。加一個 user_id?——這就是本章那場事故。Prometheus 的 TSDB 把最近的資料放在記憶體中的 head block(約 2 小時後才落成磁碟 block)。每一條活躍時序在記憶體裡都要維護自己的一份 chunk 與索引項,經驗上是每條數 KB 的量級。
10 萬條活躍時序 → 記憶體用量還算舒服
100 萬條 → 開始要認真配置
1000 萬條 → 單機幾乎撐不住而且「活躍」的定義很寬鬆:一條時序只要在保留窗內出現過就佔著索引。所以即使那些 user_id 的請求早就結束了,它們留下的時序還在——這是事故現場「重啟後記憶體又慢慢漲回去」的原因。
# Prometheus 自己的指標:目前 head 裡有幾條活躍時序
prometheus_tsdb_head_series
# 內建的 TSDB 狀態頁(UI 的 Status → TSDB Status,或 API)
/api/v1/status/tsdb
# 會直接列出「時序最多的前 10 個指標名稱」與「基數最高的 label」這兩個東西要在出事之前就先看過一次,你才知道正常水位長什麼樣。
Prometheus 很少因為「流量太大」而倒下,它幾乎總是死於 cardinality(基數)——時序的條數。
判準只有一個:這個 label 的取值範圍,是不是有界而且很小?
| label | 取值數量 | 安全嗎 |
|---|---|---|
| method、status、env | 個位數~數十 | ✅ 安全 |
| service、endpoint(用模板) | 數十~數百 | ✅ 可以,但要注意乘積 |
| pod / instance | 數十~數千,且會隨部署改變 | ⚠ 小心:每次滾動更新都產生一批新時序 |
| user_id、order_id、trace_id、session_id | 無上限 | ❌ 絕對不行 |
| 完整 URL 路徑、SQL 語句、錯誤訊息 | 實質無上限 | ❌ 絕對不行 |
pod label:pod 名稱含隨機後綴,每次部署整批時序全部換新。舊的要等保留期過了才消失——所以頻繁部署的服務,時序數會是「同時存活的版本數 × 每版的時序數」。error="connection refused to 10.2.3.4:5432"——IP 和 port 讓它變成無限集合。要放的是分類(error_type="connection_refused"),不是原文。http_request_duration_seconds_bucket{le="0.005"} ← 每個 bucket 一條
http_request_duration_seconds_bucket{le="0.01"}
... (假設 12 個 bucket)
http_request_duration_seconds_sum ← 加一條
http_request_duration_seconds_count ← 再加一條
共 14 條而這 14 條還要再乘上其他所有 label 的組合。所以「幫每個端點加一個延遲 histogram」的成本,是「端點數 × 14 × 其他 label 組合」。histogram 很有用,但它是這一章所有數字裡最大的那個乘數。時序數 ≈ Σ(每個指標) 各 label 取值數的乘積 × (histogram 就再乘 bucket+2)
記憶體 ≈ 活躍時序數 × 數 KB在寫下埋點程式碼之前,先在腦中乘一次。這個習慣可以避免這一章 95% 的問題。
用於「累計發生了幾次」:請求數、錯誤數、處理的訊息數。永遠不要直接看 counter 的值——它是一個一直長大的數字,本身沒有意義,要用 rate() 看它的變化速度(ch03)。
服務重啟時 counter 會歸零,Prometheus 的 rate() 會自動偵測並處理這個 reset,這也是為什麼命名慣例要求 counter 以 _total 結尾——讓人和工具都知道該用 rate()。
用於「當下的瞬時值」:記憶體用量、佇列長度、連線池的活躍連線數、目前的溫度。它可以直接看、可以做 avg/max/min,但不要對它用 rate()(那沒有意義)。
用於延遲、回應大小這類「需要看分佈」的東西。它在客戶端做的事很單純:把每個觀測值丟進預先定義好的桶子裡計數。
_bucket{le="0.1"} → 有幾個觀測值 ≤ 0.1 秒(累積計數)
_bucket{le="0.5"} → 有幾個 ≤ 0.5 秒
_sum → 所有觀測值的總和
_count → 觀測次數分位數是查詢時用 histogram_quantile() 從這些桶算出來的(ch03 專講怎麼算、以及怎麼算錯)。
p99=200ms。「整個服務的 p99」是多少?——答不出來。把 10 個 p99 平均是錯的(ch01 講過),而你手上沒有原始資料可以重算。Prometheus 較新的版本有「原生直方圖」——不用預先定義 bucket,改用指數式自動分桶,能在大幅降低時序數的同時提高精度。它是目前解決 histogram 乘數問題最根本的方向,但要確認你的 Prometheus 與客戶端函式庫版本都支援,且它仍在演進中。
Prometheus 是主動去抓(pull):它按設定的間隔對每個目標的 /metrics 發 HTTP 請求,把當下的文字格式指標抓回來。
# 目標吐出來的東西就長這樣,是純文字
# HELP http_requests_total Total HTTP requests
# TYPE http_requests_total counter
http_requests_total{method="GET",status="200"} 48213
http_requests_total{method="GET",status="500"} 12up{job=...} = 0。「服務掛了」這件事不需要另外做——而 push 模型下,「沒收到資料」與「服務沒事只是沒東西可送」無法區分。curl localhost:8080/actuator/prometheus 就看得到現在的所有指標,除錯極方便。這是 pull 模型唯一需要解決的問題,而答案是不要寫死目標清單:
scrape_configs:
- job_name: 'spring-apps'
kubernetes_sd_configs: # 從 K8s API 自動發現 pod
- role: pod
relabel_configs: # 只抓有特定 annotation 的
- source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_scrape]
action: keep
regex: "true"而它有兩個後遺症要知道:
up 這個健康訊號。scrape_interval: 15s # 常見預設,通常夠用兩個限制要記著:抓取間隔決定了你能偵測到的最小事件長度(15 秒抓一次,就抓不到持續 5 秒的尖峰);而間隔越短,時序的樣本點越多,儲存與記憶體成本成正比上升。這又是一次代價守恆——解析度是要付錢買的。
Prometheus 的命名慣例看起來像是團隊風格偏好,但其中幾條會直接影響工具能不能正確運作。
_total 結尾:http_requests_total。這是人和工具判斷「該不該用 rate()」的依據;OpenTelemetry 轉 Prometheus 格式時也依賴它。_seconds 而不是 _milliseconds、_bytes 而不是 _mb。Grafana 與 PromQL 的函式都假設是基礎單位,混用會讓運算結果錯得很安靜。le 是 histogram 的保留 label,quantile 是 summary 的保留 label。不要拿來當自己的 label 名。<領域>_<測什麼>_<單位>[_total]
http_request_duration_seconds (histogram)
http_requests_total (counter)
jvm_memory_used_bytes (gauge)
db_connections_active (gauge)常見的猶豫:要做 http_requests_get_total 和 http_requests_post_total 兩個指標,還是一個指標配 method label?
method 是 label。http_requests_total 和 jvm_memory_used_bytes 加起來沒有意義,所以它們是不同的指標。再強調一次上一節的重點,但換個角度:label 的取值集合應該在部署前就是可預測的。如果那個值來自使用者輸入、來自外部系統的回應、或來自任何你無法列舉的來源——它就不該是 label。
// ❌ 端點用實際路徑:每個訂單 id 一條時序
Counter.builder("http_requests_total").tag("uri", "/api/orders/8821")
// ✅ 端點用路徑模板:一條就夠
Counter.builder("http_requests_total").tag("uri", "/api/orders/{id}")這正是下一節那場事故的成因——而在 Spring 專案裡,這件事有時候是框架幫你做錯的(ch08 會回到 Micrometer 的細節)。
某個週三早上,監控系統自己開始出問題:
先問「時序是不是變多了」,這是 Prometheus 出問題時的第一個問題:
# Prometheus 自身指標,畫出來看趨勢
prometheus_tsdb_head_series
# 兩週前:18 萬 現在:430 萬 ← 24 倍接著直接問「是誰貢獻的」——用內建的 TSDB 狀態頁(Status → TSDB Status,或 /api/v1/status/tsdb):
Top 10 series count by metric name:
http_server_requests_seconds_bucket 3,847,220 ← 元兇
http_server_requests_seconds_count 320,601
...
Top 10 label value counts:
uri 27,431 ← 兩萬七千個不同的值
pod 842uri 有 27,431 個不同的值。撈幾個出來看:
/api/orders/8821
/api/orders/8822
/api/orders/8823
...uri tag 直接取了 request.getRequestURI()——也就是實際路徑,不是路徑模板。uri 值。method(2) × status(5) × pod(6):27,431 × 14 × 2 × 5 × 6 ≈ 2,300 萬(理論上限)
實際命中的組合 ≈ 384 萬metric_relabel_configs:
- source_labels: [__name__]
regex: 'http_server_requests_seconds_bucket'
action: dropmetric_relabel_configs 作用在「抓取之後、寫入之前」,所以它能擋掉已經產生的爆炸(而 relabel_configs 是抓取之前決定要不要抓這個目標,兩者常被搞混)。/api/orders/{id}。uri 的取值數從 27,431 降回 40 幾個。scrape_configs:
- job_name: 'spring-apps'
sample_limit: 10000 # 單次抓取超過就整份丟棄並標記失敗
label_limit: 30
label_value_length_limit: 200再對 prometheus_tsdb_head_series 的成長率設告警。uri 只有 20 個值。高基數問題與資料量無關,與「取值範圍有沒有上界」有關,而這件事在測試環境永遠測不出來。micrometer-registry-prometheus + Actuator,ch08 細講)。status label)、延遲分佈(histogram)。多數框架的預設埋點已經涵蓋。sample_limit 把暴走的目標擋在門外,metric_relabel_configs 丟掉不要的指標。prometheus_tsdb_head_series 的成長率設告警(不是絕對值)——本章那場事故從第一天就看得出斜率。這一章把 Prometheus 的資料模型講完了,這一節講它不擅長什麼——知道邊界才不會把它用在會受傷的地方。
rate() 是外推估算——Prometheus 不適合用來計費或做需要精確數字的稽核。它的設計目標是「在故障時仍然可用」,不是「絕對正確」。status=200 底下回的是錯的內容——ch01 說過,「回 200 但內容錯」是 metrics 天生看不到的。rate(errors[5m]) = 0.003——這個數字好不好?沒有 SLO 就只有感覺(ch06)。下一章要用 PromQL 把這些資料變成答案。而它的第一個陷阱,正好是本章 histogram 的延續:你儀表板上那個 p99,很可能從一開始就是錯的。
| 型別 | 用於 | 查詢方式 | 注意 |
|---|---|---|---|
| counter | 累計次數(請求、錯誤、訊息) | 一定要用 rate()/increase() | 命名以 _total 結尾;重啟歸零由 rate() 自動處理 |
| gauge | 瞬時值(記憶體、佇列長度、活躍連線) | 直接看,或 avg/max/min | 不要對它用 rate() |
| histogram | 分佈(延遲、回應大小) | histogram_quantile() 在查詢時算分位數 | 一個 histogram = bucket 數 + 2 條時序,是最大的乘數 |
| summary | 同上,但在客戶端算好分位數 | 直接讀 quantile label | 分位數無法跨實例聚合——多實例服務不要用 |
| label | 取值數量 | 安全嗎 | 說明 |
|---|---|---|---|
| method / status / env | 個位數~數十 | ✅ 安全 | 有界且穩定 |
| service / endpoint(路徑模板) | 數十~數百 | ✅ 可以 | 但要注意與其他 label 的乘積 |
| pod / instance | 數十~數千,隨部署變動 | ⚠ 小心 | 每次滾動更新產生一整批新時序,舊的要等保留期才消失 |
| user_id / order_id / trace_id | 無上限 | ❌ 絕對不行 | 放 trace/log,用 exemplar 串回來 |
| 完整 URL 路徑 | 實質無上限 | ❌ 絕對不行 | 改用路徑模板 /api/orders/{id} |
| 錯誤訊息原文 | 實質無上限 | ❌ 絕對不行 | 改放分類:error_type="connection_refused" |
| pull(預設) | Pushgateway | |
|---|---|---|
| 適用 | 長時間運行的服務 | 生命週期短於抓取間隔的批次任務 |
| 健康訊號 | 免費得到 up 指標 | 沒有——推上去的值不會自己消失 |
| 目標變動 | 服務發現(kubernetes_sd/file_sd) | 不適用 |
| 除錯 | curl /metrics 直接看 | 要去 Pushgateway 上看 |
| 常見誤用 | — | 拿來繞過網路限制——該用代理或聯邦,不是 Pushgateway |
點擊卡片翻面查看答案,共 13 張。