日誌是三本柱裡最老、最靈活、也最容易失控的一個。它的問題可以濃縮成一句:
它的成本與流量成正比,而故障時流量最大。
這句話有一個殘酷的推論:你的日誌系統最可能被壓垮的時刻,正好是你最需要它的時刻。一個服務開始出錯 → 每次失敗記一整個堆疊追蹤 → 上游重試放大三倍 → 日誌量瞬間翻幾百倍 → 平台開始限流丟棄 → 而被丟掉的,是其他服務那些真正能指出根因的日誌。
這一章要處理的就是「怎麼讓日誌在最需要它的時候還活著」:
grep。ERROR 不是「發生了不好的事」,而是「有人要為此做點什麼」。trace_id:ch01 說三本柱之間要有橋,而這是最低成本、也最不可省略的一座。這一章與 ch02 是對照組:metrics 的成本是維度的乘法,日誌的成本是流量的加法。兩者的治理方式完全不同——metrics 靠「限制 label」,日誌靠「限制每一筆的大小與筆數」。
日誌的價值不在「有記錄」,而在「能被查詢」。而這件事完全取決於它有沒有結構。
// ❌ 純文字:人看得懂,機器只能做字串比對
2026-08-13 22:14:07 ERROR Failed to process order 8821 for user 42 after 3 retries
// ✅ 結構化:每個欄位都可以被過濾、聚合、比較
{"ts":"2026-08-13T22:14:07Z","level":"ERROR","msg":"order processing failed",
"order_id":8821,"user_id":42,"retry_count":3,"duration_ms":4210,
"service":"order-api","trace_id":"4bf92f...","span_id":"00f067..."}這是很多人做一半的地方:明明用了 JSON,卻把變數塞進 msg 裡。
// ❌ msg 每一筆都不一樣,無法聚合「這類錯誤發生幾次」
"msg": "Failed to process order 8821 for user 42"
// ✅ msg 是固定的分類,變數獨立成欄位
"msg": "order processing failed", "order_id": 8821, "user_id": 42這與 ch02 的「錯誤訊息不要當 metric label」是同一個原則的兩面:會變動的值是資料,不是分類。差別只在於——在日誌裡它們是欄位(成本是加法),在 metrics 裡它們是 label(成本是乘法)。
// logback-spring.xml 用 JSON encoder(logstash-logback-encoder)
<encoder class="net.logstash.logback.encoder.LogstashEncoder"/>
// 程式碼用結構化參數,不要自己拼字串
log.atError()
.setMessage("order processing failed")
.addKeyValue("order_id", orderId)
.addKeyValue("retry_count", retries)
.log();
// ❌ 不要這樣:變數進了訊息本體
log.error("Failed to process order " + orderId);日誌等級是最被濫用的機制。大部分專案的 WARN 裡塞滿了沒有人會處理的東西,而 ERROR 裡混著使用者輸入錯誤。結果是等級失去了篩選能力——而那本來是它唯一的用途。
| 等級 | 判準 | 典型例子 |
|---|---|---|
| ERROR | 需要工程師介入處理,否則會有損害 | 依賴服務不可用、資料不一致、無法恢復的失敗 |
| WARN | 異常但已自動處理,累積起來才需要看 | 重試後成功、降級生效、快取失效 |
| INFO | 系統的重要狀態變化 | 啟動完成、設定載入、排程開始/結束 |
| DEBUG | 排查用,正式環境預設關閉 | 中間計算結果、外部呼叫的完整內容 |
對每一行 log.error(...) 問:「如果這行在半夜出現一百次,我需要有人起床嗎?」答案是否,那它就不是 ERROR。
「用日誌計算錯誤率」是很常見的做法,但它的成本結構很差:你為了得到一個數字,付了完整的日誌儲存與索引費用。
一個 Java 堆疊追蹤動輒 2~5 KB,而且深層框架的堆疊有 80% 是重複的。
ch01 說三本柱之間需要橋,而 trace_id 是其中最便宜、也最不能省的一座。
一個請求穿過五個服務,每個服務都寫了日誌。沒有 trace_id 時,你只能靠時間戳前後推測——而在每秒幾百個請求的系統裡,那是不可能的。
-- 有 trace_id:一句查詢就撈出這一次請求的完整故事
{trace_id="4bf92f3577b34da6a3ce929d0e0e4736"}
gateway → 收到請求
order-api → 建立訂單
payment → 呼叫外部支付(重試 3 次)
payment → 逾時失敗
order-api → 回滾,回傳 500核心機制是 MDC(Mapped Diagnostic Context)——一個綁在執行緒上的鍵值表,logger 輸出時會自動帶上。
// Spring Boot 3 + Micrometer Tracing:trace_id 會自動放進 MDC
// logback pattern 直接引用即可
<pattern>%d %-5level [%X{traceId},%X{spanId}] %logger - %msg%n</pattern>
// JSON encoder 則會自動把整個 MDC 展開成欄位@Async、送進執行緒池、透過 MQ 傳到別的服務。那正是 ch05 那個「追蹤斷在非同步邊界」的失敗場景,而日誌會跟著一起斷。處理方式在 ch05。service/env/version:與 metrics 和 trace 用同一套命名(ch01 說的「統一標籤語彙」),否則無法自動關聯。tenant_id/customer_id:多租戶系統裡,「只有某個客戶出問題」是極常見的模式。① Grafana 看到 p99 尖峰(metrics,ch03)
② 點 exemplar 跳到一條代表性的 trace(ch05)
③ 複製那條 trace 的 trace_id
④ 到日誌系統用 trace_id 查 → 看到完整的細節與例外堆疊這條動線能不能一路走通,比每一樣工具各自做得多好都重要。而它成立的唯一前提,就是每筆日誌都有 trace_id。
日誌平台的帳單通常拆成三塊,而它們的成長方式不同。搞清楚自己付的是哪一塊,才知道該砍什麼。
| 全文索引(Elasticsearch) | 只索引 label(Loki) | |
|---|---|---|
| 做法 | 每個欄位、每個詞都建索引 | 只索引少量 label,內容原封壓縮存放 |
| 查詢 | 任意欄位都很快 | 先用 label 縮小範圍,再暴力掃描內容 |
| 成本 | 索引常比原始資料還大 | 低很多 |
| label 基數 | 影響不大 | 高基數 label 會殺死它(與 ch02 同一個病) |
trace_id 或 user_id 設成 Loki 的 label(而不是留在日誌內容裡)會製造無數個 stream,症狀跟 ch02 那場事故一模一樣。正確做法是:label 只放 service/env/level,其餘留在內容裡靠掃描過濾。不是所有日誌都值得留一樣久:
ERROR / 稽核紀錄 → 90 天以上(可能有合規要求)
WARN / INFO → 14~30 天
DEBUG / 存取日誌 → 3~7 天,或直接取樣
熱儲存(可查詢) 7 天 → 冷儲存(物件儲存,需要時再撈) 1 年<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<queueSize>8192</queueSize>
<discardingThreshold>0</discardingThreshold> <!-- 佇列滿時不要偷偷丟掉 WARN 以下 -->
<neverBlock>true</neverBlock> <!-- 佇列滿時丟棄而不是阻塞請求 -->
</appender>而 neverBlock 這個選擇本身就是一次取捨:要嘛丟掉日誌,要嘛拖慢請求。沒有第三個選項。日誌治理的目標不是「少記一點」,而是「在故障時仍然記得住重要的東西」。有三種手段,效果與風險各不相同。
成功的請求存取日誌 → 取樣 1%(統計上足夠)
慢請求(> SLO 門檻) → 100% 保留
所有 WARN 以上 → 100% 保留這是投報比最高的一種:存取日誌通常佔總量的八成以上,而其中 99% 是「一切正常」。
ERROR order processing failed(含完整堆疊)
... 之後 60 秒內
WARN order processing failed × 12,847(省略重複堆疊)Logback 有 DuplicateMessageFilter,也可以在日誌管線(Fluent Bit/Vector)那層做。資訊完全沒有損失——你仍然知道它發生了幾次、也仍然有一份完整範例。正式環境預設 INFO,但排查時需要 DEBUG。重新部署來改 log level 是最糟的做法(改變了現場,而且可能讓問題消失)。
// Spring Boot Actuator:不重啟就能改單一 logger 的等級
POST /actuator/loggers/com.shop.payment
{"configuredLevel": "DEBUG"}用完記得改回去——忘記關掉的 DEBUG 是日誌帳單暴增最常見的原因之一。
在序列化層統一遮蔽:對敏感欄位加註解(例如自訂 @Masked),由 JSON encoder 自動替換成 ***。比要求每個工程師記得不要記,可靠得多。
週日凌晨兩點,日誌平台開始大量丟棄資料。而值班工程師發現這件事,是因為他查不到另一個服務的日誌——那個服務跟這次事故毫無關係。
-- 依服務看日誌量(大部分平台都有這種內建指標)
sum by (service) (rate(log_bytes_total[5m]))
-- payment-worker: 41 MB/s ← 其他服務加起來不到 0.1 MB/s撈幾筆出來看,全部是同一則錯誤:
{"level":"ERROR","msg":"Failed to call risk service",
"request_body":"{...完整的 8KB 請求內容...}",
"stack_trace":"...約 4KB..."}正常: 1,000 req/s × 300 B = 0.3 MB/s
故障中:1,000 × 10 次 × 12 KB ≈ 120 MB/s ← 400 倍平台的限流是全域、無差別的。於是這一個服務的失控,讓所有服務的日誌一起被丟——包括那些正在排查其他問題的團隊。可觀測性系統本身變成了單點故障。
WARN(不用重新部署),日誌量立刻回落。DEBUG 或不記,只有最終失敗才記一次 ERROR——重試成功的話那根本不是錯誤(ch05 的斷路器與重試策略見 高可用防禦)。prometheus_tsdb_head_series 設告警是同一個道理——可觀測性系統本身也需要被觀測。把前面收成一份可以直接執行的清單。這六件事決定了你的日誌是資產還是負債。
trace_id/span_id/service/env/version。與 metrics、trace 使用同一套命名。寫下 log.info(...) 之前問三件事:
① 誰會讀它?(如果答案是「沒有人」,那就不要寫)
② 在什麼情境下讀?(排查什麼問題)
③ 讀的時候需要哪些欄位?(那些就是該加的 key-value)答不出第一題的日誌,是純粹的成本。而它們通常佔了總量的大半。
absent_over_time 是唯一的補救)。neverBlock=true 的代價是靜默丟棄。trace_id 是可觀測性的最低門檻:沒有結構就只剩 grep,沒有 trace_id 就無法跨服務串起一次請求。下一章處理三本柱的最後一根:trace 是唯一能回答「這一次的時間花在哪」的東西,而它最常見的失敗,是在你最需要的那一段斷掉。
| 等級 | 判準 | 例子 | 常見誤用 |
|---|---|---|---|
| ERROR | 需要工程師介入,否則會有損害 | 依賴不可用、資料不一致 | 把使用者輸入錯誤記成 ERROR,讓「錯誤數上升」失效 |
| WARN | 異常但已自動處理,累積才需看 | 重試後成功、降級生效 | 當成「比較不嚴重的 ERROR」隨便用 |
| INFO | 系統重要狀態變化 | 啟動完成、排程開始/結束 | 在請求路徑上大量輸出 |
| DEBUG | 排查用,正式環境預設關閉 | 中間計算、外部呼叫內容 | 排查完忘了關掉——帳單暴增第一名 |
| 全文索引(Elasticsearch) | 只索引 label(Loki) | |
|---|---|---|
| 查詢彈性 | 任意欄位都快 | 先用 label 縮範圍,再掃描內容 |
| 成本 | 索引常比原始資料還大 | 低很多 |
| 怕什麼 | 資料量與欄位數 | 高基數 label——與 ch02 同一個病 |
| label 該放什麼 | 影響不大 | 只放 service/env/level;trace_id 留在內容裡 |
| 適合 | 需要複雜全文搜尋、有預算 | 以 trace_id/service 為主要入口的排查動線 |
| 手段 | 怎麼做 | 降量幅度 | 風險 |
|---|---|---|---|
| 分級取樣 | 成功請求取樣 1%,慢請求與 WARN 以上全留 | 常見降 80% 以上 | 低——存取日誌 99% 是「一切正常」 |
| 錯誤去重 | 同簽名錯誤 60 秒內只記一次完整內容,其餘計數 | 故障時可降千倍 | 幾乎沒有:仍保有一份完整範例與次數 |
| 不記請求本體 | 只記關鍵欄位與大小 | 單筆從 12KB 降到數百 bytes | 低,且順帶解決合規風險 |
| 動態調整等級 | Actuator 改單一 logger 等級,不重啟 | 視情況 | 忘了改回去——帳單暴增常見原因 |
點擊卡片翻面查看答案,共 13 張。