前面七章講了方法論、資料模型、查詢、三本柱、SLO 與告警。這一章要做兩件事:
第一,把它們真正接到一個 Spring Boot 專案上。好消息是這件事比想像中簡單——Micrometer 與 OpenTelemetry 已經把大部分工作做完了,你需要做的決定其實只有幾個。而那幾個決定裡,有一個做錯就會重演 ch02 那場事故。
第二,算一次完整的帳。可觀測性很容易變成整個架構裡最貴的一套系統——它的成本會隨著服務數量、流量、與保留期同時成長,而它自己沒有 SLO 來約束自己。很多團隊是在收到帳單的那一天才第一次認真看這件事。
這一章的內容:
這一章的最後一張助記卡是整軌的總結。
在 Spring Boot 3 專案上接可觀測性,第一步的投報比高到有點不合理:加兩個依賴、寫幾行設定,前面七章講的東西大部分就開始有資料了。
<!-- metrics -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- tracing(ch05)-->
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-otel</artifactId>
</dependency>management:
endpoints.web.exposure.include: health,prometheus
metrics.tags:
application: ${spring.application.name} # 對應 OTel 的 service.name(ch05)
tracing.sampling.probability: 1.0 # 全收,取樣交給 Collector 做
otlp.tracing.endpoint: http://otel-collector:4318/v1/traces| 指標 | 對應到 |
|---|---|
| http_server_requests_seconds | RED 三個指標一次到齊(count/錯誤 status/histogram) |
| jvm_memory_used_bytes、jvm_gc_pause_seconds | backend ch04 的 GC 與記憶體 |
| hikaricp_connections_*(active/pending/timeout) | backend ch03 的連線池——USE 的飽和度 |
| executor_*(queued/active/pool_size) | 執行緒池的飽和度 |
| jdbc / cache / kafka 等 | 視依賴自動註冊 |
hikaricp_connections_pending 是這份清單裡最該立刻放上儀表板的一個。ch01 的 USE 方法說過:「用滿」不可怕,「有人在排隊」才可怕;而 db ch07 也說過,連線池排隊會偽裝成「資料庫很慢」。這個指標是分辨兩者的關鍵。management:
metrics.distribution:
percentiles-histogram:
http.server.requests: true # ① 產生 bucket,才能正確聚合分位數(ch03)
slo:
http.server.requests: 100ms,300ms,1s # ② bucket 邊界對齊 SLO 門檻(ch03/ch06)
observations.annotations.enabled: true # ③ 啟用 @Observed(ch05 的手動 span)percentiles(客戶端算好的分位數)——那是 ch02 說的 summary,它的分位數無法跨實例聚合。要開的是 percentiles-histogram(產生 bucket,由 PromQL 在查詢時算)。兩個設定名稱只差一個字,效果完全相反。ch02 那場「一個 label 把 Prometheus 從 4GB 吃到 30GB」的事故,在 Spring 專案裡有一個非常具體的形態。
Spring MVC 的自動埋點會用路徑模板當 uri tag:
GET /api/orders/8821 → uri="/api/orders/{id}" ✅ 有界request.getRequestURI()。這是 ch02 那場事故的原文。uri 標成 NOT_FOUND(安全),但某些過濾器層的埋點、或 WebFlux 的特定寫法可能漏掉。而爬蟲與掃描器會製造大量隨機路徑——這是最常見的觸發來源。// ❌ 每個租戶一條時序,租戶數無上限
Counter.builder("orders.created").tag("tenant_id", tenantId)
// ✅ 分級:把無界的維度收成有界的
.tag("tenant_tier", tier) // free / pro / enterprise需要單一租戶的細節?那屬於 trace 與 log(ch01 的規則)。// ① 應用端:設定 tag 上限(超過就合併成 "other")
@Bean
MeterFilter uriCardinalityLimit() {
return MeterFilter.maximumAllowableTags(
"http.server.requests", "uri", 100, MeterFilter.deny());
}
// ② 也可以直接丟掉不需要的指標
@Bean
MeterFilter denyActuatorMetrics() {
return MeterFilter.denyNameStartsWith("http.server.requests.actuator");
}加上 ch02 說的第三道防線:Prometheus 端的 sample_limit 與 metric_relabel_configs。三道防線裡至少要有兩道。
curl localhost:8080/actuator/prometheus | wc -l。ch01 說三本柱之間的橋比每一樣本身做得多好都重要。這一節是那些橋在 Spring 專案裡的具體接法。
Spring Boot 3 + Micrometer Tracing 會自動把 traceId/spanId 放進 MDC,你只要在 pattern 裡取用:
<pattern>%d{HH:mm:ss} %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n</pattern>
<!-- JSON encoder 則會自動把整個 MDC 展開成欄位(ch04 建議的做法) -->
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>management:
prometheus.metrics.export.properties:
io.prometheus.exemplars.sampleIntervalMilliseconds: 1000接好之後,Grafana 的延遲圖上會出現可點的小點,直接跳到那一次的 trace。Prometheus 端要啟用 exemplar 儲存(--enable-feature=exemplar-storage)。
spring.application.name: order-api
→ metrics 的 tag: application="order-api"
→ trace 的屬性: service.name="order-api"
→ 日誌的欄位: service="order-api"三個名字必須完全相同,Grafana 才能自動從一條 trace 跳到對應的日誌。不一致的話你會退回複製貼上的年代——而且要改就是改所有服務。// 執行緒池:讓 context 跟著任務走
@Bean
TaskDecorator otelTaskDecorator() {
return runnable -> {
Context ctx = Context.current();
return () -> { try (Scope s = ctx.makeCurrent()) { runnable.run(); } };
};
}
// @Async 的 executor 也要套用同一個 decorator
// Kafka:producer inject / consumer extract(ch05)走不通的那一段,就是你還沒接好的那座橋。這個檢查應該在每次新服務上線時做一次。
指標有了,接下來的問題是怎麼擺。而這件事有一個很實際的目標:讓一個沒看過這個服務的人,在 30 秒內判斷出「它現在有沒有事」。
第一列 SLO 與錯誤預算:目前達成率、預算剩餘、burn rate ← 唯一該先看的
第二列 RED:QPS / 錯誤率 / 延遲分位數(含 SLO 門檻的參考線)
第三列 依賴:對下游服務、DB、快取的 RED
第四列 USE:連線池 pending、執行緒池佇列、JVM 堆、GC pause
第五列 最近部署的標記(annotation)儀表板不要直接寫 histogram_quantile(...)——ch03 那場事故就是儀表板裡的查詢寫錯而沒人發現。正確寫法固化成 recording rule,儀表板只引用名字。
max by (pod)(後者能抓出 ch03 那個「單一實例異常被平均掉」的情況)。年度成本檢視時,財務部門標出了一筆異常:
把帳單拆開來看(這件事本身就該每季做一次):
| 項目 | 佔比 | 問題 |
|---|---|---|
| 日誌 ingest 與索引 | 61% | 全部 INFO 以上都收、全欄位索引、一律保留 90 天 |
| Trace 儲存 | 22% | head-based 取樣 100%,全部保留 30 天 |
| Metrics | 14% | 430 萬條活躍時序,其中大量來自已下線的服務 |
| 其他 | 3% | — |
| 順序 | 做什麼 | 典型降幅 | 排查能力的損失 |
|---|---|---|---|
| ① | Trace 改 tail-based 取樣 | 80~90% | 負的——有問題的全留,反而更好用 |
| ② | 存取日誌取樣、DEBUG 不進平台 | 50~70% | 幾乎沒有 |
| ③ | 日誌分級保留 + 冷儲存 | 30~50% | 舊資料查詢變慢(但很少查) |
| ④ | 清理高基數與無人查詢的 metrics | 視情況 | 幾乎沒有 |
| ⑤ | 縮短 metrics 保留期 + recording rule | 中等 | 長期趨勢的解析度降低 |
① 每個服務設 ingest 配額(ch04)——同時防成本與防單一服務拖垮平台
② 對「日誌量」「時序數」的成長率設告警——不是絕對值,是斜率
③ 每季檢視:帳單拆解、TSDB top 10、儀表板使用率
④ 新服務上線的檢查表裡加一項:預估它會產生多少可觀測性資料把整軌收成一份可以直接跟著做的清單。新服務上線時照這個順序走,一天之內可以完成大部分。
□ actuator + micrometer-registry-prometheus,暴露 /actuator/prometheus
□ spring.application.name 設好,且與 trace、log 的服務名一致
□ percentiles-histogram: true(不是 percentiles)
□ SLO 門檻加進 bucket 邊界(例如 100ms,300ms,1s)
□ curl /actuator/prometheus | wc -l 檢查行數(幾萬行 = 有 cardinality 問題)
□ Prometheus 端加 sample_limit□ micrometer-tracing-bridge-otel,指向 Collector
□ logback JSON encoder,pattern 帶 traceId/spanId
□ 執行緒池套 TaskDecorator、MQ 兩端做 inject/extract
□ 開 exemplar
□ 走一次驗證:metrics → 點 exemplar → trace → 用 trace_id 查 log□ 定義 1~2 條關鍵旅程的 SLI(好事件/有效事件形式)
□ 觀察 4 週後再訂 SLO(先別急著訂數字)
□ 正確查詢固化成 recording rule
□ 一張標準版面的儀表板(SLO / RED / 依賴 / USE / 部署標記)
□ burn rate 多視窗告警 + runbook
□ dead man's switch
□ 每條 page 級告警都問過那七個檢查(ch07)這一軌從「儀表板全綠但使用者在客訴」開始,走到 Spring 專案的設定檔。八章底下其實只有三件事。
可觀測性不是「收集越多越好」——每一種資料都在某個維度上付錢:
觀測點要站在使用者的位置,而不是機器的位置:
metrics 發現異常 → trace 定位到哪一段 → log 確認細節。而三者之間的橋(trace_id、exemplar、統一的 service.name),比每一樣本身做得多好都重要——這條動線走不通的那一段,就是還沒接好的地方。
absent_over_time、流量異常下降、dead man's switch、孤兒 span 佔比)。這一類防護幾乎都很便宜,而且幾乎都被遺漏。這是 SRE 補強計畫的第一軌(見 MIGRATION.md Step 11),而它被排在第一位是因為後面每一軌都會用到它的語言:
| 設定 | 預設 | 該改成 | 理由 |
|---|---|---|---|
| percentiles-histogram | false | true | 產生 bucket 才能跨實例正確聚合分位數(ch03) |
| percentiles | 未設 | 不要開 | 那是客戶端算好的分位數(summary),無法跨實例聚合(ch02) |
| distribution.slo | 未設 | 填入 SLO 門檻值 | bucket 邊界對齊門檻,「≤門檻的比例」變精確值(ch03/ch06) |
| tracing.sampling.probability | 0.1 | 1.0 + Collector 做 tail 取樣 | head 取樣會丟掉罕見錯誤(ch05) |
| spring.application.name | 未設 | 必設,且三邊一致 | metrics/trace/log 的服務名不一致就無法自動關聯(ch05) |
| open-in-view | true | false | 與可觀測性無關但同樣是預設值陷阱(db ch08) |
| 順序 | 做什麼 | 典型降幅 | 排查能力損失 |
|---|---|---|---|
| ① | Trace 改 tail-based 取樣 | 80~90% | 負的——有問題的全留,更好用 |
| ② | 存取日誌取樣、DEBUG 不進平台 | 50~70% | 幾乎沒有 |
| ③ | 日誌分級保留 + 冷儲存 | 30~50% | 舊資料查詢變慢(但很少查) |
| ④ | 清理高基數與無人查詢的 metrics | 視情況 | 幾乎沒有 |
| 不能砍 | SLI 指標、錯誤與慢請求的 trace/log、稽核紀錄 | — | 砍了等於失去決策機制與排查對象 |
| 主線 | 在哪幾章展開 | 一句話 |
|---|---|---|
| 測量本身有成本 | ch02 乘法 / ch04 加法 / ch05 取樣 / ch07 注意力 / ch08 相乘 | 每一種資料都在某個維度上付錢,而告警付的是最貴的那一種 |
| 症狀優先於原因 | ch01 黑箱 / ch03 先懷疑查詢 / ch06 SLI / ch07 徵狀告警 | 觀測點要站在使用者的位置,不是機器的位置 |
| 三本柱是一條路徑 | ch01 動線 / ch04 trace_id / ch05 exemplar / ch08 四座橋 | 橋比每一樣本身做得多好都重要 |
| (未明說)看不到沒發生的事 | ch01 請求沒進來 / ch03 absent / ch05 斷掉的 trace / ch07 watchdog | 「什麼都沒有」與「一切正常」長得一模一樣 |
點擊卡片翻面查看答案,共 13 張。