「這個 bug 只在星期一早上出現」——當工程師講出這句話,八成不是他們在推卸,而是系統真的在某個特定條件下才會壞。而這種 bug,通常比 NullPointerException 難處理十倍。
這類疑難雜症的共同點是:它不是程式邏輯錯,而是環境、時間、狀態或第三方服務的某個組合出了問題。你在本機跑一百次都對,一上正式環境就壞;或者上線三個月都好好的,某天忽然開始間歇性 500。這時候一味 code review 是沒用的,得換一套思路。
先建立「可重現」,再談「修好」
很多團隊面對詭異 bug 的第一反應是:翻 code、猜原因、丟一版上去試。這種做法的問題在於,如果 bug 本身無法穩定重現,你根本不知道下一次它不出現是因為修好了,還是只是條件沒湊齊。
更務實的順序是:
- 先鎖定觸發條件。收集所有出問題時的 log、時間戳、使用者、輸入資料、上游服務狀態。列一張表比對,找出交集。
- 在測試環境重現。如果重現不出來,代表你對觸發條件的理解還不夠。這時候與其硬修,不如加更多觀測點(tracing、結構化 log、metric),等它下次出現時把現場保留下來。
- 確認假設後才動 code。改一行程式的成本不高,但改錯方向、讓 bug 潛得更深的成本很高。
舉個例子(假設情境):一個電商 API 偶爾回傳空的商品清單,但重打就正常。如果直接猜是資料庫連線問題然後加 retry,可能真正的原因其實是快取層在 key 過期的瞬間、與後端更新之間有 race condition,retry 只是剛好避開那個窗口,bug 還在,只是更難被抓到。
幾個常見的「元兇類別」
實務上,難搞的 bug 大多不脫這幾類:
- 時間與時區:跨時區的 job、日光節約、UTC 與本地時間混用。程式在午夜、月底、閏年、週一凌晨忽然壞掉,先往這邊查。
- 快取一致性:多層快取(CDN、應用層、DB 層)之間狀態不同步。特徵是「重新整理就好了」或「換一台機器就正常」。
- 第三方 API 的默默變更:對方沒發公告改了回傳格式、加了 rate limit、或某個欄位從必填變選填。你的 log 只會看到解析失敗,追不到根因。
- 併發與資源競爭:連線池、檔案 lock、資料庫 transaction isolation。在流量低時完全看不到,一到尖峰才炸。
- 設定漂移:正式環境和測試環境的環境變數、feature flag、憑證版本不一致。
面對這幾類,加 log 的位置很關鍵。與其在每個函式進出都印,不如在「跨邊界」的地方——呼叫第三方前後、快取讀寫前後、transaction 開始與結束——加上結構化的 trace id。這樣一份 log 撈出來就能看到整條路徑,而不是一堆片段。
# 示意:跨邊界處加上 trace,實務請搭配 OpenTelemetry 等工具
logger.info("cache.read", key=key, hit=hit, trace_id=ctx.trace_id)
resp = external_api.call(payload)
logger.info("external.call", status=resp.status, latency_ms=elapsed, trace_id=ctx.trace_id)
我們的觀察
難搞的 bug 幾乎都在提醒你一件事:這個系統對自己的狀態沒有足夠的觀測能力。與其把時間花在事後猜謎,不如平時就在關鍵邊界埋好 trace 與 metric。真正省時間的除錯,是讓下一個 bug 出現時,log 已經幫你寫好了半份報告。
