上一章把資料收進來了,這一章要把它變成答案。而 PromQL 有一個很危險的性質:
寫錯的查詢幾乎從不報錯,它只是安靜地回一個「看起來很合理」的數字。
rate() 的範圍寫太短,圖上會出現斷斷續續的空洞,但你可能以為那是流量真的沒了。histogram_quantile() 漏掉一個 by (le),p99 照樣畫得出來,只是那個數字沒有任何意義。把十台機器的 p99 取平均,Grafana 會很開心地畫給你——ch01 說過那是錯的,但它不會提醒你。
這一整章的重點就是:哪些寫法會給你一個錯的數字,而你不會發現。
rate 的三個真相:它會外推、它需要至少 4 倍抓取間隔、它只能用在 counter。sum 與 rate 的順序:這兩個寫反的後果,比你想的嚴重得多。histogram_quantile:正確寫法只有一種,而 bucket 邊界要圍繞 SLO 來設計。absent():ch01 說可觀測性天生看不到「沒發生的事」——這是少數的補救工具。這一章的例子都用上一章的指標。如果
rate、counter、histogram的關係還不熟,先回 ch02 看一次型別那節——PromQL 的一半陷阱,根源都在型別選錯或用錯。
PromQL 的每個運算式都會回傳四種型別之一,而日常會碰到的只有兩種。搞清楚它們的差別,大部分「為什麼這個函式不能用」的問題就沒了。
http_requests_total
# → 每條符合的時序,各給一個當前值
# http_requests_total{status="200"} 48213
# http_requests_total{status="500"} 12http_requests_total[5m]
# → 每條時序,各給過去 5 分鐘內的一串樣本點
# http_requests_total{status="200"} 48180@t1 48195@t2 48213@t3 ...rate()/increase()/*_over_time() 這類「需要看變化」的函式,只吃 range vector;而 sum()/avg() 這類聚合運算子,只吃 instant vector。sum(http_requests_total[5m]) 是錯的,而 sum(rate(http_requests_total[5m])) 是對的——因為 rate() 把 range vector 變回了 instant vector。http_requests_total{job="order-api"} # 完全相等
http_requests_total{status!="200"} # 不等於
http_requests_total{status=~"5.."} # 正則匹配(5xx)
http_requests_total{uri!~"/actuator.*"} # 正則不匹配
{__name__=~"http_.*"} # 連指標名稱都能匹配(很貴,少用)job 或 service。sum(rate(http_requests_total[5m])) 會把全公司所有服務的請求數加在一起——查詢不會錯,圖也畫得出來,只是那個數字沒有任何意義。每個查詢都要問自己:這裡面混進了什麼我沒想到的東西?Prometheus 有一個 5 分鐘的過期規則:如果一條時序超過大約 5 分鐘沒有新樣本,查詢就當它不存在了(而不是一直沿用最後一個值)。
這對「pod 被刪掉」這類情境是對的行為,但它也造成一個現象:服務掛掉之後,它的指標不是變成 0,而是「消失」。於是 rate(errors[5m]) > 0 這種告警在服務完全掛掉時不會觸發——因為沒有資料可以比較。這是本章 absent() 那一節要處理的問題。
rate() 是 PromQL 用得最多的函式,也是誤用最多的。它做的事情是:在指定的時間範圍內,計算 counter 的每秒平均增長率。
rate(http_requests_total[5m])
# → 過去 5 分鐘,平均每秒增加幾次請求最常見的疑問是:「為什麼 increase() 告訴我這 5 分鐘發生了 2.7 次錯誤?錯誤怎麼會有小數?」
rate 與 increase 都會做外推(extrapolation)。範圍的兩端很少剛好落在抓取的時間點上,所以 Prometheus 會依照已知樣本的斜率,把兩端補到範圍的邊界。它算的是「估計值」,不是精確計數。rate() 至少需要範圍內有兩個樣本點才算得出東西。抓取間隔 15 秒時,如果你寫 [30s],只要有一次抓取失敗或延遲,那個時間點就會沒有結果——圖上出現空洞。
scrape_interval: 15s
rate(x[30s]) # ❌ 太脆弱,一次抓取失敗就斷
rate(x[1m]) # ⚠ 勉強
rate(x[5m]) # ✅ 慣用值,穩定經驗法則:範圍 ≥ 4 × 抓取間隔。代價是反應變鈍——5 分鐘的範圍會把尖峰抹平,所以告警用的範圍要在「穩定」與「靈敏」之間取捨(ch07 會再回到這件事)。
rate() 沒有意義——gauge 會上下波動,而 rate() 假設它只增不減,遇到下降就當成「counter 重啟」處理,結果完全錯誤。delta() 或 deriv(),看趨勢用 *_over_time()。rate(x[5m]) # 範圍內第一個到最後一個樣本,取平均 → 平滑,適合告警與長期趨勢
irate(x[5m]) # 只用最後兩個樣本 → 靈敏,圖形鋸齒,適合看瞬間尖峰告警一律用 rate,不要用 irate——後者對單一樣本的抖動極度敏感,會製造大量假警報(ch07 的主題之一)。
服務重新部署時 counter 歸零。rate() 會偵測到「這次的值比上次小」,判定為 reset,並自動補償——所以你不需要自己處理重啟。但這個能力有一個前提,就是下一節的主題。
這兩個寫法看起來只是順序不同,實際上其中一個是錯的:
sum(rate(http_requests_total[5m])) # ✅ 正確
rate(sum(http_requests_total)[5m:]) # ❌ 錯誤(而且不會報錯)rate() 依賴「counter 單調遞增」這個性質來偵測重啟——值突然變小就是 reset。sum() 會把多條時序加起來,破壞掉這個性質:某一個 pod 重啟歸零時,總和會下降,但其他 pod 還在正常累加。rate() 看到一個「下降但不是歸零」的序列,會把它誤判成一次 counter reset,然後補償一個根本不存在的增量。「先在每一條時序上各自算變化率,再把變化率加起來。」這個順序在數學上也才是對的:每秒請求數是可以相加的,而累計計數在跨實例聚合之後就失去意義了。
-- 每個服務的 QPS
sum by (job) (rate(http_requests_total[5m]))
-- 錯誤率(分子分母都要先 rate)
sum by (job) (rate(http_requests_total{status=~"5.."}[5m]))
/
sum by (job) (rate(http_requests_total[5m]))sum by (job, status) (...) # 只保留這些 label,其餘全部合併
sum without (instance, pod) (...) # 只丟掉這些 label,其餘保留without (instance, pod) 常常比 by 更好用——因為它會自動保留你之後才加上的新 label,不會因為「忘了把新 label 加進 by 清單」而讓資料被意外合併。算比率時,如果分子在某個時間點完全沒有符合的時序(例如那五分鐘一個 5xx 都沒有),結果不是 0,而是沒有資料——因為向量除法是靠 label 匹配的,分子沒有那條時序就配不起來。
-- 錯誤率為 0 時圖上會斷掉,用 or 補一個 0
sum(rate(errors[5m])) or vector(0)ch01 說過「分位數不能平均」,ch02 說過「histogram 保留原始 bucket 所以可以正確聚合」。這一節就是把那個「正確聚合」寫出來。
histogram_quantile(
0.99,
sum by (le) (rate(http_server_requests_seconds_bucket[5m]))
)三件事缺一不可,而且順序固定:
rate():bucket 是 counter,要先變成每秒速率。sum by (le):把各實例的同一個 bucket 加起來。le 一定要保留,因為 histogram_quantile 靠它來重建分佈。histogram_quantile():從合併後的 bucket 分佈計算分位數。| 寫法 | 症狀 |
|---|---|
| 漏掉 by (le) | 直接報錯或回空——這是最幸運的一種,因為你會發現 |
| 忘了先 rate | 用累計值算分位數,得到的是「服務啟動至今」的分佈,完全不反映當下 |
| avg(histogram_quantile(...)) | 每個實例各算一個 p99 再平均——數字看起來很正常,但它沒有任何意義 |
histogram_quantile 只知道「有多少觀測值落在每個桶裡」,桶內的分佈它用線性插值猜。所以:
bucket 邊界:... 0.25, 0.5, 1.0, ...
真實的 p99 落在 0.25 與 0.5 之間
→ 回傳值是在這兩者之間線性插值出來的
→ 如果實際分佈集中在 0.26,插值可能給你 0.40.3 就必須是一個 bucket 邊界——這樣「有多少比例 ≤ 0.3 秒」是直接讀出來的精確值,完全不需要插值。+Inf):回傳值會是最後一個有限邊界,它是下限而不是真實值。看到 p99 剛好等於你最大的 bucket 邊界,那代表你的 bucket 上限不夠高,真實延遲更糟。ch01 最後說過:可觀測性天生偏向觀測「發生過的事」,而有一整類故障的本質是什麼都沒發生。PromQL 有兩組工具可以部分補這個洞。
absent(up{job="order-api"})
# 有資料 → 回空(不觸發)
# 沒資料 → 回 1(觸發告警)它處理的是本章第一節說的 staleness 問題:服務完全掛掉時,指標不是變成 0,而是消失——所以任何 xxx > 0 的告警都不會響。
-- 這條告警在服務掛掉時「不會」觸發,因為沒有資料
rate(http_requests_total{job="order-api"}[5m]) == 0
-- 這條才會
absent(up{job="order-api"}) or up{job="order-api"} == 0absent_over_time(batch_job_last_success_timestamp[25h])
# 25 小時內都沒有成功紀錄 → 告警time() - batch_job_last_success_timestamp > 86400注意這一族與 sum()/avg() 的差別:聚合運算子是「跨時序」聚合,*_over_time 是「跨時間」聚合。
max_over_time(queue_depth[1h]) # 過去一小時佇列最深到多少(gauge 的尖峰)
avg_over_time(cpu_usage[30m]) # 過去半小時的平均
min_over_time(connections_free[10m]) # 連線池最少剩幾條 —— 這種「最壞值」比平均有用得多對 gauge 做告警時,用 max_over_time/min_over_time 往往比直接比較當下的值更穩——它天然帶有「持續了一段時間」的意味,可以避免瞬間抖動造成的假警報。
changes(process_start_time_seconds[1h]) > 3
# 一小時內重啟超過 3 次 → 崩潰迴圈(K8s 的 CrashLoopBackOff)
resets(http_requests_total[1h])
# counter 被重置幾次,同樣可以看出重啟某支核心 API 的月度可靠性報告連續三個月「綠燈」:
先做唯一能繞過所有查詢語法問題的事:不看儀表板,直接算一個最原始的數字。
-- 過去 7 天,超過 300ms 的請求佔多少比例?(直接讀 bucket,不做分位數)
1 - (
sum(rate(http_server_requests_seconds_bucket{le="0.3", uri="/api/search"}[7d]))
/
sum(rate(http_server_requests_seconds_count{uri="/api/search"}[7d]))
)
-- 結果:0.041 → 4.1% 的請求超過 300ms4.1% 超標,而儀表板說 p99 是 180ms——兩者不可能同時為真。去看儀表板的查詢:
-- 儀表板上實際寫的
avg(histogram_quantile(0.99, rate(http_server_requests_seconds_bucket[5m])))
↑ 對每個實例各算一次 p99,然後把 12 個實例的 p99 平均掉sum by (le),變成每個 pod 各算一個 p99,再由外層的 avg 取平均。ch01 說過分位數不能平均——當流量不均勻(某個 pod 承接了大客戶的重查詢)時,那個 pod 的 p99 是 2 秒,但被其他 11 個健康的 pod 平均掉,最後顯示 180ms。... 0.25, 0.5, 1.0 ...,沒有 0.3 這個邊界。就算查詢寫對了,p99 也是在 0.25 與 0.5 之間插值猜出來的——而 SLO 門檻正好落在那個猜測區間裡,誤差最大的地方剛好是最重要的地方。histogram_quantile(0.99, sum by (le) (rate(http_server_requests_seconds_bucket[5m])))修正後的 p99 是 1.4 秒。management.metrics.distribution.slo.http.server.requests=100ms,300ms,1sle="0.3" 的 bucket,沒有插值、沒有聚合問題(ch06 會說明為什麼這是更好的 SLI 形式)。max by (pod) 的視圖能立刻看出這件事。上一節的結論是「不要讓每個人自己拼查詢」。recording rule 就是實現這件事的機制:它讓 Prometheus 定期把一個查詢的結果算好存成一條新的時序。
groups:
- name: http
interval: 30s
rules:
- record: job:http_requests:rate5m
expr: sum by (job) (rate(http_requests_total[5m]))
- record: job:http_request_duration:p99_5m
expr: histogram_quantile(0.99, sum by (job, le) (rate(http_server_requests_seconds_bucket[5m])))level:metric:operations
job:http_requests:rate5m
↑聚合到哪一層 ↑原始指標 ↑做了什麼運算這個慣例的用處是:看到名字就知道它已經被聚合到哪一層了,避免有人拿一條已經 sum by (job) 過的時序再去 sum by (pod)(那會得到空結果,然後花半小時 debug)。
一個 range query 的成本大致是:
要處理的樣本點數 ≈ (時間範圍 / step) × 符合的時序條數
30 天、step=1m、5,000 條時序
≈ 43,200 × 5,000 = 2.16 億個樣本點{__name__=~".+"} 這種要掃整個索引,是查詢逾時的常見來源。query.max-samples 與逾時保護,被擋下來時的錯誤訊息會直接告訴你樣本數超標——那是在提示你該用 recording rule 了。max_over_time( rate(http_requests_total[5m])[1h:1m] )
# 過去一小時內,5 分鐘速率的最高點它很方便,但成本高(等於在查詢時跑 60 次內層查詢)。會反覆用到的 subquery,一律改成 recording rule。
| 寫法 | 會發生什麼 |
|---|---|
| rate(gauge[5m]) | 把下降當成 counter reset,結果完全錯誤 |
| rate(x[30s])(抓取間隔 15s) | 圖上出現空洞,被誤讀成「流量沒了」 |
| rate(sum(...)) | 每次滾動更新產生假尖峰 |
| avg(histogram_quantile(...)) | 單一實例的異常被平均掉(本章失敗場景) |
| sum(rate(x[5m])) 沒限定 job | 把全公司的服務加在一起 |
p99 = 180ms 是好是壞?沒有 SLO 就沒有答案(ch06)——而本章那場事故正是「沒有基準所以無法反駁儀表板」的後果。rate/increase 會外推,抓取可能失敗。不要拿 Prometheus 當計費或稽核依據。interval 都會執行一次,規則太多會讓 Prometheus 本身變慢。而且它們產生的新時序同樣佔 cardinality。histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))),而 bucket 邊界要圍繞 SLO 門檻設計。下一章換一本柱:日誌是三者裡最靈活、也最容易失控的一個——它的成本與流量成正比,而故障時流量最大。
| 函式 | 輸入 | 用在 | 注意 |
|---|---|---|---|
| rate() | range vector(counter) | QPS、錯誤率、吞吐 | 範圍 ≥ 4× 抓取間隔;會外推;只能用於 counter |
| irate() | range vector(counter) | 看瞬間尖峰 | 只用最後兩個樣本——告警不要用 |
| increase() | range vector(counter) | 「這段期間發生幾次」 | =rate × 秒數,同樣外推,所以會有小數 |
| histogram_quantile() | 帶 le 的 instant vector | 延遲分位數 | 必須先 rate 再 sum by (le) |
| absent() / absent_over_time() | instant / range vector | 偵測指標消失、排程沒跑 | 抓「沒發生的事」唯一的工具 |
| max_over_time() 等 | range vector | gauge 的尖峰/最壞值 | 跨『時間』聚合,不是跨時序 |
| changes() | range vector | 重啟次數、崩潰迴圈 | 配 process_start_time_seconds 用 |
| 錯誤寫法 | 正確寫法 | 症狀 |
|---|---|---|
| rate(gauge[5m]) | delta() / deriv() / *_over_time() | 把下降誤判成 counter reset,數字完全錯 |
| rate(x[30s]),抓取間隔 15s | rate(x[5m]) | 圖上空洞,被誤讀成流量消失 |
| rate(sum(...)) | sum(rate(...)) | 每次滾動更新出現假尖峰 |
| avg(histogram_quantile(...)) | histogram_quantile(0.99, sum by (le) (rate(...))) | 單一實例異常被平均掉 |
| sum(rate(x[5m])) 未限定 job | sum by (job) (rate(x{job="..."}[5m])) | 把不相干的服務加在一起 |
| rate(errors[5m]) == 0 當掛掉告警 | absent(up{...}) or up{...} == 0 | 服務完全掛掉時指標消失,告警不會響 |
| 情境 | 做法 | 理由 |
|---|---|---|
| 有明確的 SLO 門檻 | 把門檻值設成 bucket 邊界(例如 0.3) | 「≤門檻的比例」變成精確讀取,完全不需插值 |
| p99 剛好等於最大 bucket 邊界 | 把上限拉高 | 那是下限不是真實值——真實延遲更糟 |
| bucket 太多 | 只留關鍵邊界(含 SLO 門檻) | 每個 bucket 一條時序,是最大的乘數(ch02) |
| 低流量服務 | 拉長 rate 的時間範圍 | 一分鐘 3 個請求時,p99 沒有統計意義 |
點擊卡片翻面查看答案,共 13 張。