JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

正式環境中的結構化與分散式追蹤

這一級的第一篇導引把追蹤點名為三根支柱之一;其餘幾篇展示了如何從內部觀察一台機器。這篇收尾之作把鏡頭拉遠,面對最難的情形——一個橫跨多台機器的請求——並一片一片地搭起讓你事後重播它整段旅程的機制:一筆結構化日誌、一個 span 的解剖、一個穿過行程邊界傳遞的上下文 id,以及讓這一切負擔得起的取樣。

首先,把日誌從句子變成一筆紀錄

在我們跨越機器之前,先修好那卑微的一行日誌,因為其餘的一切都建在它之上。在除錯那一級你印出的是給人看的句子:「payment failed for order 4192」。當一個人讀一個畫面時這沒問題,但當上百萬條這樣的行落進一個搜尋系統、而你需要某個區域、某五分鐘窗內、所有超過某個大小的訂單的每一筆失敗時,它就毫無用處。問題在於一個句子沒有欄位——對機器而言它是一團不透明的字元。結構化日誌(structured logging)就是那個簡單卻承重的修法:把每個事件發成一筆有命名鍵值對的紀錄,而非散文。

unstructured (a sentence, one opaque blob):
  2026-06-24T03:14:07 ERROR payment failed for order 4192, code 0x2

structured (named fields a machine can filter and join):
  {
    "ts":       "2026-06-24T03:14:07Z",
    "level":    "error",
    "event":    "payment_failed",
    "order_id": 4192,
    "amount":   18999,
    "region":   "eu-west",
    "code":     "0x2",
    "trace_id": "0x7af3c1...",
    "span_id":  "0x91b0"
  }
同一個事件的兩種寫法。結構化形式讓一條查詢能用一句話說 level=error AND amount>10000 AND region=eu-west。注意最後兩個欄位:trace_id 與 span_id,稍後正是它們把這一行日誌黏回它所屬請求的更大故事裡。

格式本身遠不及那份紀律重要。JSON 很常見,但 key=value 行與二進位編碼也是;要緊的是欄位有命名、且跨服務一致,好讓 amount 在每個地方都是同一個意思、查詢引擎能據它樞轉。回想第一篇導引:日誌保留了指標所扔掉的逐事件細節——而把它結構化,正是讓那份細節在規模下可查詢、而非一面你用 grep 翻找並祈禱的文字牆。這也是接上那兩個識別碼的天然接縫,正是這兩個碼把一條孤零零的日誌變成一條追蹤的一部分,而那就是我們要去的方向。

一個 span 的解剖

第一篇導引把分散式追蹤介紹成一棵跟著單一請求的 span 樹。現在讓我們打開 span 本身,因為它是一個精確的小物件,而追蹤的大半就只是正確地建立並串連這些東西。一個 span 代表一個有持續時間的工作單元——一次資料庫查詢、一次對外的 HTTP 呼叫、一塊運算。具體上它帶著:一個 span_id(此 span 獨有)、一個 trace_id(同一請求中每個 span 共用)、一個 parent_span_id(引發這份工作的那份工作)、一個名稱、一個開始時間與結束時間,以及一袋屬性——又是你的結構化欄位:db.statement、http.status_code、region。

那三個 id 就是全部的把戲。span_id 為這個節點命名;parent_span_id 是通往它父節點的那條邊;trace_id 標示它屬於哪一棵樹。給每個 span 同一個 trace_id 與一個正確的 parent_span_id,你根本沒有儲存一棵樹——你儲存的是一張扁平的 span 清單,每個各自指向它的父節點,而當檢視器依 trace_id 分組、並把子連到父時,那棵樹會自己重建出來。這就是為什麼一個 span 能從四十台機器各自獨立發出、緩衝、分開傳送,卻仍能重新組裝成一幅連貫的圖:結構住在那些 id 裡,而不在任何中央協調者身上。這正是第一篇導引稱為關聯之脊樑的那個串 id 的想法,如今變得具體了。

用四個共用 trace_id 0x7af3c1 的 span 把它變具體。span A 是 web-frontend,沒有父節點、持續 12 毫秒;span B 是 auth,父節點是 A、持續 3 毫秒;span C 是 cart,父節點是 A、持續 45 毫秒;span D 是 db-query,父節點是 C、持續 41 毫秒,帶著屬性 db.rows=0。存到磁碟上那就只是四列扁平的紀錄,每一列點名自己的父節點、且可能由不同機器寫下。檢視器依 trace_id 把它們分組、順著父連結,畫出 A 分岔到 B 與 C、C 再往下到 D——而那個形狀立刻顯示 cart 的 45 毫秒裡有 41 毫秒是那次資料庫查詢。沒有任何中央協調者畫出那棵樹;是那些 parent_span_id 的邊畫的。

傳播:把上下文帶過行程邊界

以下這個問題讓分散式追蹤真正變難,而它是一個你如今有能力體會的系統問題。在單一行程內,把當前的 trace_id 與 span_id 一個函式傳給下一個函式很容易——它們躺在執行緒區域儲存裡,或沿著呼叫鏈傳遞。但當 web 服務對 cart 服務發出一個對外呼叫的那一刻,那是另一個行程、極可能在另一台機器上、透過一個通訊端抵達。你那些在記憶體裡的上下文不會自己越過那條線。trace_id 與呼叫端的 span_id 必須被序列化進那個請求、在另一端再被讀回來,否則 cart 服務上的子 span 將完全不知道自己屬於哪一條追蹤。

這一步叫做上下文傳播(context propagation),而對 HTTP 來說慣例簡單到不行:呼叫端把那些 id 寫進請求標頭,被呼叫端把它們讀出來。W3C 的標準標頭是 traceparent,帶著一個版本、trace_id、呼叫端的 span_id(它會成為子節點的 parent_span_id),以及一些旗標。接收端服務解析那個標頭,用承襲來的 trace_id 與承襲來的父節點開始它自己的 span,於是鏈條繼續——橫跨這個請求所經的任意多跳。這個機制刻意樸實無華;它就只是一個字串,搭在你本來就在說的那個協定的中介資料裡。但這恰恰是這一級總體警告所瞄準的邊界:一條追蹤的連貫程度,只取決於它最弱的那一跳,而任何丟掉或忘記轉發那個標頭的服務,都會默默地破壞它底下的那棵樹。

你負擔不起追蹤一切:取樣

現在成本重新登場,恰如第一篇導引以觀察者效應所預告的。一個繁忙的服務每秒處理上萬個請求;為其中每一個都記下完整的 span 樹,會產生比實際工作還多的追蹤資料、把傳送它的網路撐爆、並花一筆鉅款去儲存。所以在正式環境你幾乎從不追蹤每一個請求——你取樣(sample)。這份誠實的張力是真的:一條你沒記下的追蹤,就是一條你日後無法檢視的追蹤,所以取樣是刻意扔掉證據以讓系統負擔得起,而整門手藝就是扔掉無聊的證據、留住有趣的那種。

有兩大策略,差別在於你何時下決定。前端取樣(head-based sampling)在最開頭那個 span、請求都還沒完成時就決定:在大門口擲一次骰子,說「百中取一」,並把這個留或丟的決定沿著追蹤旗標傳播下去,好讓每一個下游跳轉都遵從它、讓追蹤保持完整。它便宜又簡單,但它是盲的——它在還不知道這個請求會不會是那個慢的、或那個失敗的之前,就已下了承諾。尾端取樣(tail-based sampling)緩衝一條追蹤的所有 span,在它完成之後才決定,於是它能留下每一條出錯或超過某延遲門檻的追蹤,並丟掉那些無聊的快速成功。那對追查尾端延遲與錯誤有用得多,但它要花真金白銀的記憶體與基礎設施,把每一條進行中的追蹤多留住一陣子,久到足以對它做判斷。

取樣正是追蹤與指標是夥伴而非對手的原因,也是為什麼三根支柱各自值得存在。因為你對追蹤做了取樣,單憑追蹤你並沒有一個可信的總請求計數——百中取一的取樣依設計就低估了一切。所以你仍然保留一個未取樣的指標來算速率與錯誤百分比:那個又便宜又聚合的指標,可靠地告訴你橫跨所有請求的錯誤率「翻倍了」;而你留下的那批失敗追蹤的樣本,則在你確實保留的那些範例上告訴你「為什麼」。要可信的聚合就拿指標,要因果的細節就拿追蹤;它們刻意回答不同的問題。

把它組起來,以及誠實的界限

把一次真實的調查從頭走到尾,看每個零件如何咬合。一個監控警報(一個越過門檻的指標)呼叫你:結帳的 p99 延遲翻倍了。你打開延遲圖、點下那個尖峰,樞轉到那段時間裡那些被取樣保留下來的慢追蹤——尾端取樣確保了慢的那些活了下來。你挑一條追蹤、看它的 span 樹,時間明明白白不在 web 層也不在 auth,而在單一一個 db-query span,它在請求的 45 毫秒裡佔了 41。你點那個 span,靠它的 trace_id 與 span_id 接合,跳到它發出的結構化日誌,讀到 db.rows=0 配著一個重試迴圈——那次查詢正在掃描一張剛剛被刪掉索引的資料表。指標到追蹤到日誌,每一跳都是在一個共用 id 上的樞轉:這就是整個一級一路搭建所朝向的那條從症狀到根因的路徑

讓這一切在一個多語言機群上運作的開放標準是 OpenTelemetry——一套廠商中立的 API、函式庫,以及一個用一致中介資料發出全部三根支柱的線上格式(wire format),好讓一個用某語言寫的服務產生的 span,與另一個服務產生的日誌,仍能在同一個 trace_id 上接合。它的價值不在於它發明了追蹤;而在於它標準化了欄位名與傳播標頭,好讓關聯真的能在從不曾協調過的團隊之間發生。你不需要它也能領會這裡的任何想法,但正是它讓一套現代堆疊能提供那種「點穿尖峰」的體驗,而不是四十種永遠接不起來、互不相容的追蹤方言。