可觀測性實戰:我在 Go 服務裡怎麼埋 log、metrics、trace
後端工程師,主力 Go,做過交易所撮合引擎、金流串接與高併發系統,專注於把線上服務做得可觀測、可排查。
線上半夜出事的時候,你最不想看到的就是一片漆黑。服務回應變慢、錯誤率往上飆,但你打開系統一看,只有一行又一行毫無頭緒的 log,沒有任何線索告訴你「到底是哪一條請求、卡在哪個環節、為什麼慢」。我在交易所做撮合引擎、做金流串接的那幾年,最深刻的體會不是怎麼把功能寫出來,而是當事情出錯時,你有沒有辦法在三分鐘內知道發生了什麼事。這就是可觀測性(Observability)要解決的問題。這篇講我在 Go 服務裡怎麼實際埋 log、metrics、trace,講我的原則,也講踩過的坑——因為做得不好的可觀測性,反而會變成噪音,讓你在真正出事的時候更慌。
先講清楚:可觀測性不是「多裝幾個 dashboard」
很多人對可觀測性的理解停留在「裝個 Grafana、接個 Prometheus、畫幾張圖」。這沒錯,但這只是工具。可觀測性真正的定義是:你能不能只靠系統對外輸出的訊號,回答關於系統內部狀態的任何問題,而不需要重新部署、重新加 log、重新跑一次。
這個定義很關鍵。它的反面是「監控」。監控是你事先知道會出什麼問題,所以你針對那些已知狀況設好儀表板和告警。但線上事故最可怕的,往往是你沒預料到的問題。可觀測性要的是:當一個你從沒想過的狀況發生時,你手上既有的資料夠不夠讓你查出來。
我的原則是:可觀測性的成敗,在於你能不能事後問出當下沒想到的問題。 如果每次查線上問題都得「先加一行 log、再部署、再等問題重現」,那你的可觀測性其實是不及格的。
三本柱:log、metrics、trace 各自的分工
可觀測性有三本柱(three pillars),這個說法很多人聽過,但真正搞懂三者分工的人不多。我用一個實際情境來講:假設線上出現「下單 API 偶爾很慢」。
- Metrics 看趨勢與告警。 它告訴你「現在 p99 延遲從 50ms 跳到 800ms 了」「錯誤率從 0.1% 變成 3%」。Metrics 是聚合過的數字,便宜、可以長期保存、適合畫趨勢圖和設告警。但它沒辦法告訴你「是哪一筆訂單慢」。
- Trace 看一條請求的完整路徑。 當你知道現在很慢,你需要抓一條具體的慢請求,看它從進入 API 閘道、到呼叫帳戶服務、到查資料庫、到寫入撮合佇列,每一段花了多久。Trace 把一條請求跨多個服務的時間軸攤開給你看,讓你定位「慢在哪一段」。
- Log 查細節。 當你已經知道是「帳戶服務查餘額那段慢」,你需要去看那個時間點、那個 request id 對應的 log,看到底是 SQL 慢、還是鎖等待、還是某個分支走錯了。Log 是最細粒度的事件紀錄,有最完整的脈絡,但也最貴、最雜。
這三者是有順序的:metrics 告訴你有問題、trace 告訴你問題在哪、log 告訴你問題為什麼。 我看過太多團隊只有 log,每次出事就 grep 幾百萬行日誌,效率極差。也看過團隊堆了一大堆 metrics 卻沒有 trace,知道整體慢了但永遠定位不到是哪個服務。三本柱缺一不可,而且要能互相串接——這是後面要講的重點。
結構化日誌:別再 print 字串了
我第一個要講、也是最常被忽略的,就是結構化日誌(structured logging)。
很多人寫 log 還是這樣:把一堆變數用字串拼接,印出一行人類看得懂的句子。例如「使用者 12345 下單失敗,金額 100,原因餘額不足」。這在開發階段看起來很友善,但到了線上,當你有上百萬行這種 log,你會發現你根本沒辦法做任何有效的查詢。你想找「所有餘額不足的失敗」,只能用關鍵字去 grep,而關鍵字隨時可能因為有人改了文案就失效。
結構化日誌的做法是:log 不是給人看的句子,而是給機器查的事件。 每一條 log 是一組鍵值對(key-value),輸出成 JSON。同樣的事件會變成:事件名稱是 order_failed、user_id 是 12345、amount 是 100、reason 是 insufficient_balance。
這樣帶來的差別是巨大的:
- 你可以在日誌系統裡直接下查詢條件,例如「reason 等於 insufficient_balance 且 amount 大於 1000」,秒級回應。
- 你可以對某個欄位做聚合統計,例如「過去一小時各種失敗原因的分佈」。
- 欄位是結構化的,不會因為有人改了句子的措辭就讓你的查詢失效。
在 Go 裡,我現在的標準做法是用標準庫的 slog(Go 1.21 之後內建)。在那之前我用過 zap 和 zerolog,這兩個在高吞吐場景下效能很好,零記憶體配置(zero allocation)這件事在每秒幾萬條 log 的服務裡是真的有感的。但如果是新專案,我會優先用 slog,因為它是標準庫、介面乾淨、而且整個生態系都在往它靠攏。
一定要帶上 request id 和 trace id
結構化日誌最重要的一個欄位,是能把一條請求的所有 log 串起來的識別碼。
想像一個下單請求,它在過程中可能寫出十幾條 log,散落在不同函數、不同服務裡。如果這些 log 沒有共同的識別碼,你在日誌系統裡看到的就是一團亂麻,根本分不清哪幾條屬於同一個請求。
我的做法是:每一條請求在進入系統的第一個入口,就生成一個 request id,並且在整個請求生命週期裡,所有 log 都帶上這個 id。 更進一步,如果你有分散式追蹤,這個欄位應該直接用 trace id,這樣你的 log 和 trace 就天然串接在一起——你在 trace 裡看到某一段慢,可以直接拿那個 trace id 去 log 系統撈出那段時間的細節。
這件事的關鍵在於「傳遞」。在 Go 裡,這個識別碼應該放在 context 裡傳遞,而不是當成函數參數一路傳。我會封裝一個 helper,從 context 取出 request id 和 trace id,自動塞進每一條 log 的欄位裡。這樣業務程式碼裡呼叫 log 時根本不用管 id,它會自動帶上。
日誌分級,以及絕對不要記的東西
日誌分級(log level)聽起來很基礎,但我看過太多服務把分級用壞了。我的原則很簡單:
- ERROR 只給「需要人介入處理」的事。如果一個 ERROR 出現了,但沒人需要做任何事,那它就不該是 ERROR。我看過服務把「使用者輸入了錯誤的密碼」記成 ERROR,結果線上 ERROR log 一天幾百萬條,真正的錯誤完全被淹沒。使用者打錯密碼是預期內的事,那是 INFO 甚至不用記。
- WARN 給「現在還好,但持續下去會出事」的狀況,例如重試了一次才成功、連線池快滿了。
- INFO 給關鍵的業務里程碑,例如訂單建立成功、付款完成。INFO 要克制,不是每一步都記。
- DEBUG 給開發排查用的細節,線上預設關閉,但要能在不重新部署的情況下對特定模組動態打開。
關於分級,我最強調的一件事是:ERROR 的數量應該要少到你願意為每一條 ERROR 設告警。 如果你的 ERROR 多到不可能逐條看,那你的分級一定有問題。
接著是更重要的——絕對不要記敏感資料。 這件事我講得很慎重,因為它踩過真的會出大事,尤其在金流系統。
- 不要記完整的信用卡卡號、CVV。
- 不要記密碼、API 金鑰、token、session id 的明文。
- 不要記完整的身分證字號、個資。
- 不要把整個 request body 或 response body 無腦 dump 進 log,因為裡面常常夾帶上面這些東西。
我的做法是在日誌層做一層遮罩(masking),對已知的敏感欄位自動打碼,例如卡號只留末四碼。但更根本的原則是:從源頭就不要把敏感資料放進會被記錄的結構裡。 因為遮罩永遠有漏網的,總有一天有人新增一個欄位忘了加遮罩。日誌一旦寫進去,它會被同步到日誌系統、被備份、被很多人看到,等於資料外洩的範圍瞬間放大。這在合規上是嚴重事故,我看過因為這個被稽核釘到牆上的團隊。
Metrics:四個黃金訊號與 RED/USE
講完 log,講 metrics。Metrics 的核心問題是:你到底該量什麼? 因為你可以量的東西有無限多,但有用的就那幾類。
Google 的 SRE 書提出了四個黃金訊號(four golden signals),我覺得這是最好的起點:
- 延遲(Latency): 請求花了多久。而且要分開量「成功請求的延遲」和「失敗請求的延遲」,因為一個快速失敗的請求會把你的平均延遲拉得很好看,但其實是壞的。
- 流量(Traffic): 系統承受多少需求,例如每秒請求數(QPS)。
- 錯誤(Errors): 請求失敗的比率。注意有些失敗是隱性的,例如回了 200 但內容是錯的。
- 飽和度(Saturation): 系統有多滿,例如 CPU、記憶體、連線池、佇列長度。飽和度通常是最早預示問題的訊號。
在這之上有兩個方法論,幫你針對不同對象選指標:
- RED 方法 用在「服務」這種有請求進出的東西:Rate(流量)、Errors(錯誤)、Duration(延遲)。每一個對外提供服務的 endpoint,我都會至少埋這三個。
- USE 方法 用在「資源」這種有容量上限的東西:Utilization(使用率)、Saturation(飽和度)、Errors(錯誤)。用在 CPU、磁碟、連線池這類資源。
我的原則是:對服務用 RED,對資源用 USE,兩個合起來大致就涵蓋了四個黃金訊號。 這給你一個系統化的清單,而不是憑感覺東埋一個西埋一個。
用 histogram 看 p99,不要只看平均
這是我要特別強調的一個坑:平均延遲(average latency)幾乎是沒用的指標。
假設你的服務 99% 的請求都是 10ms,但有 1% 的請求是 5 秒。算平均,可能是 60ms,看起來很健康。但那 1% 的使用者體驗是災難性的。在交易所,那 1% 可能就是搶不到單、下不了單的人,他們會在客服那邊罵翻天,而你的監控告訴你一切正常。
正確的做法是看分位數(percentile):p50(中位數)、p95、p99,甚至 p999。p99 是 800ms 的意思是「最慢的那 1% 請求,至少要 800ms」。這才反映了尾端使用者(tail latency)的真實體驗。
要算分位數,你的 metrics 必須用 histogram(直方圖) 這種型別,而不是只記一個平均值。histogram 把延遲分到一個個桶(bucket)裡,例如 0-10ms、10-50ms、50-100ms……記下每個桶有多少個請求,事後就能算出任意分位數。在 Prometheus 生態裡,這對應的就是 histogram 型別的指標。
這裡有個實務細節要注意:histogram 的桶要設得貼合你的實際分佈。 如果你的服務正常是 10ms,但你的桶從 100ms 才開始切,那你就完全看不到正常區間的變化,分位數會算得很不準。我會先用實際流量觀察延遲分佈,再決定桶的邊界。
還有一個常見的坑:跨多個實例的分位數不能直接平均。 你有十台機器,不能把每台的 p99 加起來除以十,那在數學上是錯的。histogram 之所以重要,正是因為它可以把各實例的桶先加總,再從總桶算出全域的分位數,這樣才是對的。
分散式追蹤:把 trace id 一路傳下去
當服務拆成很多個,一個請求會跨越好幾個服務。光看單一服務的 log 和 metrics,你永遠搞不清楚「整體很慢」到底是哪個服務造成的。這就是分散式追蹤(distributed tracing)要解決的。
追蹤的核心概念是 trace 和 span。一條 trace 代表一個完整的請求,由很多個 span 組成,每個 span 代表請求在某個服務、某個操作上花的一段時間。 span 之間有父子關係,串起來就是一棵樹,攤平來看就是一條時間軸,你一眼就能看出哪一段最寬(最慢)。
讓這一切成立的關鍵,是 trace id 的傳遞。一條請求從入口生成一個 trace id,之後不管它呼叫多少下游服務,這個 trace id 都要跟著傳下去,這樣各服務上報的 span 才能被歸到同一條 trace。
在跨服務呼叫時,trace id 是透過請求的標頭(header)傳遞的。現在的業界標準是 W3C Trace Context,它定義了一個叫 traceparent 的標頭,裡面帶著 trace id 和當前 span id。HTTP 呼叫塞在 header、gRPC 呼叫塞在 metadata。下游服務收到後,從標頭解出 trace id,繼續往下傳。
在 Go 裡,這一切都靠 context
在 Go 服務內部,trace 的傳遞是靠 context 串起來的,這跟前面講 request id 是同一套邏輯。
我的做法是用 OpenTelemetry(簡稱 OTel)這套標準。它的好處是廠商中立——你的程式碼只依賴 OTel 的介面,後端要接 Jaeger、Tempo、還是某家雲廠商的追蹤服務,都只是換個輸出設定,不用改業務程式碼。我在這上面吃過虧:早年用某家綁定很深的 SDK,後來想換後端,發現整套 instrument 程式碼都得重寫。所以現在我的原則是:追蹤一律用 OpenTelemetry,絕不綁定特定後端。
實際的傳遞長這樣:
- 請求進入服務時,攔截器(middleware/interceptor)從 header 解出上游的 trace context,放進 context。
- 業務程式碼在做任何一個值得追蹤的操作時,從 context 開一個新的 span,操作結束時關掉它。重點是這個操作裡呼叫下游時,要把帶著 span 的 context 一路傳下去。
- 呼叫下游服務時,OTel 的攔截器會自動從 context 把 trace context 注入到 header 裡。
這裡最容易出錯的,就是 context 斷掉。只要中間有任何一段程式碼沒有把 context 往下傳,trace 就斷了,下游的 span 會變成一條孤立的新 trace,你就拼不回完整路徑。Go 裡最常見的斷點是:開了一個 goroutine 做背景處理,卻沒有把父 context 帶進去。所以我的習慣是:只要這個操作邏輯上屬於同一條請求,就一定要把 context 傳進去,哪怕是非同步的處理。
trace 還有一個很實用的點:你不需要追蹤 100% 的請求。在高流量服務上全量追蹤成本很高,所以會做取樣(sampling)。但我的原則是:取樣可以,但錯誤的請求要保證被採。 一個正常請求漏採沒關係,一個出錯的請求漏採,你就少了最關鍵的線索。所以我會設成「基礎取樣率 + 對錯誤強制保留」。
一個真實的排查場景
把上面講的串起來,講一個我實際遇過的場景。
某天線上告警響了:下單 API 的 p99 延遲從正常的 80ms 飆到 1.2 秒。這是 metrics 告訴我的——它沒告訴我為什麼,但它讓我在使用者大量抱怨之前就知道有事。
第一步,我看 metrics 儀表板。錯誤率沒漲,流量也正常,所以不是被打爆、也不是大量失敗,是「成功但變慢」。我接著看各下游服務的 RED 指標,發現帳戶服務的 Duration 同步噴高,而其他服務正常。到這裡,metrics 已經把範圍縮到帳戶服務。
第二步,我去追蹤系統撈幾條這個時間點的慢 trace。攤開時間軸,清楚看到絕大部分時間都耗在帳戶服務裡的一個 span:查詢使用者餘額。那個 span 正常是 5ms,現在是 1 秒多。trace 告訴我問題卡在哪一個具體操作。
第三步,我拿那條 trace 的 trace id,到日誌系統裡撈帳戶服務在那個時間點、那個 trace id 的 log。因為我們的 log 都帶 trace id,這一撈就精準命中。log 裡看到那段 SQL 查詢的執行時間欄位異常高,而且伴隨著鎖等待的 warning。log 告訴我為什麼——有一個沒走到索引的查詢,加上當時一個批次作業在同一張表上做大量更新,造成鎖競爭。
整個過程從告警到定位根因,大概十分鐘。如果沒有這三本柱、沒有 trace id 把它們串起來,同樣的問題我以前要查一兩個小時,還得一邊加 log 一邊等問題重現。這就是可觀測性的價值——不是平常用得到,是出事的那十分鐘,決定了你是從容處理還是手忙腳亂。
告警:對症狀告警,不要對原因告警
最後講告警,因為告警設不好,前面做的一切都會被噪音淹沒。
我看過太多團隊的告警是「狼來了」:一天響幾十次,大部分是假警報或無關緊要的波動,久而久之大家就把告警靜音了,然後真的出事那次也沒人理。一個沒人看的告警系統,比沒有告警更糟,因為它給你虛假的安全感。
我的核心原則是:對症狀告警,不要對原因告警(alert on symptoms, not causes)。
什麼意思?症狀是「使用者感受得到的壞」,例如「下單 API 錯誤率超過 1%」「p99 延遲超過 500ms」。原因是「可能導致壞的內部狀態」,例如「某台機器 CPU 超過 80%」「某個服務記憶體偏高」。
為什麼要對症狀告警?因為:
- 原因不一定導致問題。 CPU 80% 可能完全沒影響使用者,這種告警響了你也不知道要不要處理,久了就麻木。
- 你列不完所有原因。 導致延遲變高的原因有無數種,你不可能為每一種都設告警。但症狀就那幾個——使用者要嘛遇到錯誤、要嘛遇到慢。對症狀告警,不管根因是什麼,你都會被通知到。
- 症狀告警直接對應使用者體驗。 它響,就代表使用者真的在受影響,這種告警你會願意半夜爬起來處理。
那原因類的指標(CPU、記憶體、飽和度)還要不要看?要,但它們的角色是排查時的線索和容量規劃的依據,不是半夜叫醒你的告警。 我會把它們放在儀表板上,但不設成會呼叫人的告警。
還有幾個告警的實務原則:
- 告警要有可操作性。 一個告警響了,收到的人要清楚知道「現在該做什麼」。如果一個告警響了大家只能互看「這要幹嘛」,那它不該存在。
- 設定要避免抖動。 用「持續 N 分鐘超過閾值」而不是「瞬間超過」,避免一個尖刺就把大家吵醒。
- 分級。 會影響使用者的、需要立刻處理的,才呼叫人(page)。其他的進非緊急通知管道,上班時間看。
可觀測性要在設計階段就想,不是事後補
最後這一點,是我這幾年最大的體悟,也是最常被違反的。
可觀測性是設計的一部分,不是上線後再補的東西。 我看過太多專案,功能急著上線,可觀測性「之後再說」,結果線上一出事,發現關鍵路徑上連個像樣的 log 都沒有,metrics 也沒埋,trace 更是斷的,只能盲猜,或者緊急加 log 重新部署——而問題往往在你部署完之後就不重現了。
事後補的另一個問題是,很多東西事後補不回來。trace 的傳遞要在每一層程式碼裡接好 context,這在事後補是個浩大工程,你得回頭改每一個函數簽名、每一個下游呼叫。但如果一開始就把 context 傳遞當成預設習慣,它幾乎是零成本的。
我現在做新服務、設計新介面時,會把這幾件事當成跟功能同等重要的需求:
- 這個操作的關鍵步驟,要輸出哪些結構化 log?要帶哪些欄位?
- 這個服務對外的 endpoint,RED 指標怎麼埋?
- 這條請求路徑,trace 的 span 怎麼切才能讓我事後看得懂?
- context 有沒有一路傳到底?
這些在寫第一行業務邏輯時就想,成本極低;等到出事才想,成本極高,而且你還是在事故壓力下做的。好的可觀測性,是你在系統健康的時候投資的保險,回報發生在系統生病的那一刻。
小結
可觀測性的三本柱各司其職:metrics 告訴你「有問題」、trace 告訴你「問題在哪」、log 告訴你「問題為什麼」,而把它們串起來的,是一路傳遞的 trace id 和 context。結構化日誌讓 log 可查、histogram 讓你看見尾端的真相、對症狀告警讓你不被噪音淹沒、設計階段就埋好讓你在出事時有牌可打。
說到底,可觀測性不是為了平常好看的儀表板,而是為了出事那十分鐘——當系統在你面前崩塌,你是有資料能冷靜定位根因,還是只能對著一片漆黑禱告。把它當成保險,在系統健康時就買好,因為你不會挑生病的時間。