可擴展至跨服務與工作的結構化日誌
一旦請求跨越服務,自由格式的日誌便會中斷。本文將說明 JSON 日誌、一個關聯 ID,以及一個共用結構描述如何讓分散式請求變得可追蹤。
單一服務搭配單一本日誌檔案,除錯起來很簡單。你用 grep 搜尋錯誤字串,閱讀其前後的幾行,就能了解來龍去脈。一旦請求觸及第二個服務、將工作交給非同步 worker,或產生一個背景作業,這種模式就失效了。現在,單一請求的來龍去脈分散在三個日誌串流中,與數千行無關的內容交錯在一起,而且沒有任何線索能將它們串連起來。解決方法不是記錄更多的日誌。而是將日誌記錄為結構化資料,並附上一個共用識別碼,讓它跟著請求到任何地方。這篇文章將展示如何達成這個目標:使用 JSON 日誌而非純文字句子、在進入點產生並透過每個躍點傳播的關聯 ID,以及所有服務都同意的一套綱要。
為何自由格式的文字日誌無法擴展
像 user 4021 failed to charge card, retrying 這樣的一行日誌,對人類來說很容易閱讀。但對日誌系統而言,它是一個不透明的字串。如果你想回答「過去一小時內,年費方案使用者發生了多少次扣款失敗」,你是辦不到的,因為「年費方案」和「扣款失敗」這些資訊被埋藏在每次日誌呼叫都不同的文字敘述中。一位工程師寫下 charge failed,另一位寫 payment declined,第三位則寫 could not bill。它們指的是同一個事件,但沒有任何查詢可以將它們歸為一組。
當一個請求橫跨多個服務時,更深層的問題就出現了。一個 API 伺服器接收到請求,呼叫一個帳務服務,該服務再將一個任務排入佇列給一個 worker 處理。在 worker 中發生了某些失敗。你得到了 worker 的錯誤訊息,但沒有任何東西能將它與原始請求、使用者或啟動這一切的 API 呼叫連結起來。你最終只能透過時間戳記和猜測來進行關聯,捲動三個終端機視窗,希望時間點能夠對得上。在低流量時,這很惱人。在實際流量下,這是不可能的,因為在同一秒內,每個串流中都交錯著數十個不相關的請求。
自由格式的日誌假設會由人類按順序閱讀。分散式系統打破了這兩個假設:沒有什麼是按順序的,而且也沒有人能閱讀如此龐大的資料量。日誌必須轉變為可查詢的資料。
日誌是資料,不是句子
心態上的轉變是這樣的:一行日誌不是給人讀的訊息,而是給機器查詢的紀錄。您不是將值格式化成句子,而是發出欄位。事件名稱是一個欄位。使用者 ID 是一個欄位。結果是一個欄位。訊息(如果有的話)只是另一個欄位,而且不是重要的那個。
以下是同一個收費失敗事件,分別以自由格式文字和結構化資料呈現。
| 方面 | 自由格式文字 | 結構化 (JSON) |
|---|---|---|
| 形式 | user 4021 failed to charge, retrying |
{"event":"charge_failed","user_id":4021,"retry":true} |
| 依事件篩選 | 子字串比對,脆弱 | event = "charge_failed" |
| 彙總 | 不可能 | count() group by user_plan |
| 跨服務串接 | 猜測時間戳 | join on trace_id |
| 結構 | 因作者而異 | 一組約定好的欄位 |
| 讀取對象 | 人類,按順序 | 查詢引擎,任何順序 |
一旦日誌成為欄位,日誌收集器就會完成您過去用肉眼所做的工作。您可以篩選單一事件類型、依任何欄位分組、計算某個時間範圍內的數量,並將結果製成圖表。「過去一小時內,年費方案使用者的收費失敗次數有多少」這個問題,變成了一個篩選和分組操作,而不是一個不可能的 grep。
在 Go 中,標準函式庫提供了 log/slog 正是為此目的。其關鍵見解是,您是以具型別的鍵值對來附加值,而不是以內插字串的方式。
import "log/slog"
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
// Not this. The values are trapped inside a sentence.
// logger.Info(fmt.Sprintf("user %d failed to charge, retry=%v", userID, true))
// This. Every value is a field the collector can query.
logger.Info("charge failed",
slog.String("event", "charge_failed"),
slog.Int("user_id", userID),
slog.String("plan", "annual"),
slog.Bool("retry", true),
)
輸出為每行一個 JSON 物件,每個日誌收集器都能原生解析:
{"time":"2026-07-17T09:14:22+09:00","level":"INFO","msg":"charge failed","event":"charge_failed","user_id":4021,"plan":"annual","retry":true}
請注意 event 欄位與 msg 是分開的。訊息是人類易讀的文字,可以自由變更。event 是一個穩定的機器可讀鍵,您可用它來查詢,而且它絕不能變動。事件名稱一旦選定,就應將其視為一個 API。
將請求串連起來的關聯 ID
結構化欄位讓單一服務可供查詢。關聯 ID 讓整個系統可供追蹤。這個概念很簡單:當請求首次進入您的系統時,為其產生一個唯一的 ID。然後將該 ID 附加到每一行日誌、每一次下游服務呼叫,以及請求衍生的每一個工作中。要重構單一請求在所有服務中的完整歷程,您只需篩選一個值。
這個 ID 有幾個名稱,例如 trace_id、request_id、correlation_id。重要的是,它在邊緣只被建立一次,且永不重新產生。進入點,通常是 HTTP 中介軟體,是它誕生的地方。
// Middleware: reuse an inbound trace ID if a trusted upstream sent one,
// otherwise mint a fresh one. Store it on the context so every log call
// downstream can read it.
type ctxKey string
const traceIDKey ctxKey = "trace_id"
func TraceMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
traceID := r.Header.Get("X-Trace-Id")
if traceID == "" {
traceID = newTraceID() // e.g. a random 16-byte hex string
}
ctx := context.WithValue(r.Context(), traceIDKey, traceID)
// Bind the trace ID into a logger stored on the context, so no
// call site has to remember to pass it.
reqLogger := slog.With(slog.String("trace_id", traceID))
ctx = context.WithValue(ctx, loggerKey, reqLogger)
w.Header().Set("X-Trace-Id", traceID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Every handler pulls the request-scoped logger back off the context.
func loggerFrom(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(loggerKey).(*slog.Logger); ok {
return l
}
return slog.Default()
}
現在,請求內的任何日誌呼叫都會自動帶有追蹤 ID,因為 logger 已在邊緣與其綁定過一次。
func chargeHandler(w http.ResponseWriter, r *http.Request) {
log := loggerFrom(r.Context())
log.Info("charge started", slog.String("event", "charge_started"))
// ... every line from here carries the same trace_id
}
跨服務與 worker 邊界傳播 ID
一個只存在於單一服務內的 ID,在邊界上毫無用處。重點在於它能在躍點之間存活下來。有兩種躍點需要處理:對另一個服務的同步呼叫,以及對 worker 或 job 的非同步交接。
對於同步的 HTTP 呼叫,呼叫端從其脈絡中讀取追蹤 ID,並將其寫入一個外送的標頭中。被呼叫端的中介軟體會讀取同一個標頭,這就是為什麼上述的中介軟體在鑄造新 ID 之前會檢查 X-Trace-Id。
// Outbound call: carry the trace ID forward as a header.
func callBilling(ctx context.Context, body io.Reader) (*http.Response, error) {
req, _ := http.NewRequestWithContext(ctx, http.MethodPost, billingURL, body)
if id, ok := ctx.Value(traceIDKey).(string); ok {
req.Header.Set("X-Trace-Id", id)
}
return http.DefaultClient.Do(req)
}
對於非同步交接,標頭技巧並不適用,因為 worker 會在稍後執行,且與呼叫者之間沒有即時連線。所以,ID 會在訊息內部傳遞。你將它放入 job 的 payload 中,然後 worker 會將其取出,並以與 middleware 相同的方式重新綁定其 logger。
// Enqueue: the trace ID rides along inside the job payload.
type ChargeJob struct {
TraceID string `json:"trace_id"`
UserID int `json:"user_id"`
}
func enqueueCharge(ctx context.Context, q Queue, userID int) error {
id, _ := ctx.Value(traceIDKey).(string)
return q.Push(ChargeJob{TraceID: id, UserID: userID})
}
// Worker: rebuild the request-scoped logger from the payload, so the
// worker's logs share the trace_id of the request that queued the job.
func (w *Worker) handle(job ChargeJob) {
log := slog.With(slog.String("trace_id", job.TraceID))
log.Info("charge job picked up", slog.String("event", "charge_job_started"))
// ... the worker's failure now joins to the original API request
}
如此一來,一個 trace_id 會將 API 日誌、計費服務日誌以及 worker 日誌連結起來。當 worker 失敗時,您可以根據該 ID 篩選每個串流,並依序讀取請求的完整路徑,跨越處理程序邊界,就好像它是在單一的日誌檔案中執行一樣。
一個綱要,一個時間基準,處處適用
只有當每個服務都同意使用相同的欄位時,傳播才有價值。如果 API 伺服器寫入 trace_id,而 worker 寫入 traceId,帳單服務又寫入 request_id,你就無法將它們關聯起來,只能回到猜測的狀態。共享的綱要才能讓跨服務的查詢真正發揮作用。請同意一小組基礎欄位,讓每個服務在每一行都發出:
timestamp,整個叢集使用同一個時間基準。偏好使用 UTC 或單一固定時區,這樣來自不同主機的紀錄行才能正確排序。混合的本地時區會讓排序變得不可信。level,嚴重性。service,發出紀錄的服務名稱,這樣你就可以從合併的串流中篩選出單一服務。event,用於表示發生了什麼事的穩定機器金鑰。trace_id,來自上述的關聯 ID。
除此之外,每個事件會新增自己的欄位。基礎集合是合約;額外的部分則是自由發揮。強制執行基礎集合最省力的方式,是將其建置到一個共享的 logger 建構函式中,讓每個服務都匯入,而不是相信每個團隊都會記得。如果欄位名稱存在於同一個函式庫中,它們就不會產生分歧。
時間基準值得特別強調,因為它是最常見的無聲失敗。當一台主機以本地時間記錄日誌,而另一台以 UTC 記錄時,合併後的檢視會錯誤地排序事件,一個實際上花了 200 毫秒的請求,可能會看起來像是時間倒流。為機器時間戳選擇一個時區,並且絕不偏離。在儀表板中讀取時才渲染成友善的本地時間,而不是在寫入日誌時。
層級紀律與避免洩漏機密
一旦系統就緒,有兩條規則能確保其可用性。
首先,謹慎使用日誌層級。error 應代表需要人為介入。如果例行性、可恢復的情況也以 error 層級記錄,該層級就失去了意義,基於此層級的警報會變成「狼來了」,人們也會學會忽略這個絕不應被忽略的信號。warn 用於可恢復但值得注意的情況,info 用於請求的正常敘述,而 debug 則用於追查特定問題時才需要的細節。重試成功應是 info 或 warn,而非 error。將 error 保留給那些確實失敗且維持失敗狀態的結果。
其次,絕不記錄機密或個人資料。結構化日誌讓這個陷阱變得更糟,而不是更好,因為將整個結構體(struct)傾印為欄位實在太容易了。那個結構體可能包含密碼雜湊、存取權杖、完整的卡號、電子郵件地址。日誌會被傳送到第三方收集器,保留數月,並由絕不應看到原始個人資料的人員讀取,因此日誌中的一行機密就是一個等待被發現的漏洞。在日誌記錄的邊界遮蔽或移除敏感欄位。記錄使用者 ID,絕不記錄電子郵件。記錄權杖存在與否,絕不記錄權杖本身。當您記錄錯誤物件時,請確保它不包含觸發該錯誤的請求主體。
結構化日誌的成本
這一切都不是免費的,而且點明其代價是值得的,這樣才能審慎地做出權衡取捨。撰寫結構化日誌比 printf 更為冗長,因為你必須為每個欄位命名,而不是僅僅將值放入一個句子中。將追蹤 ID (trace ID) 貫穿每個服務呼叫和工作負載 (job payload),是你必須建立和維護的串接機制,而只要有一個服務忘記傳遞這個 ID,就會在你最不樂見的地方產生一個盲點。JSON 日誌也比純文字日誌更大,而更大的日誌在傳輸、儲存和保留上的成本也更高,尤其是在 info 和 debug 等級的大量日誌下。
緩解措施很普通。取樣 (Sampling) 會捨棄一部分高流量、低價值的日誌行,這樣成功的請求就不必為每一筆日誌支付全額成本,而失敗的請求仍然會完整記錄。級別規範預設會將 debug 日誌排除在生產環境的儲存之外。一個共用的日誌函式庫可以吸收傳遞的串接機制,讓個別的呼叫點保持簡單。與另一種選擇——在事件發生、時間分秒流逝之際,開著三個終端機並靠時間戳來猜測——相比,這個成本是微小的。當一個請求在服務鏈的某處失敗時,一個關聯 ID (correlation ID) 和一個可查詢的結構 (schema) 就能將一個下午的猜測工作,轉變為單一的篩選操作,而這正是決定你恢復速度的關鍵。