三本柱的最後一根,也是最晚成熟的一根。它回答的問題只有一個,但那個問題沒有別的工具答得出來:
這一次請求的 3 秒,是花在哪一段?
metrics 能告訴你「payment 服務的 p99 是 2 秒」,但它不知道這一次的 3 秒裡,有多少花在 payment、多少花在它呼叫的風控、多少花在排隊等連線。日誌有每個服務各自的時間戳,但跨機器的時鐘不可靠(ch04 說過)。只有 trace 明確記錄了「誰呼叫誰、各自花了多久」的父子關係。
而 trace 有一個很特別的失敗模式:它不是壞掉,是「斷掉」。前半段好好的,到某個地方就沒有了——而斷掉的位置,往往正好是最可疑的那一段(非同步、訊息佇列、執行緒池)。
這一章要處理的是:
這一章大量回扣 ch04 的 MDC:日誌的
trace_id與 trace 的 context 是同一個東西的兩個出口,所以它們會在同一個地方一起斷掉。
一條 trace 代表「一次請求的完整旅程」,它由許多 span 組成。而 span 是這一章唯一需要記牢的資料結構:
span = {
trace_id: 4bf92f3577b34da6a3ce929d0e0e4736 ← 整條 trace 都一樣
span_id: 00f067aa0ba902b7 ← 這一段自己的 id
parent_id: a2fb4a1d1a96d312 ← 誰呼叫我(根 span 沒有)
name: "POST /api/orders"
start / end: 時間戳
attributes: { http.request.method: "POST", ... }
events: [ { name: "exception", ... } ]
status: OK / ERROR
}parent_id 就是全部的關鍵。有了它,後端才能把散落在五個服務的幾十個 span 重建成一棵樹,畫出那張「每一段各花多久」的瀑布圖。而所謂「trace 斷了」,就是某個 span 沒有拿到正確的 parent_id——於是它變成另一棵樹的根,跟前面完全失聯。SERVER:我收到了一個請求(通常是一條 trace 在這個服務的入口)。CLIENT:我發出了一個請求並等待回應(HTTP 呼叫、資料庫查詢)。PRODUCER / CONSUMER:我送出/消費了一則訊息(非同步,兩者之間沒有「等待」關係)。INTERNAL:純粹的內部區段(一段耗時的計算)。PRODUCER/CONSUMER 之所以要獨立成一種,就是因為它們的因果關係與時間關係不同——生產者不會等消費者,所以子 span 可能在父 span 結束很久之後才發生。這個特性正是本章失敗場景的背景。
服務 A 呼叫服務 B 時,A 必須把「我是誰」告訴 B。標準做法是 W3C 的 traceparent HTTP header:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
↑版本 ↑trace_id ↑父 span_id ↑取樣旗標span_id 當自己的 parent_id,於是樹接起來了。Context 綁在當前執行緒上,所以同一個執行緒裡的任何程式碼都能取得「我現在在哪個 span 底下」。trace_id 也會在同一個地方一起消失——因為它們是同一個東西的兩個出口。trace 的成本與流量成正比(ch01),而全部保留通常不可行——一個請求可能產生幾十個 span,每個 span 都要序列化、傳輸、儲存。所以取樣是必要的,而取樣策略決定了你會失去什麼。
if (random() < 0.01) 記錄整條 trace; else 全部丟掉;traceparent 的旗標傳給下游,整條鏈路一致。收集這條 trace 的所有 span → 等它完成 → 判斷:
有錯誤? → 保留
超過 1 秒? → 保留
正常且快速? → 取樣 1%入口服務 head-based 取樣 100%(先全部收下來)
↓
Collector tail-based:錯誤 100%、慢請求 100%、正常 1%
↓
後端儲存另外一定要有「速率上限」:不論用哪種策略,都要能在流量暴增時把 trace 產生量壓住——否則 ch04 那場「日誌壓垮平台」的故事會在 trace 這邊重演一次。
好消息是:在 Java 生態裡,大部分的 span 你不需要自己寫。
-javaagent:opentelemetry-javaagent.jar,用 bytecode 插樁,完全不用改程式碼。它認得 Servlet、Spring MVC/WebFlux、JDBC、HttpClient、Kafka、Redis 等上百種函式庫。Observation 抽象——好處是 metrics、trace、日誌的 context 天然一致。# Spring Boot 3:加依賴 + 兩行設定就有 trace
management:
tracing:
sampling:
probability: 1.0 # 開發環境全收;正式環境交給 Collector 做 tail 取樣
otlp:
tracing:
endpoint: http://otel-collector:4318/v1/traces// 手動加 span:Spring Boot 3 最簡單的方式
@Observed(name = "risk.evaluate")
public RiskResult evaluate(Order order) { ... }
// 或用 OTel API 直接控制
Span span = tracer.spanBuilder("risk.evaluate").startSpan();
try (Scope scope = span.makeCurrent()) {
span.setAttribute("risk.rule_count", rules.size());
return doEvaluate(order);
} catch (Exception e) {
span.recordException(e);
span.setStatus(StatusCode.ERROR);
throw e;
} finally {
span.end(); // ← 忘了這行,span 永遠不會被送出
}customer.tier、order.item_count、cache.hit、retry.count。order_id、user_id)——ch01 那條規則的另一面:trace 的成本是加法。每個 span 都要建立物件、記錄時間、序列化、批次送出。過度細粒度的插樁(例如給每個 private 方法都加 span)會產生數量驚人的 span,而它們的資訊價值極低。合理的粒度是「一個有意義的操作」:一次外部呼叫、一次資料庫查詢、一段明確的業務步驟。
OpenTelemetry 的標準架構是:應用程式 → Collector → 後端。直接送到後端也可以,但那個中間層值得存在。
App (SDK) ──OTLP──> Collector ──> Tempo / Jaeger / 商用平台
│
├─ receivers(收:OTLP、Jaeger、Zipkin、Prometheus…)
├─ processors(處理:批次、取樣、脫敏、加 label)
└─ exporters(送:可以同時送去多個後端)env、cluster、region 這類環境資訊由 Collector 加,應用程式不用管。OTel 定義了一套標準屬性命名,讓不同語言、不同函式庫產生的資料能互相對得上:
http.request.method = "POST"
http.response.status_code = 500
url.path = "/api/orders"
db.system = "mysql"
messaging.system = "kafka"
service.name = "order-api" ← 最重要的一個service.name 是三本柱能不能自動關聯的關鍵。如果 metrics 裡叫 order-api、trace 裡叫 order_api、日誌裡叫 orderapi,那 Grafana 就沒辦法自動幫你從一條 trace 跳到對應的日誌——你會退回手動複製貼上的年代。ch01 說 metrics 到 trace 之間需要一座橋,exemplar 就是它:histogram 的 bucket 上,額外掛一個「落在這個桶裡的某次請求的 trace_id」。
# Spring Boot:開啟 exemplar(需要 tracing 一起啟用)
management.prometheus.metrics.export.properties.io.prometheus.exemplars.sampleIntervalMilliseconds: 1000效果是:在 Grafana 的延遲圖上,p99 那個突起會出現可以點的小點,點下去直接跳到那一次的完整 trace。從「有事發生」到「看見那一次」變成一次點擊——這是 ch01 那條排查動線裡最花時間的一步。
下單 API 的 p99 是 9 秒,使用者抱怨「按下送出之後轉圈圈很久」。團隊已經接好 trace,於是很有信心地去看:
trace_id 都是全新的,與下單那條完全對不起來。traceId 欄位是空的。ch04 說過日誌的 trace_id 與 trace 的 context 是同一個東西的兩個出口——它們一起斷了,這就是證據。traceparent 寫進訊息的 header,而 consumer 把它讀出來還原。而那 8.7 秒的真相,在補上傳播之後一眼就看到了:庫存服務的消費者單執行緒處理、積壓了 40 則訊息——時間全花在「排隊等著被處理」,而不是處理本身。這種「等待」在任何單一服務的指標上都看不出來,因為每個服務自己都很快。
// producer
propagator.inject(Context.current(), record.headers(),
(headers, k, v) -> headers.add(k, v.getBytes(UTF_8)));
// consumer:取出後用 CONSUMER kind 建立 span
Context parent = propagator.extract(Context.root(), record.headers(), getter);
Span span = tracer.spanBuilder("inventory.process")
.setSpanKind(SpanKind.CONSUMER).setParent(parent).startSpan();@Async、CompletableFuture、自建執行緒池上重演。Spring 的做法是包裝 executor:@Bean
TaskDecorator otelTaskDecorator() {
return runnable -> {
Context ctx = Context.current(); // 提交當下的 context
return () -> { try (Scope s = ctx.makeCurrent()) { runnable.run(); } };
};
}OTel 也提供 Context.taskWrapping(executor) 直接包一個 ExecutorService。service.name 三邊一致(metrics/trace/log),否則後面所有自動關聯都不會生效。@Async、執行緒池、CompletableFuture、reactive 的排程切換)parent_id 是全部的關鍵——所謂「斷了」就是某個 span 沒拿到正確的 parent。三本柱到這裡講完了。但它們全部都只回答「發生了什麼」,沒有回答「這樣算好還是不好」——下一章要處理那個問題,而那是 SRE 這個角色真正的分水嶺。
| Head-based | Tail-based | |
|---|---|---|
| 決定時機 | 請求進來的那一刻 | 整條 trace 結束之後 |
| 能不能「只留有問題的」 | 不能——決定時還不知道會不會出錯 | 可以:錯誤與慢請求 100% 保留 |
| 實作位置 | 應用程式 SDK | Collector(需緩衝所有 span) |
| 鏈路一致性 | 靠 traceparent 的取樣旗標,天然一致 | 同一 trace 的 span 必須路由到同一個 Collector |
| 成本 | 低,可預測 | Collector 需要記憶體與一致性路由 |
| 風險 | 罕見錯誤在統計上必然被丟掉 | Collector 成為需要被監控的元件 |
| 斷點 | 為什麼 | 怎麼補 |
|---|---|---|
| @Async / 執行緒池 | context 是 thread-local,提交任務時不會跟過去 | TaskDecorator 包裝,或 Context.taskWrapping(executor) |
| 訊息佇列(Kafka/MQ) | producer 沒注入 traceparent 到 header,consumer 沒取出 | 兩端用 propagator inject/extract,consumer 用 CONSUMER kind |
| 自訂協定與封裝 | 繞過自動插樁認得的介面 | 手動注入與還原 |
| 第三方 SDK | 它自己發的請求沒帶 header | 確認 SDK 是否支援 OTel,或改用可攔截的 HTTP client |
| 怎麼提早發現 | 下游服務出現大量「沒有 parent 的 SERVER/CONSUMER span」 | 對孤兒 span 佔比設監控——少見的可自動偵測的可觀測性缺陷 |
| 問題 | 用哪一柱 | 為什麼 |
|---|---|---|
| 有多少比例的請求有問題 | Metrics | trace 取樣過,比率沒有意義 |
| 這一次的 3 秒花在哪一段 | Traces | 唯一有 parent-child 與各段耗時的資料 |
| 那一次到底發生什麼細節 | Logs | 完整上下文與例外堆疊 |
| 跨服務的等待時間 | Traces | 日誌時間戳跨機器不可靠(ch04) |
| 長期趨勢 | Metrics | trace 與 log 的保留期都太短 |
點擊卡片翻面查看答案,共 13 張。