可觀測性與 SLO ch05 分散式追蹤與 OpenTelemetry:斷在最需要的那一段
下一章→
CH 05 唯一能回答時間去哪

分散式追蹤與 OpenTelemetry:斷在最需要的那一段

span 與 tracecontext 傳播head 與 tail 取樣自動與手動儀器化Collector 的價值semantic conventionsexemplar 串起三本柱

三本柱的最後一根,也是最晚成熟的一根。它回答的問題只有一個,但那個問題沒有別的工具答得出來:

這一次請求的 3 秒,是花在哪一段?

metrics 能告訴你「payment 服務的 p99 是 2 秒」,但它不知道這一次的 3 秒裡,有多少花在 payment、多少花在它呼叫的風控、多少花在排隊等連線。日誌有每個服務各自的時間戳,但跨機器的時鐘不可靠(ch04 說過)。只有 trace 明確記錄了「誰呼叫誰、各自花了多久」的父子關係。

而 trace 有一個很特別的失敗模式:它不是壞掉,是「斷掉」。前半段好好的,到某個地方就沒有了——而斷掉的位置,往往正好是最可疑的那一段(非同步、訊息佇列、執行緒池)。

這一章要處理的是:

  • span、trace、context 傳播:一條 trace 是怎麼跨越五個服務被串起來的。
  • 取樣:head-based 與 tail-based 的根本差異,以及「只留慢的和錯的」為什麼比較難做。
  • 儀器化:自動插樁能拿到什麼、什麼時候必須手動加 span。
  • Collector:為什麼多一個中間層反而讓事情變簡單。
  • semantic conventions:ch01 說三本柱需要「統一的標籤語彙」,這是它的規格。
  • 一個真實場景:trace 在 MQ 消費端斷掉,而真正的 8 秒就藏在那裡。

這一章大量回扣 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——於是它變成另一棵樹的根,跟前面完全失聯。

span kind:這一段是誰的角色

  • SERVER:我收到了一個請求(通常是一條 trace 在這個服務的入口)。
  • CLIENT:我發出了一個請求並等待回應(HTTP 呼叫、資料庫查詢)。
  • PRODUCER / CONSUMER:我送出/消費了一則訊息(非同步,兩者之間沒有「等待」關係)。
  • INTERNAL:純粹的內部區段(一段耗時的計算)。

PRODUCER/CONSUMER 之所以要獨立成一種,就是因為它們的因果關係與時間關係不同——生產者不會等消費者,所以子 span 可能在父 span 結束很久之後才發生。這個特性正是本章失敗場景的背景。

context 傳播:那條線怎麼跨過服務邊界

服務 A 呼叫服務 B 時,A 必須把「我是誰」告訴 B。標準做法是 W3C 的 traceparent HTTP header:

traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
             ↑版本 ↑trace_id                        ↑父 span_id      ↑取樣旗標
  • B 收到之後,用這個 span_id 當自己的 parent_id,於是樹接起來了。
  • 最後那個取樣旗標很重要:它讓整條鏈路的取樣決定保持一致——要嘛全留、要嘛全丟,不會出現只有一半的 trace。

在程序內,它靠什麼傳遞?

答案是 thread-local——而這正是 ch04 那個 MDC 的同一個機制。
OpenTelemetry 的 Context 綁在當前執行緒上,所以同一個執行緒裡的任何程式碼都能取得「我現在在哪個 span 底下」。

它的必然後果是:任何切換執行緒的地方,這條線都會斷。而日誌的 trace_id 也會在同一個地方一起消失——因為它們是同一個東西的兩個出口。

trace 的成本與流量成正比(ch01),而全部保留通常不可行——一個請求可能產生幾十個 span,每個 span 都要序列化、傳輸、儲存。所以取樣是必要的,而取樣策略決定了你會失去什麼。

Head-based:在請求進來的那一刻決定

if (random() < 0.01) 記錄整條 trace; else 全部丟掉;
  • 優點:簡單、成本可預測、決定會透過 traceparent 的旗標傳給下游,整條鏈路一致。
  • 致命缺點:你在「還不知道這個請求會不會出問題」的時候就決定了要不要記錄它。1% 取樣率下,一個每小時發生 5 次的錯誤,有 95% 的機率完全沒有留下任何 trace。
而你需要 trace 的時候,正好就是那些罕見的慢請求與錯誤請求——head-based 取樣在統計上必然會丟掉它們。這是它與可觀測性目標的根本矛盾。

Tail-based:等請求結束再決定

收集這條 trace 的所有 span → 等它完成 → 判斷:
  有錯誤?        → 保留
  超過 1 秒?     → 保留
  正常且快速?    → 取樣 1%
  • 優點:「有問題的全留、正常的取樣」——這才是你真正想要的。
  • 代價:必須先把所有 span 緩衝起來直到 trace 結束(通常在 Collector 做)。這需要記憶體,而且同一條 trace 的所有 span 必須路由到同一個 Collector 實例(否則它看不到全貌),這讓部署變複雜。

實務組合

入口服務   head-based 取樣 100%(先全部收下來)
     ↓
Collector  tail-based:錯誤 100%、慢請求 100%、正常 1%
     ↓
後端儲存

另外一定要有「速率上限」:不論用哪種策略,都要能在流量暴增時把 trace 產生量壓住——否則 ch04 那場「日誌壓垮平台」的故事會在 trace 這邊重演一次。

取樣造成的統計偏差

取樣過的 trace 不能拿來算比率。「trace 裡有 30% 是錯誤」——如果錯誤是 100% 保留、正常的只留 1%,這個比例毫無意義。

要算比率就用 metrics(ch02),那是它存在的理由。trace 用來回答「這一次發生了什麼」,不是「多少比例有問題」。

好消息是:在 Java 生態裡,大部分的 span 你不需要自己寫。

兩條自動化路線

  • OpenTelemetry Java Agent:-javaagent:opentelemetry-javaagent.jar,用 bytecode 插樁,完全不用改程式碼。它認得 Servlet、Spring MVC/WebFlux、JDBC、HttpClient、Kafka、Redis 等上百種函式庫。
  • Micrometer Tracing(Spring Boot 3 內建):靠 Spring 的自動組態,與 Micrometer 的 metrics 共用同一套 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

自動插樁抓不到的三類東西

1
你自己的業務邏輯區段。一個 span 顯示「這個方法花了 3 秒」,但裡面有五個步驟——你需要知道是哪一步。
2
非標準的通訊方式:自訂協定、走檔案交換、或用了 agent 不認識的函式庫。
3
跨執行緒的傳遞——這是下面失敗場景的主題。
// 手動加 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 永遠不會被送出
}

attribute 該放什麼

  • 能幫助你縮小範圍的維度:customer.tier、order.item_count、cache.hit、retry.count。
  • 高基數的識別碼在這裡是安全的(order_id、user_id)——ch01 那條規則的另一面:trace 的成本是加法。
  • 但仍然不要放個資與憑證(ch04 的合規規則在這裡同樣適用,而且 trace 也會被匯出到第三方平台)。

成本:span 不是免費的

每個 span 都要建立物件、記錄時間、序列化、批次送出。過度細粒度的插樁(例如給每個 private 方法都加 span)會產生數量驚人的 span,而它們的資訊價值極低。合理的粒度是「一個有意義的操作」:一次外部呼叫、一次資料庫查詢、一段明確的業務步驟。

OpenTelemetry 的標準架構是:應用程式 → Collector → 後端。直接送到後端也可以,但那個中間層值得存在。

App (SDK) ──OTLP──> Collector ──> Tempo / Jaeger / 商用平台
                         │
                         ├─ receivers(收:OTLP、Jaeger、Zipkin、Prometheus…)
                         ├─ processors(處理:批次、取樣、脫敏、加 label)
                         └─ exporters(送:可以同時送去多個後端)

它解決五件事

  • tail-based 取樣:需要看到整條 trace 才能決定,這件事只有集中的地方做得到。
  • 脫敏:在資料離開你的網路之前,統一移除或遮蔽敏感屬性——比要求每個服務各自做可靠得多(ch04 同樣的道理)。
  • 統一補齊標籤:env、cluster、region 這類環境資訊由 Collector 加,應用程式不用管。
  • 換後端不用改應用程式:所有服務只認得 OTLP,要從 Jaeger 換成別的平台,改 Collector 的 exporter 就好。這是 OTel 最大的實務價值:把儀器化與後端選擇解耦。
  • 緩衝與重試:後端短暫不可用時,Collector 擋著,應用程式不受影響。
順帶一提:Collector 也能收 metrics 與 logs。所以「三本柱各有一套採集管線」可以收斂成一套——這對維運成本的影響比想像中大。

semantic conventions:ch01 那個「統一標籤語彙」的規格

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 跳到對應的日誌——你會退回手動複製貼上的年代。

這件事在建置初期定好幾乎沒有成本,事後統一則要改動所有服務。

exemplar:把 metrics 與 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 顯示:gateway 50ms → order-api 總共 320ms → 結束。
  • trace 上完全看不到那 9 秒。
  • 而且 order-api 的日誌也只到「訂單已建立、事件已送出」就沒有下文了。
  • 但使用者確實等了 9 秒才看到結果。

診斷

1
先確認 9 秒是真的:從瀏覽器開發工具看,那個 HTTP 請求確實花了 9 秒。所以不是使用者的錯覺,是 trace 沒說實話。
2
看架構:下單流程是同步等待非同步結果——order-api 送出 Kafka 事件之後,會輪詢等待庫存服務處理完成才回應。
3
去庫存服務找對應的 trace——找不到。那裡有 trace,但它們的 trace_id 都是全新的,與下單那條完全對不起來。
4
看庫存服務的日誌:traceId 欄位是空的。ch04 說過日誌的 trace_id 與 trace 的 context 是同一個東西的兩個出口——它們一起斷了,這就是證據。

根因:context 沒有跨過訊息佇列

OTel 的 context 是 thread-local 的(本章第一節)。它會跟著 HTTP header 走,但不會自動跟著 Kafka 訊息走——除非 producer 把 traceparent 寫進訊息的 header,而 consumer 把它讀出來還原。

這個專案用的是自訂的訊息封裝,繞過了 OTel agent 認得的 Kafka client 介面,於是自動插樁沒有生效。結果就是:庫存服務每次消費訊息,都開了一條全新的 trace。

而那 8.7 秒的真相,在補上傳播之後一眼就看到了:庫存服務的消費者單執行緒處理、積壓了 40 則訊息——時間全花在「排隊等著被處理」,而不是處理本身。這種「等待」在任何單一服務的指標上都看不出來,因為每個服務自己都很快。

修法

1
MQ 兩端傳播 context:producer 端把 context 注入訊息 header,consumer 端取出後當作 parent。
// 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();
2
執行緒池也要補:同一個問題會在 @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。
3
加上佇列積壓的 metrics:consumer lag 與處理時間要分開量(ch01 的 USE 方法——「有人在排隊」比「使用率高」更重要)。
4
治本是架構問題:「同步等待非同步結果」本身就是可疑設計。要嘛真的非同步(回傳受理中,之後推送結果),要嘛就同步呼叫。

怎麼提早發現 trace 斷了

對「沒有 parent 的 SERVER/CONSUMER span 佔比」設監控。正常情況下,只有真正的入口服務會產生根 span;如果某個下游服務出現大量根 span,那就是 context 傳播斷了。

這是一個少見的、可以被自動偵測的可觀測性缺陷——而多數團隊是在事故當中才發現自己的 trace 是斷的。

導入順序

1
先開自動插樁(Java agent 或 Spring Boot 3 的 Micrometer Tracing),開發環境取樣 100%。這一步的投報比最高,幾乎不用改程式碼。
2
確認 service.name 三邊一致(metrics/trace/log),否則後面所有自動關聯都不會生效。
3
把 trace_id 接進日誌(ch04 的 MDC)——這是最便宜的一座橋。
4
部署 Collector,做批次、脫敏、tail 取樣。應用程式只認 OTLP。
5
補非同步邊界的傳播(MQ、執行緒池)——這一步必須手動確認,自動插樁常常蓋不到。
6
開 exemplar,把 metrics 與 trace 焊起來。
7
最後才加業務層的手動 span——等你知道哪裡是黑洞了再加,不要一開始就到處插。

怎麼驗證它真的通了(三個檢查)

  • 端到端測試:發一個請求,確認產生的是一條 trace 而不是三條。這件事可以寫成整合測試——與 db ch08 那個「斷言 SQL 條數」是同一種思路。
  • 檢查孤兒 span:下游服務有沒有大量沒有 parent 的 SERVER/CONSUMER span(上一節)。
  • 從 metrics 點到 trace 再點到 log:親自走一次 ch01 那條動線。走不通的那一段,就是你還沒接好的那座橋。

什麼時候不需要 trace

  • 單體服務、沒有外部依賴:一個 profiler 或 APM 的方法級分析會更有用。trace 的價值與「服務數量」和「呼叫深度」成正比。
  • 批次處理系統:trace 的模型是「一次請求」,對「處理一億筆資料」這種工作,metrics 與日誌更合適。
  • 還沒有 metrics 的時候:順序是 metrics → log → trace。連「有沒有事」都不知道的時候,先做 trace 是本末倒置。

四筆帳

  • 執行期開銷:span 的建立、attribute 記錄、序列化、批次送出。單一 span 很便宜,但過度細粒度的插樁會讓它變成可觀的 CPU 與記憶體成本。
  • 儲存成本:與 span 數 × 取樣率成正比。一個請求 30 個 span 的系統,全量保留幾乎不可能。
  • Collector 的維運:tail-based 取樣需要記憶體與一致性路由,它自己也會成為需要被監控的元件。
  • 認知成本:一條有 200 個 span 的 trace,人是讀不完的。span 太多與 span 太少一樣沒用。

三個盲點

  • 取樣過的 trace 不能算比率(本章講過)——那是 metrics 的工作。
  • 看不到「沒有發生的呼叫」:某個條件分支沒進去、某個服務根本沒被呼叫——trace 上只是少了一段,而「少了一段」與「這條路徑本來就不會走」長得一模一樣。
  • 時鐘偏差會讓瀑布圖失真:跨機器的 span 時間戳依賴各自的系統時鐘。偏差幾十毫秒是常態,所以不要用瀑布圖上的微小間隙推論因果。

而最需要記住的是:斷點的位置

trace 幾乎不會「壞掉」,它只會「斷掉」。而它斷掉的地方永遠是這幾個:
  • 執行緒切換(@Async、執行緒池、CompletableFuture、reactive 的排程切換)
  • 訊息佇列(producer 沒注入 / consumer 沒取出)
  • 自訂協定與封裝(繞過了自動插樁認得的介面)
  • 第三方 SDK(它自己發的 HTTP 請求可能沒帶 header)
而這些地方,恰好也是分散式系統最容易出問題的地方。換句話說:trace 最容易斷的位置,與最需要 trace 的位置高度重疊。

把這一章收成三句

① trace 回答「這一次的時間花在哪一段」,而 parent_id 是全部的關鍵——所謂「斷了」就是某個 span 沒拿到正確的 parent。
② head-based 取樣會在統計上丟掉你最需要的那些罕見錯誤,所以要用 Collector 做 tail-based:有問題的全留、正常的取樣。
③ context 是 thread-local,所以它必定在執行緒切換與 MQ 邊界斷掉——而那正是最需要它的地方,必須手動補。

三本柱到這裡講完了。但它們全部都只回答「發生了什麼」,沒有回答「這樣算好還是不好」——下一章要處理那個問題,而那是 SRE 這個角色真正的分水嶺。

head-based vs tail-based 取樣
Head-basedTail-based
決定時機請求進來的那一刻整條 trace 結束之後
能不能「只留有問題的」不能——決定時還不知道會不會出錯可以:錯誤與慢請求 100% 保留
實作位置應用程式 SDKCollector(需緩衝所有 span)
鏈路一致性靠 traceparent 的取樣旗標,天然一致同一 trace 的 span 必須路由到同一個 Collector
成本低,可預測Collector 需要記憶體與一致性路由
風險罕見錯誤在統計上必然被丟掉Collector 成為需要被監控的元件
context 會在哪裡斷掉
斷點為什麼怎麼補
@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 佔比設監控——少見的可自動偵測的可觀測性缺陷
三本柱的分工(本章補完)
問題用哪一柱為什麼
有多少比例的請求有問題Metricstrace 取樣過,比率沒有意義
這一次的 3 秒花在哪一段Traces唯一有 parent-child 與各段耗時的資料
那一次到底發生什麼細節Logs完整上下文與例外堆疊
跨服務的等待時間Traces日誌時間戳跨機器不可靠(ch04)
長期趨勢Metricstrace 與 log 的保留期都太短

練習題 點選選項查看解析

0 / 10
01 / 10
一條 trace 能被重建成樹狀瀑布圖,關鍵靠的是什麼?
A 每個 span 的時間戳
B 每個 span 的 parent_id——所謂「trace 斷了」就是某個 span 沒拿到正確的 parent,變成另一棵樹的根
C service.name 屬性
D span 的名稱
解析
trace_id 只是把 span 歸到同一次請求,parent_id 才建立了「誰呼叫誰」的因果結構。而跨服務時,parent_id 是透過 W3C 的 traceparent header 傳過去的。
02 / 10
traceparent header 裡的取樣旗標有什麼作用?
A 標記這條 trace 的優先級
B 讓整條鏈路的取樣決定保持一致——要嘛全留要嘛全丟,不會出現只有一半的 trace
C 指定取樣率
D 標記是否為測試流量
解析
這是 head-based 取樣的重要特性:決定在入口做一次,透過 header 傳給所有下游。缺點是決定時還不知道這個請求會不會出問題。
03 / 10
head-based 取樣與可觀測性目標的根本矛盾是什麼?
A 它的成本太高
B 它在「還不知道請求會不會出問題」時就決定要不要記錄——而你需要 trace 的正是那些罕見的慢請求與錯誤,1% 取樣下它們幾乎必然被丟掉
C 它無法跨服務保持一致
D 它需要修改應用程式
解析
1% 取樣率下,一個每小時發生 5 次的錯誤有 95% 機率完全沒留下 trace。解法是 Collector 做 tail-based:等 trace 結束再決定,錯誤與慢請求 100% 保留、正常的取樣 1%。
04 / 10
為什麼「trace 裡有 30% 是錯誤」這個統計沒有意義?
A 因為 trace 資料不準確
B 因為取樣是有偏的(錯誤 100% 保留、正常只留 1%),要算比率必須用 metrics
C 因為 trace 保留期太短
D 因為 30% 太高不合理
解析
trace 用來回答「這一次發生了什麼」,不是「多少比例有問題」。用錯工具的結果是得到一個看起來精確、實際上被取樣策略決定的數字。
05 / 10
OTel 的 context 在程序內是靠什麼傳遞?這帶來什麼必然後果?
A 靠靜態變數,沒有限制
B 靠 thread-local,所以任何切換執行緒的地方(@Async、執行緒池、MQ)這條線都會斷——而日誌的 trace_id 也在同一個地方一起消失
C 靠 Spring 的 ApplicationContext
D 靠 HTTP header,程序內不需要傳遞
解析
這與 ch04 的 MDC 是同一個機制——它們是同一個東西的兩個出口。所以「日誌的 traceId 欄位是空的」正是 trace 斷掉的證據。
06 / 10
trace 顯示 order-api 只花了 320ms,但使用者實測等了 9 秒。最可能的原因是什麼?
A trace 的時間戳不準
B context 沒有跨過某個邊界(例如 MQ),那 8 秒發生在一條「全新的、對不起來的」trace 裡
C 取樣把慢請求丟掉了
D 使用者的網路很慢
解析
本章的失敗場景:producer 沒把 traceparent 注入 Kafka header,consumer 每次消費都開一條新 trace。而真相是消費者單執行緒處理、積壓 40 則訊息——時間全花在排隊,這種「等待」在任何單一服務的指標上都看不出來。
07 / 10
要讓 trace 跨過 Kafka,兩端各要做什麼?
A 只要在 producer 端設定即可
B producer 用 propagator.inject 把 context 寫進訊息 header;consumer 用 extract 取出當作 parent,並以 CONSUMER kind 建立 span
C 在 Kafka broker 設定啟用追蹤
D 使用同一個 trace_id 命名規則
解析
同一個問題會在 @Async、CompletableFuture、自建執行緒池重演,那邊的解法是用 TaskDecorator 包裝或 Context.taskWrapping(executor)。自動插樁常常蓋不到這些地方,必須手動確認。
08 / 10
怎麼「提早」發現 trace 的 context 傳播斷了,而不是在事故當中才發現?
A 定期人工檢查幾條 trace
B 監控「沒有 parent 的 SERVER/CONSUMER span 佔比」——正常只有入口服務會產生根 span,下游出現大量根 span 就是斷了
C 檢查 Collector 的錯誤日誌
D 比對 trace 數量與請求數量
解析
這是少見的、可以被自動偵測的可觀測性缺陷。另外端到端測試也可以斷言「一個請求應該產生一條 trace 而不是三條」——與 db ch08 斷言 SQL 條數是同一種思路。
09 / 10
OpenTelemetry Collector 最大的實務價值是什麼?
A 減少網路流量
B 把儀器化與後端選擇解耦——所有服務只認 OTLP,換後端只要改 Collector 的 exporter;順帶還能做 tail 取樣、脫敏、統一補標籤
C 提高 trace 的精確度
D 自動修復斷掉的 context
解析
另外 tail-based 取樣本來就只有集中的地方做得到(需要看到整條 trace),脫敏在資料離開網路前統一做也比要求每個服務各自做可靠。Collector 也能收 metrics 與 logs,三本柱的採集管線可以收斂成一套。
10 / 10
什麼情況下不需要急著導入 trace?
A 微服務數量超過十個時
B 單體服務且沒有外部依賴、批次處理系統、或連 metrics 都還沒有的時候——順序是 metrics → log → trace
C 團隊規模較小時
D 流量很大時
解析
trace 的價值與服務數量、呼叫深度成正比。單體服務用 profiler 或方法級 APM 更有用;批次系統的模型不是「一次請求」。而連「有沒有事」都不知道就先做 trace 是本末倒置。

點擊卡片翻面查看答案,共 13 張。

QUESTION
trace 能被重建成瀑布圖的關鍵欄位是什麼?「斷了」的定義是什麼?
點擊翻面
ANSWER
parent_id。trace_id 只是把 span 歸到同一次請求,parent_id 才建立因果結構。「斷了」就是某個 span 沒拿到正確的 parent,於是變成另一棵樹的根,與前面完全失聯。
點擊翻回
QUESTION
span kind 有哪幾種?為什麼 PRODUCER/CONSUMER 要獨立?
點擊翻面
ANSWER
SERVER(收到請求)、CLIENT(發出並等待)、PRODUCER/CONSUMER(送出/消費訊息)、INTERNAL(內部區段)。PRODUCER 不會等 CONSUMER,所以子 span 可能在父 span 結束很久之後才發生——因果與時間關係不同。
點擊翻回
QUESTION
跨服務的 context 靠什麼傳遞?裡面有什麼?
點擊翻面
ANSWER
W3C 的 traceparent header:版本-trace_id-父 span_id-取樣旗標。最後那個旗標讓整條鏈路的取樣決定一致,不會出現只有一半的 trace。
點擊翻回
QUESTION
head-based 取樣的根本問題是什麼?
點擊翻面
ANSWER
在「還不知道請求會不會出問題」時就決定要不要記錄。而你需要 trace 的正是罕見的慢請求與錯誤——1% 取樣下,每小時 5 次的錯誤有 95% 機率完全沒留下 trace。
點擊翻回
QUESTION
tail-based 取樣的做法與代價?
點擊翻面
ANSWER
等整條 trace 結束再決定:錯誤與慢請求 100% 保留、正常的取樣 1%。代價是必須先緩衝所有 span(通常在 Collector),需要記憶體,且同一 trace 的 span 必須路由到同一個 Collector 實例。
點擊翻回
QUESTION
為什麼不能用 trace 算「錯誤比例」?
點擊翻面
ANSWER
取樣是有偏的(錯誤全留、正常只留 1%),算出來的比率沒有意義。要算比率用 metrics——trace 回答「這一次發生了什麼」,不是「多少比例有問題」。
點擊翻回
QUESTION
OTel context 在程序內靠什麼傳遞?必然的後果是什麼?
點擊翻面
ANSWER
thread-local(與 ch04 的 MDC 同一機制)。所以任何切換執行緒的地方都會斷,而日誌的 trace_id 會在同一個地方一起消失——「日誌 traceId 為空」就是 trace 斷掉的證據。
點擊翻回
QUESTION
context 最常在哪四個地方斷掉?
點擊翻面
ANSWER
①執行緒切換(@Async、執行緒池、CompletableFuture)②訊息佇列(producer 沒注入/consumer 沒取出)③自訂協定與封裝(繞過自動插樁)④第三方 SDK。而這些正好也是分散式系統最容易出問題的地方。
點擊翻回
QUESTION
怎麼提早發現 context 傳播斷了?
點擊翻面
ANSWER
監控「沒有 parent 的 SERVER/CONSUMER span 佔比」——正常只有入口服務產生根 span,下游出現大量根 span 就是斷了。也可以寫整合測試斷言「一個請求產生一條 trace」。
點擊翻回
QUESTION
自動插樁抓不到哪三類東西?
點擊翻面
ANSWER
①自己的業務邏輯區段(知道方法花 3 秒,但不知道是裡面哪一步)②非標準通訊方式與自訂封裝 ③跨執行緒的傳遞。前兩者用手動 span,第三者要包裝 executor。
點擊翻回
QUESTION
OTel Collector 解決哪五件事?
點擊翻面
ANSWER
①tail-based 取樣(只有集中處做得到)②脫敏(資料離開網路前統一處理)③統一補環境標籤 ④換後端不用改應用(儀器化與後端解耦,最大價值)⑤緩衝與重試。它也能收 metrics 與 logs。
點擊翻回
QUESTION
semantic conventions 裡最重要的一個屬性是什麼?為什麼?
點擊翻面
ANSWER
service.name。三本柱能不能自動關聯全靠它——如果 metrics 叫 order-api、trace 叫 order_api、log 叫 orderapi,Grafana 就沒辦法自動從 trace 跳到日誌。建置初期定好幾乎沒成本,事後統一要改所有服務。
點擊翻回
QUESTION
exemplar 做什麼?效果是什麼?
點擊翻面
ANSWER
在 histogram 的 bucket 上掛一個「落在這個桶裡的某次請求的 trace_id」。效果是 Grafana 延遲圖上 p99 的突起會出現可點的小點,一鍵跳到那一次的完整 trace——把 ch01 排查動線裡最花時間的一步變成一次點擊。
點擊翻回