可觀測性與 SLO ch04 日誌:一次事故吃掉整月預算,關鍵那筆正好被丟掉
下一章→
CH 04 最貴的一柱

日誌:一次事故吃掉整月預算,關鍵那筆正好被丟掉

結構化日誌等級的真實定義trace_id 是最低要求取樣與去重成本模型與索引策略什麼不該記非同步 appender 的代價

日誌是三本柱裡最老、最靈活、也最容易失控的一個。它的問題可以濃縮成一句:

它的成本與流量成正比,而故障時流量最大。

這句話有一個殘酷的推論:你的日誌系統最可能被壓垮的時刻,正好是你最需要它的時刻。一個服務開始出錯 → 每次失敗記一整個堆疊追蹤 → 上游重試放大三倍 → 日誌量瞬間翻幾百倍 → 平台開始限流丟棄 → 而被丟掉的,是其他服務那些真正能指出根因的日誌。

這一章要處理的就是「怎麼讓日誌在最需要它的時候還活著」:

  • 結構化日誌:為什麼 JSON 不只是好看——沒有結構就沒有查詢,沒有查詢就只剩 grep。
  • 等級的真實定義:ERROR 不是「發生了不好的事」,而是「有人要為此做點什麼」。
  • trace_id:ch01 說三本柱之間要有橋,而這是最低成本、也最不可省略的一座。
  • 取樣與去重:怎麼在不丟掉關鍵資訊的前提下砍掉 90% 的量。
  • 成本模型:ingest、索引、儲存三筆帳分別怎麼算,以及 Loki 與 ELK 的取捨。
  • 什麼不該記:這一節是合規問題,寫錯的代價不是錢。
  • 一個真實場景:六小時內產生 900GB 日誌,觸發平台限流,全公司的日誌一起消失。

這一章與 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..."}

差別在哪:能問的問題完全不同

  • 純文字只能問:「有沒有出現這個字串」。
  • 結構化可以問:「重試超過 2 次、耗時超過 3 秒、而且發生在特定使用者身上的請求有哪些」——這正是 ch01 說的「未知的未知」:一個你事前沒想過的維度組合。
結構化日誌是「可觀測性」與「有記錄」的分水嶺。因為維度就在欄位裡,你可以事後任意切分,不需要重新部署去加一行 log。

訊息本身要固定,變數放欄位

這是很多人做一半的地方:明明用了 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(成本是乘法)。

Java/Spring 的做法

// 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排查用,正式環境預設關閉中間計算結果、外部呼叫的完整內容
最常見的誤用:把「使用者輸入錯誤」記成 ERROR。使用者填錯表單、帳號密碼打錯、傳了不合法的參數——這些是系統正常運作的一部分,不是錯誤。把它們記成 ERROR 的後果是:ERROR 的量與流量成正比,於是「ERROR 數量上升」這個訊號徹底失效——而那本來是最有價值的告警之一。

一個實用的檢查

對每一行 log.error(...) 問:「如果這行在半夜出現一百次,我需要有人起床嗎?」答案是否,那它就不是 ERROR。

不要用日誌數量當指標

「用日誌計算錯誤率」是很常見的做法,但它的成本結構很差:你為了得到一個數字,付了完整的日誌儲存與索引費用。

正確的分工:計數用 metrics(ch02,成本與流量無關),細節用日誌。需要「錯誤率」就在程式裡加一個 counter;日誌負責回答「那一次錯誤的細節是什麼」。

反過來,從日誌產生 metrics(Loki 的 metric query、或日誌管線的聚合)是合理的過渡手段——但它是權宜,不是目標。

堆疊追蹤要克制

一個 Java 堆疊追蹤動輒 2~5 KB,而且深層框架的堆疊有 80% 是重複的。

  • 同一個錯誤重複發生時,不要每次都印完整堆疊(下面的去重那節會處理)。
  • 預期內的例外不要印堆疊:驗證失敗、找不到資料——它們的堆疊沒有資訊量。
  • Logback 可以設定堆疊的最大深度與過濾框架的 package。

ch01 說三本柱之間需要橋,而 trace_id 是其中最便宜、也最不能省的一座。

它解決的問題

一個請求穿過五個服務,每個服務都寫了日誌。沒有 trace_id 時,你只能靠時間戳前後推測——而在每秒幾百個請求的系統裡,那是不可能的。

-- 有 trace_id:一句查詢就撈出這一次請求的完整故事
{trace_id="4bf92f3577b34da6a3ce929d0e0e4736"}

gateway    → 收到請求
order-api  → 建立訂單
payment    → 呼叫外部支付(重試 3 次)
payment    → 逾時失敗
order-api  → 回滾,回傳 500

Java/Spring 怎麼做

核心機制是 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 展開成欄位
MDC 是 thread-local 的,所以它會在跨執行緒的地方消失:丟進 @Async、送進執行緒池、透過 MQ 傳到別的服務。那正是 ch05 那個「追蹤斷在非同步邊界」的失敗場景,而日誌會跟著一起斷。處理方式在 ch05。

除了 trace_id,還有三個欄位值得每筆都帶

  • service/env/version:與 metrics 和 trace 用同一套命名(ch01 說的「統一標籤語彙」),否則無法自動關聯。
  • tenant_id/customer_id:多租戶系統裡,「只有某個客戶出問題」是極常見的模式。
  • 請求的關鍵識別碼(order_id 之類):注意這是欄位不是 metric label——在這裡它的成本是加法,完全合理。

由此得到的排查動線

① Grafana 看到 p99 尖峰(metrics,ch03)
② 點 exemplar 跳到一條代表性的 trace(ch05)
③ 複製那條 trace 的 trace_id
④ 到日誌系統用 trace_id 查 → 看到完整的細節與例外堆疊

這條動線能不能一路走通,比每一樣工具各自做得多好都重要。而它成立的唯一前提,就是每筆日誌都有 trace_id。

日誌平台的帳單通常拆成三塊,而它們的成長方式不同。搞清楚自己付的是哪一塊,才知道該砍什麼。

  • Ingest(寫入):與「筆數 × 每筆大小」成正比。這通常是最大的一塊,也是最容易失控的。
  • 索引:與「要能被搜尋的欄位量」成正比。這是 ELK 與 Loki 分道揚鑣的地方。
  • 儲存:與「保留期」成正比。相對便宜,但它是唯一會持續累積的。

兩種索引策略

全文索引(Elasticsearch)只索引 label(Loki)
做法每個欄位、每個詞都建索引只索引少量 label,內容原封壓縮存放
查詢任意欄位都很快先用 label 縮小範圍,再暴力掃描內容
成本索引常比原始資料還大低很多
label 基數影響不大高基數 label 會殺死它(與 ch02 同一個病)
Loki 的 label 要當成 Prometheus 的 label 來管。把 trace_id 或 user_id 設成 Loki 的 label(而不是留在日誌內容裡)會製造無數個 stream,症狀跟 ch02 那場事故一模一樣。正確做法是:label 只放 service/env/level,其餘留在內容裡靠掃描過濾。

分層保留

不是所有日誌都值得留一樣久:

ERROR / 稽核紀錄     → 90 天以上(可能有合規要求)
WARN / INFO         → 14~30 天
DEBUG / 存取日誌     → 3~7 天,或直接取樣

熱儲存(可查詢) 7 天 → 冷儲存(物件儲存,需要時再撈) 1 年

一個最容易忽略的成本:同步寫入

預設的 Logback appender 是同步的:寫日誌這件事發生在請求執行緒上。磁碟慢了、日誌收集端塞住了、或某個錯誤路徑在迴圈裡狂寫——你的請求執行緒就跟著卡住。ch01 說「儀器化不是免費的」,這是最具體的一個例子。
<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 是日誌帳單暴增最常見的原因之一。

不該記的東西(這一節是合規問題)

寫錯的代價不是錢,是法律責任與資安事件。
  • 個資:身分證字號、完整住址、生日、電話。需要識別時記內部 id,不要記本人資訊。
  • 憑證:密碼(就算是雜湊過的)、token、API key、session id。
  • 完整請求/回應本體:它同時包含上面兩類,而且是日誌暴增的頭號來源。要記就記欄位名稱與長度,不要記值。
  • 信用卡號:這是 PCI DSS 的硬性規定,不是建議。
而日誌一旦寫出去就很難撤回——它已經被複製到收集器、索引、備份、冷儲存。所以這件事只能在寫入的當下擋住,不能事後補救。

一個實用的防線

在序列化層統一遮蔽:對敏感欄位加註解(例如自訂 @Masked),由 JSON encoder 自動替換成 ***。比要求每個工程師記得不要記,可靠得多。

徵狀

週日凌晨兩點,日誌平台開始大量丟棄資料。而值班工程師發現這件事,是因為他查不到另一個服務的日誌——那個服務跟這次事故毫無關係。

  • 平常全公司日誌約 2 GB/天,這六小時內產生了 900 GB。
  • 平台觸發 ingest 限流,開始無差別丟棄。
  • 當天的日誌成本超過整個月的預算。
  • 最糟的是:真正該被保留的、其他服務的關鍵日誌,被那 900 GB 擠掉了。

診斷

-- 依服務看日誌量(大部分平台都有這種內建指標)
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
下游的風控服務掛了,所有呼叫失敗。這是起點,但它本身不是災難。
2
錯誤路徑記錄了完整請求本體 + 完整堆疊:每筆約 12 KB(正常日誌約 300 bytes,是 40 倍)。
3
重試機制放大了三倍:每個請求失敗重試 3 次,每次都記一筆完整的錯誤。
4
上游還有一層重試,於是總放大倍數接近 10 倍。
正常:  1,000 req/s × 300 B  = 0.3 MB/s
故障中:1,000 × 10 次 × 12 KB ≈ 120 MB/s   ← 400 倍
關鍵不在「錯誤變多了」,而在「每個錯誤變貴了 40 倍,而且被重試放大了 10 倍」。這兩個乘數平常都看不見——它們只在故障時才同時出現。這正是本章開頭那句話的具體形狀:日誌系統最可能被壓垮的時刻,就是你最需要它的時刻。

而真正的傷害是連帶的

平台的限流是全域、無差別的。於是這一個服務的失控,讓所有服務的日誌一起被丟——包括那些正在排查其他問題的團隊。可觀測性系統本身變成了單點故障。

修法

1
止血:把該服務的 log level 用 Actuator 動態調到 WARN(不用重新部署),日誌量立刻回落。
2
錯誤去重:同一個錯誤簽名在 60 秒內只記一次完整內容,其餘只累加計數。資訊沒有損失,量降到千分之一。
3
不要記完整請求本體:改記關鍵欄位與大小。順帶解決了合規風險(那 8KB 裡有個資)。
4
重試不要每次都記 ERROR:中間的重試記 DEBUG 或不記,只有最終失敗才記一次 ERROR——重試成功的話那根本不是錯誤(ch05 的斷路器與重試策略見 高可用防禦)。
5
每個服務設 ingest 配額:讓失控的服務先被自己的上限擋住,而不是拖垮整個平台。這是最重要的一條——它把「全域故障」變回「單一服務故障」。
6
對日誌量的成長率設告警:與 ch02 對 prometheus_tsdb_head_series 設告警是同一個道理——可觀測性系統本身也需要被觀測。

教訓

日誌的容量規劃不能用「平常的量」來估,要用「最壞情況的量」——而最壞情況是「所有請求都在錯誤路徑上,而且每一層都在重試」。那個數字通常是平常的幾百倍,而多數團隊從來沒算過它。

把前面收成一份可以直接執行的清單。這六件事決定了你的日誌是資產還是負債。

1
格式:一律結構化(JSON),訊息固定、變數進欄位。這是所有後續能力的前提。
2
必帶欄位:trace_id/span_id/service/env/version。與 metrics、trace 使用同一套命名。
3
等級:用「需不需要人介入」定義 ERROR。使用者輸入錯誤不是 ERROR。
4
量:存取日誌取樣、錯誤去重、堆疊克制、不記請求本體。
5
保留:依等級分層,熱儲存短、冷儲存長。
6
防護:per-service ingest 配額 + 非同步 appender + 日誌量成長率告警。

什麼時候不要用日誌

  • 要計數/算比率 → 用 metrics(ch02)。用日誌算錯誤率是付完整儲存費換一個數字。
  • 要看一次請求的時間分佈 → 用 trace(ch05)。從日誌的時間戳推算跨服務耗時是徒勞。
  • 要長期趨勢 → 用 metrics。日誌的保留期通常遠短於你需要的趨勢範圍。
日誌唯一無可取代的能力是:回答「那一次到底發生了什麼」的細節。其他所有用途都有更便宜的工具——把日誌用在它不擅長的地方,就是帳單失控的起點。

一個判斷每一行 log 的問題

寫下 log.info(...) 之前問三件事:

① 誰會讀它?(如果答案是「沒有人」,那就不要寫)
② 在什麼情境下讀?(排查什麼問題)
③ 讀的時候需要哪些欄位?(那些就是該加的 key-value)

答不出第一題的日誌,是純粹的成本。而它們通常佔了總量的大半。

四個盲點

  • 它看不到「沒有執行到的程式碼」。if 分支沒進去、事件沒被觸發、排程沒被排——日誌裡什麼都沒有,而那正是問題(ch03 的 absent_over_time 是唯一的補救)。
  • 它看不到聚合層的問題。每一筆日誌都正常,但「這個請求發了 200 次相同的呼叫」在日誌裡只表現成 200 筆正常紀錄——這與 db ch08 的 N+1 是同一個盲點。
  • 它的時間戳不可靠。跨機器的時鐘會有偏差,非同步 appender 會讓寫入順序與發生順序不一致。不要用日誌的時間戳去推算跨服務的耗時——那是 trace 的工作。
  • 被丟掉的日誌不會留下痕跡。取樣、限流、佇列溢出——這些都是靜默的。「查不到」不等於「沒發生」,而多數人會把前者當成後者。

三筆容易低估的帳

  • 同步寫入拖慢請求:日誌後端塞住時,它會反壓到你的請求執行緒。而 neverBlock=true 的代價是靜默丟棄。
  • 索引成本可能超過原始資料:全文索引尤其明顯。這是「先想清楚哪些欄位真的需要被搜尋」的理由。
  • 合規風險:一旦寫出去就散佈到收集器、索引、備份、冷儲存。這是唯一一種「事後無法補救」的日誌問題。

把這一章收成三句

① 日誌的成本與流量成正比,而故障時流量最大——所以容量要用「最壞情況」估,不是用平常的量。
② 結構化 + trace_id 是可觀測性的最低門檻:沒有結構就只剩 grep,沒有 trace_id 就無法跨服務串起一次請求。
③ 日誌唯一無可取代的是「那一次的細節」——計數用 metrics、時間分佈用 trace,用錯工具就是帳單失控的起點。

下一章處理三本柱的最後一根:trace 是唯一能回答「這一次的時間花在哪」的東西,而它最常見的失敗,是在你最需要的那一段斷掉。

日誌等級:用「誰要做什麼」定義
等級判準例子常見誤用
ERROR需要工程師介入,否則會有損害依賴不可用、資料不一致把使用者輸入錯誤記成 ERROR,讓「錯誤數上升」失效
WARN異常但已自動處理,累積才需看重試後成功、降級生效當成「比較不嚴重的 ERROR」隨便用
INFO系統重要狀態變化啟動完成、排程開始/結束在請求路徑上大量輸出
DEBUG排查用,正式環境預設關閉中間計算、外部呼叫內容排查完忘了關掉——帳單暴增第一名
兩種索引策略:ELK vs Loki
全文索引(Elasticsearch)只索引 label(Loki)
查詢彈性任意欄位都快先用 label 縮範圍,再掃描內容
成本索引常比原始資料還大低很多
怕什麼資料量與欄位數高基數 label——與 ch02 同一個病
label 該放什麼影響不大只放 service/env/level;trace_id 留在內容裡
適合需要複雜全文搜尋、有預算以 trace_id/service 為主要入口的排查動線
三種降量手段:效果與風險
手段怎麼做降量幅度風險
分級取樣成功請求取樣 1%,慢請求與 WARN 以上全留常見降 80% 以上低——存取日誌 99% 是「一切正常」
錯誤去重同簽名錯誤 60 秒內只記一次完整內容,其餘計數故障時可降千倍幾乎沒有:仍保有一份完整範例與次數
不記請求本體只記關鍵欄位與大小單筆從 12KB 降到數百 bytes低,且順帶解決合規風險
動態調整等級Actuator 改單一 logger 等級,不重啟視情況忘了改回去——帳單暴增常見原因

練習題 點選選項查看解析

0 / 10
01 / 10
「日誌的成本與流量成正比」這句話最重要的推論是什麼?
A 應該減少日誌等級
B 日誌系統最可能被壓垮的時刻,正好是你最需要它的時刻——故障時流量與每筆大小同時放大
C 應該使用更便宜的儲存
D 日誌不適合用於排查
解析
服務開始出錯 → 每次失敗記完整堆疊 → 上游重試放大 → 日誌量翻幾百倍 → 平台限流丟棄。所以容量規劃要用「最壞情況」估(所有請求都在錯誤路徑上且每層都在重試),那通常是平常的幾百倍。
02 / 10
用了 JSON 格式,但寫成 {"msg": "Failed to process order 8821 for user 42"},問題在哪?
A 沒有問題,這已經是結構化日誌
B 變數塞進了 msg,導致每筆訊息都不同,無法聚合「這類錯誤發生幾次」;變數應該獨立成欄位
C 應該用純文字格式
D 缺少時間戳
解析
msg 應該是固定的分類,order_id 與 user_id 是獨立欄位。這與 ch02「錯誤訊息不要當 metric label」是同一原則的兩面:會變動的值是資料不是分類——差別只在日誌裡它是欄位(加法成本),metrics 裡是 label(乘法成本)。
03 / 10
ERROR 等級的正確判準是什麼?
A 發生了不好的事
B 需要工程師介入處理,否則會有損害——檢查方式是問「如果這行半夜出現一百次,我需要有人起床嗎」
C 程式拋出了例外
D 回應碼是 4xx 或 5xx
解析
最常見的誤用是把使用者輸入錯誤(填錯表單、密碼打錯)記成 ERROR。那些是系統正常運作的一部分,而把它們記成 ERROR 會讓 ERROR 數量與流量成正比,於是「錯誤數上升」這個最有價值的訊號徹底失效。
04 / 10
為什麼「用日誌計算錯誤率」是成本結構很差的做法?
A 因為日誌不準確
B 因為你為了得到一個數字,付了完整的日誌儲存與索引費用——計數應該用 metrics(成本與流量無關),日誌負責回答「那一次的細節」
C 因為日誌會延遲
D 因為日誌無法聚合
解析
正確分工是:計數用 metrics、細節用日誌。反過來從日誌產生 metrics(Loki 的 metric query)是合理的過渡手段,但那是權宜不是目標。
05 / 10
為什麼每一筆日誌都必須帶 trace_id?
A 為了滿足合規要求
B 因為沒有它就只能靠時間戳前後推測,而在每秒幾百請求的系統裡那是不可能的;它是 ch01 說的「三本柱之間的橋」中最便宜也最不可省的一座
C 為了減少日誌量
D 為了讓日誌能被索引
解析
完整動線是:metrics 看到 p99 尖峰 → 點 exemplar 跳到代表性 trace → 複製 trace_id → 到日誌系統查完整細節。這條動線能不能走通,比每一樣工具各自做得多好都重要。
06 / 10
在 Java/Spring 中,trace_id 靠什麼機制帶進日誌?它的限制是什麼?
A 靠 ThreadLocal 變數,沒有限制
B 靠 MDC(綁在執行緒上的鍵值表),限制是跨執行緒時會消失——@Async、執行緒池、MQ 邊界都會斷
C 靠 Spring 的 RequestContextHolder,只在 Web 請求有效
D 靠日誌框架自動偵測
解析
MDC 是 thread-local 的,所以它在非同步邊界會消失——那正是 ch05 那個「追蹤斷在非同步邊界」的失敗場景,而日誌會跟著一起斷。處理方式在 ch05。
07 / 10
使用 Loki 時,把 trace_id 設成 label 會發生什麼?
A 查詢會變快
B 會製造無數個 stream,症狀跟 ch02 那場 cardinality 事故一模一樣——label 只該放 service/env/level,trace_id 留在內容裡
C 沒有影響,Loki 不索引 label
D 會導致日誌被丟棄
解析
Loki 的 label 要當成 Prometheus 的 label 來管,因為它就是用 label 來切分 stream 的。高基數 label 是它的致命傷;內容則是壓縮存放、靠掃描過濾,放 trace_id 完全沒問題。
08 / 10
Logback 的 AsyncAppender 設定 neverBlock=true,這個選擇的本質是什麼?
A 純粹的效能優化,沒有代價
B 一次取捨:佇列滿時要嘛丟掉日誌,要嘛阻塞請求執行緒——沒有第三個選項
C 可以避免日誌遺失
D 只影響 DEBUG 等級
解析
預設的 appender 是同步的,寫日誌發生在請求執行緒上——日誌後端塞住時會反壓到你的請求。這是 ch01 說「儀器化不是免費的」最具體的例子。另外 discardingThreshold 要設 0,否則佇列半滿時會偷偷丟掉 WARN 以下。
09 / 10
本章的失敗場景中,日誌量從 0.3 MB/s 暴增到 120 MB/s,關鍵原因是什麼?
A 流量突然增加了 400 倍
B 不是錯誤變多,而是「每個錯誤變貴 40 倍(完整請求本體+堆疊)」乘上「重試放大 10 倍」——兩個乘數平常看不見,只在故障時同時出現
C 日誌等級被誤設為 DEBUG
D 日誌平台本身故障
解析
而真正的傷害是連帶的:平台的限流是全域無差別的,於是一個服務的失控讓所有服務的日誌一起被丟——可觀測性系統本身變成了單點故障。最重要的修法是 per-service ingest 配額,把「全域故障」變回「單一服務故障」。
10 / 10
下列哪一項是日誌的盲點,必須靠其他工具補?
A 查詢某次請求的細節
B 「這個請求發了 200 次相同的呼叫」——每筆日誌都正常,聚合層的問題在日誌裡只表現成 200 筆正常紀錄
C 查詢某個服務的錯誤內容
D 查詢例外堆疊
解析
這與 db ch08 的 N+1 是同一個盲點。日誌的其他盲點還有:看不到「沒有執行到的程式碼」(要用 absent_over_time)、時間戳不可靠(跨機器時鐘偏差、非同步寫入亂序,不能用來推算跨服務耗時)、以及被丟掉的日誌不會留下痕跡(「查不到」不等於「沒發生」)。

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

QUESTION
「日誌成本與流量成正比」最重要的推論是什麼?
點擊翻面
ANSWER
日誌系統最可能被壓垮的時刻,正是你最需要它的時刻。所以容量規劃要用「最壞情況」估——所有請求都在錯誤路徑上且每層都在重試,那通常是平常的幾百倍。
點擊翻回
QUESTION
結構化日誌的關鍵不只是用 JSON,還有什麼?
點擊翻面
ANSWER
訊息本體要固定、變數放獨立欄位。把 order_id 塞進 msg 裡會讓每筆訊息都不同,無法聚合「這類錯誤發生幾次」——與 ch02「錯誤訊息不要當 label」是同一原則。
點擊翻回
QUESTION
ERROR 等級該怎麼定義?最常見的誤用是什麼?
點擊翻面
ANSWER
定義是「需要工程師介入,否則有損害」,檢查方式:這行半夜出現一百次,需要有人起床嗎?最常見誤用是把使用者輸入錯誤記成 ERROR——那會讓 ERROR 數量與流量成正比,使「錯誤數上升」這個訊號失效。
點擊翻回
QUESTION
為什麼不該用日誌算錯誤率?
點擊翻面
ANSWER
為了一個數字付了完整的儲存與索引費用。正確分工:計數用 metrics(成本與流量無關),日誌回答「那一次的細節」。從日誌產生 metrics 是過渡手段,不是目標。
點擊翻回
QUESTION
每筆日誌至少要帶哪些欄位?
點擊翻面
ANSWER
trace_id/span_id(跨服務串聯)、service/env/version(與 metrics、trace 用同一套命名才能自動關聯)。多租戶系統再加 tenant_id。
點擊翻回
QUESTION
Java 專案裡 trace_id 靠什麼進日誌?它什麼時候會斷?
點擊翻面
ANSWER
MDC(綁在執行緒的鍵值表),Spring Boot 3 + Micrometer Tracing 會自動放入。它是 thread-local 的,所以跨執行緒(@Async、執行緒池)與跨 MQ 時會消失。
點擊翻回
QUESTION
日誌平台的三筆帳是什麼?
點擊翻面
ANSWER
ingest(筆數 × 每筆大小,通常最大也最易失控)、索引(與可搜尋欄位量成正比,ELK 與 Loki 的分野)、儲存(與保留期成正比,唯一持續累積的)。
點擊翻回
QUESTION
Loki 的 label 該怎麼管?
點擊翻面
ANSWER
當成 Prometheus 的 label 來管——只放 service/env/level 這類低基數的。把 trace_id 或 user_id 設成 label 會製造無數 stream,症狀與 ch02 的 cardinality 事故一模一樣;它們留在內容裡即可。
點擊翻回
QUESTION
三種降量手段各自的效果?
點擊翻面
ANSWER
①分級取樣(成功取樣 1%、慢請求與 WARN 以上全留)常降 80% 以上 ②錯誤去重(同簽名 60 秒記一次完整內容 + 計數)故障時可降千倍且資訊無損 ③不記請求本體(單筆 12KB → 數百 bytes,順帶解決合規風險)。
點擊翻回
QUESTION
AsyncAppender 的 neverBlock 設定本質上是什麼取捨?
點擊翻面
ANSWER
佇列滿時要嘛丟掉日誌、要嘛阻塞請求執行緒,沒有第三個選項。另外 discardingThreshold 要設 0,否則佇列半滿時會偷偷丟掉 WARN 以下的日誌。
點擊翻回
QUESTION
哪些東西絕對不能寫進日誌?為什麼這件事無法事後補救?
點擊翻面
ANSWER
個資、憑證(密碼/token/API key/session id)、完整請求回應本體、信用卡號(PCI DSS 硬性規定)。無法補救是因為一旦寫出就散佈到收集器、索引、備份、冷儲存——只能在寫入當下擋住,最好在序列化層統一遮蔽。
點擊翻回
QUESTION
本章事故中日誌量暴增 400 倍的組成是什麼?
點擊翻面
ANSWER
不是錯誤變多,而是兩個乘數:每個錯誤變貴 40 倍(完整請求本體 12KB + 堆疊)× 重試放大 10 倍。而傷害是連帶的——平台限流是全域無差別的,一個服務失控讓所有服務的日誌一起被丟。
點擊翻回
QUESTION
日誌的四個盲點是什麼?
點擊翻面
ANSWER
①看不到「沒執行到的程式碼」(要用 absent_over_time)②看不到聚合層問題(200 次相同呼叫只是 200 筆正常紀錄,與 db ch08 的 N+1 同一個盲點)③時間戳不可靠(跨機器時鐘、非同步亂序,不能推算跨服務耗時)④被丟掉的日誌不留痕跡(查不到 ≠ 沒發生)。
點擊翻回