間歇性 bug 最可怕的地方,不是它會壞,而是它只在你不看的時候壞。
開發者最熟悉的場景大概是這樣:QA 回報某功能偶爾出錯,工程師打開本機重跑五十次,全部正常;上 staging 也正常;只有 production 在某個時段穩定地壞。這時候如果用「再多加幾行 log」當主要策略,通常會花掉一整週還是抓不到。
先分類,再動手
排查詭異 bug 的第一步不是讀程式碼,是先判斷它屬於哪一類。我們的經驗法則大致分成三種:
第一種是狀態污染。同一支程式在乾淨環境跑沒事,跑久了就壞。常見原因是單例物件累積了不該累積的資料、connection pool 沒有正確釋放、或某個 cache key 在特定條件下被覆寫。這類 bug 的特徵是「重啟就好」,而重啟就好往往讓人放棄追根究柢。
第二種是時序競爭。兩個非同步流程在大部分時間裡都是 A 先於 B,但在高負載或特定排程下會反過來。這類問題本機幾乎不可能重現,因為你的筆電沒有 production 的延遲分布。
第三種是外部相依的隱性契約。第三方 API 沒寫在文件裡的 rate limit、DB driver 在連線中斷時的 retry 行為、CDN 對某些 header 的特殊處理。這種 bug 的徵兆是「我們程式沒改,但它今天開始壞了」。
判斷屬於哪一類,比寫任何修補程式都重要。因為這三類的解法完全不同。
用最小可重現逼近問題
舉個例子,假設某個電商後台的訂單匯出功能,平常都正常,但每週一早上開工後三十分鐘內,會出現少數筆資料欄位錯位。工程師第一反應通常是檢查匯出邏輯本身,但這條路通常會走進死胡同。
比較有效的做法是反過來問:週一早上跟其他時間,環境有什麼不同?
可能的差異包括:週末跑的 batch job 剛結束、cache 在沒人用的時段過期、DB 統計資訊在凌晨重算導致 query plan 改變、或是某個排程任務跟匯出搶同一張表的鎖。把這些差異列出來,一個一個排除,比埋頭看匯出函式有效得多。
再舉一個常見情境:假設某 API 整合在開發環境完全沒事,上線後偶爾回 timeout。如果直接加 retry,問題會被掩蓋但不會消失。比較踏實的做法是先抓對方 API 的 response time 分布,看看 P99 跟你設的 timeout 之間還有多少緩衝。很多「詭異 timeout」其實是 timeout 設得剛好卡在對方正常回應的長尾上。
程式片段大致是這個骨架:
# 示意:實際請接你用的監控系統
start = time.monotonic()
try:
resp = client.call(payload, timeout=T)
except TimeoutError:
metrics.record("upstream_timeout", duration=time.monotonic()-start)
raise
metrics.record("upstream_ok", duration=time.monotonic()-start)
關鍵不在程式碼本身,而在於你開始累積分布資料,而不是只看出錯那一刻的 log。
我們的觀察
大部分難纏的 bug,最後追下去都不是「程式寫錯了」,而是某個假設沒被寫下來:假設這個 API 一定會在三秒內回應、假設這張表不會同時被兩個流程寫、假設 cache 過期跟資料更新會同步。把這些隱性假設攤開來檢視,往往比任何工具都有效。寫程式的時候多寫一行註解說明前提,未來的自己會少熬好幾個夜。
