結論先說:Fault、Error、Failure 位在不同層次。先壓低使用者看見的 failure,再收集 error 證據,最後才修正 fault;把一條 503 告警直接叫做根因,通常會讓修復走錯方向。
Day 9 已經畫出 service boundary 與 critical path。今天要替那條路徑加上因果模型。它不是考試名詞,而是 on-call 在五分鐘內要回答的三件事:誰受影響?哪一段正在出錯?什麼條件讓它出錯?
本文以 policy lookup API 為例。使用者查詢遠端工作政策,policy-api 讀取 policy store,回傳資料或明確告知暫時無法取得。這篇(上)先建立因果模型、拆解一個 503 背後可能藏的五種根因、談事故處理的優先順序,再用三個真實事故驗證這套模型;telemetry 設計與可動手做的 FastAPI 練習留到(下)。
先別從 exception 開始。先寫下 service contract:
已授權使用者查詢政策時,服務在可接受時間內回傳目前資料;必要 dependency 暫時不可用時,服務回傳可辨識、可重試的暫時失敗,不假裝回答正確資料。
寫 contract 常見的另一個陷阱,是把它寫成一份無法被檢驗的願望清單——「系統應該要穩定」「使用者體驗要好」這類句子讀起來政治正確,卻沒有辦法拿來對照任何一次具體觀察,判斷它到底有沒有被滿足。一條好用的 contract,句子結構要能被拆成「條件」與「承諾」兩半,並且每一半都要能對應到一個可以檢查的事實:
Contract(可檢驗) ≠ Aspiration(不可檢驗)
已授權使用者查詢政策時, "系統應該要穩定"
服務在可接受時間內回傳目前資料; "使用者體驗要好"
必要 dependency 暫時不可用時, "我們重視可靠性"
服務回傳可辨識、可重試的暫時失敗,
不假裝回答正確資料。
↓ 可以直接對照一次觀察來判斷 ↓ 沒有任何一次觀察可以
「這句話有沒有被滿足」 拿來反駁這句話
左邊那句話的每個子句都有明確的檢驗方式:「已授權使用者」排除了未授權存取的情境;「可接受時間內」需要另外定義一個數字(這是 SLO 的工作,這裡先不展開);「不假裝回答正確資料」直接排除了「回傳過期資料卻沒有標示」這個行為。右邊那句話無論系統做什麼,都可以宣稱自己符合,因為它本來就沒有給出任何可以被違反的具體條件。
這條 contract 對應 Day 9 的 critical path:
使用者查詢遠端工作政策
↓
Gateway 驗證請求
↓
policy-api 讀取政策資料
↓
回傳目前政策,或明確說明暫時無法取得
沒有這句承諾,團隊會用各自喜歡的訊號判定「壞了沒」。有人看 CPU,有人看 exception,有人看 HTTP 200。三個都可能是真的,卻沒有一個等於使用者完成了工作。
| 觀察到的事情 | 一定是 Failure 嗎? | 原因 |
|---|---|---|
| DB client 出現一次 timeout | 否 | retry 或 fallback 可能已吸收,使用者未受影響。 |
| API 回傳 503 | 取決於 contract | 若承諾必須取得資料,這是 failure;若允許明確拒絕,則是受控降級,仍要納入可用性設計。 |
| HTTP 200 但回傳過期且未標示的政策 | 是 | transport success 不等於資料正確。 |
| 頁面超過產品承諾時間才完成 | 是 | 使用者工作沒有在承諾時間內完成。 |
| 回傳明確的 503 與 Retry-After,client 依指示重試後成功 | 否 | 這是受控降級被完整履行的樣子,contract 承諾的正是「明確、可重試」,不是「絕不失敗」。 |
| AI workflow 回傳語氣肯定、格式正確,但事實錯誤的答案 | 是 | 沒有任何 exception 或 5xx,但使用者依然沒有得到承諾的正確結果,屬於第 ② 節談的無聲 failure。 |
503 不是自動免責卡。它只比裸露的 500 更清楚。是否為 failure,要回到使用者被承諾什麼;是否為好設計,要看服務是否能誠實說明狀態並避免錯誤資料。
Day 9 已經替 policy-api 畫出 service boundary 與 critical path,這一步理論上足以讓團隊開始寫 dashboard 了。但先寫 dashboard 常掉進一個順序錯誤:拿現成的 metric(HTTP status、CPU、exception count)去反推「什麼叫壞掉」,而不是先問「使用者被承諾了什麼」再去找哪個 metric 能代表它。
這個順序錯誤會直接反映在告警設計上:某團隊看到 exception count 上升就建 alert,卻常在「使用者完全沒受影響」時觸發(exception 被 retry 吸收了);同時真正讓使用者卡住的情況(回傳過期資料但沒有錯誤碼)完全沒有告警,因為那條路徑從未拋出過 exception。兩種偏差的根源相同:把系統內部訊號直接當成使用者體驗的代理,跳過了「contract 先於 metric」。
contract 不必是正式文件,一句 code review 註解就夠,重點是它明確到能拿來檢驗任何一次觀察:
給定:contract = "已授權使用者查詢政策時,服務在可接受時間內
回傳目前資料;必要 dependency 暫時不可用時,服務回傳
可辨識、可重試的暫時失敗,不假裝回答正確資料。"
檢驗:某次觀察 X 是否違反 contract?
是 → X 是 failure,無論有沒有 exception、有沒有 5xx
否 → X 可能只是內部 error 或正常的降級路徑
一個常見誤解,是把「系統視角看起來正常」直接翻譯成「使用者也沒事」——這個翻譯只有在 contract 明確定義「正常」是什麼時才成立。如果 contract 只寫「HTTP 200 視為成功」,一個回傳 200 但內容是半年前資料的請求,系統視角完全正常,使用者視角卻是不折不扣的 failure。這正是 Day 7 談過的 Technical Success ≠ Semantic Success:contract 沒寫清楚「正確」是什麼,系統視角乾不乾淨就毫無意義。
這裡刻意不展開 SLI/SLO 的完整設計,因為兩者先後順序常被顛倒:contract 回答「什麼構成 failure」這個定性問題,SLI/SLO 回答「多少 failure 在多長時間內算可接受」這個定量問題。定性問題沒解決,定量問題就無從問起——如果連「回傳過期資料算不算 failure」都沒共識,爭論「available 門檻設 99.9% 還是 99.95%」就是在沒有共同定義的基礎上吵架。等後面幾天談 SLI/SLO 設計時,這句 contract 會直接變成挑選 SLI 的依據:能對應 contract 裡「條件」與「承諾」的訊號才有資格當候選;只反映系統內部狀態、對不回 contract 的訊號,無論多容易取得,都不該當作「使用者是否被服務好」的代理。
最小模型如下:
Fault:系統內可造成問題的缺陷或狀態
↓ 在特定條件下被觸發
Error:執行中的元件進入不正確或不預期的狀態
↓ 沒有被隔離、吸收或妥善轉換
Failure:服務沒有履行對使用者或下游的承諾
Fault 可以是程式碼、設定、容量、流程或依賴設計上的缺陷:
- DB client timeout 設成 60 秒,沒有使用者可接受的等待上限
- exception path 漏掉 connection release
- endpoint 指向錯誤網域
- retry 沒有上限,慢請求反覆佔住 worker
- 把 stale policy 當成目前資料,卻沒有標示版本
Fault 可以潛伏很久。某個 error path 不釋放連線,不代表每次請求都失敗;它可能只在特定流量、特定 error 或 deployment 後被觸發。因此「昨天沒壞」不是設計正確的證據。
Avižienis 等人的分類法還進一步依照持續時間把 fault 分成幾種型態,這個區分在判斷「怎麼修」的時候特別有用,因為不同型態的 fault 需要完全不同的應對策略:
| Fault 型態 | 特徵 | 本文對應案例 | 適合的應對 |
|---|---|---|---|
| Permanent(永久性) | 一旦存在,條件符合就必定觸發 | 情境 A 的 DNS 設定錯誤 | 直接修正,rollback 或改設定 |
| Transient(暫態性) | 由環境條件短暫觸發,環境條件消失就不再出現 | 一次性的網路封包遺失 | retry、timeout,通常不需要改程式碼 |
| Intermittent(間歇性) | 觸發條件本身反覆出現,症狀跟著反覆出現 | Clerk 每 15 分鐘一次的連線風暴 | 需要先找出反覆出現的觸發條件,而非一次性修復 |
分不清楚眼前的 fault 屬於哪一種,很容易用錯力氣:把 intermittent fault 當 transient 處理(只靠 retry 硬撐),只會讓同樣的 failure 一次又一次重演;把 transient fault 當 permanent 處理,則可能在沒問題的地方引入新風險。(下)會談到的一個誤判——「fake timeout 重現了,所以 production 一定同一個 Fault」——本質上也是搞混了 fault 型態。
這個「先潛伏、後觸發」的性質,也是 Fault、Error、Failure 這條鏈值得認真對待、而不是三個可以互換的同義詞的原因。這套區分不是本系列自創,而是可靠性工程裡流傳已久的分類——Avižienis、Laprie 與 Randell 在 IEEE Transactions on Dependable and Secure Computing 上發表的〈Basic Concepts and Taxonomy of Dependable and Secure Computing〉,把它定義成一條標準的因果鏈:
Fault(缺陷)
↓ activated(被觸發)
Error(系統內部狀態偏離正確)
↓ propagated(沒有被吸收,向外傳播)
Failure(外部可觀察行為偏離規格)
這篇論文用的詞是 dependability(可靠性)而非 SRE 常講的 reliability,但骨架相同,且特別強調:fault 不會自動變成 error,error 也不會自動變成 failure,中間各有一個條件句(activated、propagated)。connection pool 設定不當是 fault,只有流量衝上去把 pool 撐爆才變成 error;這個 error 有沒有變成使用者看得到的 failure,取決於系統有沒有 retry、fallback、circuit breaker 把它攔下來——永遠不該看到 fault 就假設它必然變成 failure,也不該看到 failure 就跳過中間的 error 直接猜 fault。
policy store 沒在 deadline 內回覆時,client 出現 timeout,這是 error。其他例子包括:
- DNS lookup 失敗
- connection refused
- connection pool 沒有可用連線
- upstream 回傳不符合 schema 的資料
- retriever 找不到足以回答問題的來源
TimeoutError 只是調查入口。它沒有說明是 DB 慢、網路壅塞、pool 耗盡、deadline 太短,還是 upstream 根本沒有收到 request。把 error class 當 root cause,是事件分析最便宜也最危險的捷徑。
把 error 想像成「一定會留下痕跡」是另一個常見誤解。error 只是系統內部狀態偏離正確,它留不留下可觀察的訊號,完全取決於系統有沒有主動去偵測、記錄它。以下三種情況都是真實發生過 error,卻可能完全沒被觀測到:
- exception 被一個空的 except 區塊吞掉,沒有 log 也沒有 metric
- retry 把第一次 timeout 悄悄重試成功,只有第二次以後的
重試會留下計數,第一次的 error 從未被記錄
- 上游把逾時的請求判為成功並回傳快取的舊資料,
error 被「正確地」隱藏在一個技術上合法的回應裡
第三種情況特別值得留意,因為它模糊了 error 和 failure 之間的界線:系統內部確實發生了偏離(拿不到最新資料),但因為設計選擇(回退到快取),這個 error 沒有變成 failure——前提是這個回退行為本身有被記錄下來、並且產品明確允許 stale read。如果沒有任何記錄,這種「悄悄成功」看起來和真正健康的請求一模一樣,等到有人發現資料是舊的,才會回頭發現這裡其實發生過一次沒被觀測到的 error。
Error 與 Fault 之間也常被搞混。一個簡單的判斷方法是看它能不能被「修好」而不用改程式碼:重啟服務、清空一個卡住的 queue、手動釋放連線,這些動作都在清除 error(讓系統回到正確狀態),但它們不會動到造成 error 的那個 fault。fault 還在原地,下一次觸發條件出現時,同樣的 error 還會再發生一次。這正是(下)會展開的「重啟後好了,所以已經解決」這個誤判的根源。
Failure 站在使用者或必要下游的角度。使用者拿不到資料、拿到錯誤資料、等得太久,或無法安全完成工作,才是 service failure。
AI workflow 也有無聲的 failure:
Fault:retrieval index 使用錯誤的文件版本
↓
Error:模型上下文不足,卻仍產生肯定答案
↓
Failure:使用者依錯誤政策採取行動
如果 fault、error、failure 之間沒有中間地帶,reliability 工程本身就沒有存在的必要——工程師能做的事只剩下「消滅所有 fault」,這在任何有一定規模的系統裡都不可能做到。真正讓工程有施力空間的,是 error 到 failure 這段轉換並非必然,而是可以被主動攔截的:
Error 發生
↓
有沒有 retry 把它悄悄變成成功? → 有:不會變成 failure
↓ 沒有
有沒有 fallback 提供可接受的替代結果? → 有:不會變成 failure(但要標示降級)
↓ 沒有
有沒有 circuit breaker 把它轉成快速的
明確拒絕,而不是讓使用者乾等? → 有:failure 存在,但被縮小成
「明確、可重試」的可控形式
↓ 都沒有
Failure:使用者直接承受這個 error 的完整後果
這條鏈也解釋了為什麼第 ① 節的 contract 要特別區分「暫時不可用時回傳明確的暫時失敗」和「假裝回答正確資料」——即使系統設計得再完善,某些 error 終究會演變成使用者可見的 failure,這時候唯一能做的是控制這個 failure 的「形狀」:讓它明確、可辨識、可重試,而不是包裝成一個看起來正常、實際上錯誤的回應。本文從頭到尾都不把「有 failure 發生」本身當成問題——沒有任何系統能做到零 failure,值得檢討的永遠是「這次 failure 有沒有被設計成使用者能理解、能應對的形狀」。
另一個常見的 AI workflow failure pattern 是 tool call 層級的沉默失敗:agent 呼叫工具時收到不完整或格式錯誤的結果,卻當作有效狀態繼續執行,最後產出錯誤答案。對生產環境 AI agent 多層故障的分析發現,超過六成的多層事件裡,tool-call failure 是最容易被觀察到的入口點,但真正的起點往往在更上游的檢索或認證層——例如 tool 回傳被分頁截斷的部分紀錄、OAuth token 被撤銷時 connector 回傳空 dataset 而非明確的認證失敗訊號,agent 把它解讀為「沒有相關資料」而非「無法驗證」。這些型態的共同點,是每一層都各自「成功」(tool 回傳 200、agent 拿到格式合法的 JSON),沒有任何一層主動抗議,故障因此難以偵測。AI Agent Tool Call Failures in Production:多篇工程分析與事故彙整
這條鏈可能沒有 exception、5xx 或 CPU 尖峰。Day 7 的 Technical Success ≠ Semantic Success 在這裡不是旁枝:錯誤的答案同樣是 failure,只是需要 evaluation、dataset 或人工 review 才看得見。
如果把上面的鏈套進一個常見的 RAG(Retrieval-Augmented Generation)workflow,可以看到 error 藏在比想像中更早的位置。業界對 production RAG 系統的失敗模式分析,整理出至少八種會讓「檢索器正常運作、LLM 有回應、API 回傳 200」但答案依然錯誤的型態,這裡挑最相關的幾種:
Fault:chunking 策略把關鍵上下文與它的代詞來源切到不同區塊
↓
Error:retriever 準確命中相關文件,卻只取回缺少代詞先行詞的片段
↓
Failure:模型在推論時把「它」錯誤地指回另一個實體,
回答內容表面通順、格式正確,但事實錯誤
除了上面詳細展開的兩種,其餘幾種常見的沉默 failure 型態,用同一套因果鏈簡短列出,能讓讀者快速比對自己的系統是否也有類似風險:
| 失敗型態 | Fault 的位置 | 為什麼技術指標看不出來 |
|---|---|---|
| 過時索引 | 文件已更新,向量索引未同步重建 | retriever 命中「曾經正確」的區塊,回傳成功 |
| Embedding 版本不一致 | 索引與查詢用不同版本的 embedding 模型 | 相似度分數正常,只是排序邏輯已經失準 |
| 檢索範圍過窄 | top-k 設得太小,遺漏必要的補充上下文 | 每個回傳的片段各自看都正確,組合起來卻不完整 |
| 多文件衝突未標示 | 兩份文件內容矛盾,系統未標示版本優先序 | 模型隨機選了其中一份,兩次詢問可能給出不同答案 |
這幾種型態的共通點,和前面兩種一樣:每一個環節單獨看都「正常運作」,問題只存在於環節與環節之間的接縫。這也是為什麼(下)反覆強調的三根 telemetry 支柱,對這一整類 failure 幾乎無能為力——它們天生只能觀察系統的技術執行狀態,觀察不到「這個答案跟事實是否一致」這種語意層級的問題。
另一種更難察覺的型態,業界稱為 Parametric Memory Override:模型的參數記憶蓋過檢索到的事實。文件明確寫著「每分鐘 100 次請求」,模型卻因為訓練資料裡看過大量「60 requests/min」這類常見數字,在生成時悄悄替換成訓練時記住的版本。retriever 做對了它的工作,error 發生在生成階段——模型權重儲存的統計規律不會因為這次拿到正確 context 就被關掉,仍參與每個 token 的機率計算,某些情況下訓練記憶的權重就是比只出現一次的這份 context 更重。
這對系統設計的意涵是:光看「retrieval 有沒有命中正確文件」永遠無法保證輸出忠於這份文件。要偵測它,需要額外一層事實一致性檢查——把生成的答案跟 retrieved context 逐項比對,確認數字、專有名詞、日期有沒有被憑空改寫。這正好對應前面工作流程表格裡 Validator 節點「格式合法但事實錯誤的輸出通過驗證」那一格:多數團隊的 Validator 只檢查 JSON schema 或關鍵字黑名單,卻沒檢查「這個數字是不是真的出現在剛剛餵給模型的 context 裡」。
這條鏈值得放進今天的模型,原因是它示範了「retriever 正常」和「檢索系統成功」是兩件事——如同 HTTP 200 不等於 Semantic Success,retriever 命中 也不等於 context 完整且沒有被模型覆蓋。要偵測這一類 failure,metrics、logs、traces 三件套完全無能為力,唯一能看見它的是針對答案內容設計的 evaluation dataset 或人工抽樣審查,這也是為什麼 Season 2 會把 evaluation pipeline 列為獨立主題。
前面 policy-api 的例子只有一個 dependency(policy store),容易讓人誤以為 Fault-Error-Failure 這套模型只適合這種簡單的「service 呼叫 DB」場景。實際上,一個典型的 AI workflow 有更多節點,每個節點都可能是 fault 潛伏的位置,而且不同節點潛伏的 fault 型態彼此差異很大:
使用者請求
↓
Gateway(驗證、rate limit、路由)
↓
Prompt / Policy(組裝 prompt、套用 system instruction)
↓
Retriever → Vector DB(語意搜尋、取回相關文件片段)
↓
Model → LLM Provider(生成回應)
↓
Tool Executor(呼叫外部工具、API)
↓
Validator(檢查輸出格式、安全性、事實性)
↓
Response
這條管線本身就是 Day 9 critical path 的一個具體案例,也是這個系列後面幾十天會不斷回頭引用的骨架。把 Fault-Error-Failure 套進每一個節點,會得到一張遠比「DB timeout」豐富的故障地圖:
| 節點 | 典型 Fault | 典型 Error | 若未被攔截,典型 Failure |
|---|---|---|---|
| Gateway | rate limit 門檻設得比下游真實承載量高 | 下游 LLM provider 回傳 429 | 使用者請求被 provider 拒絕,卻收到通用的 500 |
| Prompt / Policy | system prompt 版本與 model 版本不同步 | 組裝出的 prompt 超過 context window 上限 | 請求被截斷或直接被 provider 拒絕,答案缺少關鍵指令 |
| Retriever / Vector DB | embedding 模型與索引建立時用的版本不一致 | 語意搜尋回傳看似相關、實際無關的片段 | 模型基於錯誤上下文生成看似合理但錯誤的答案 |
| Model / LLM Provider | 沒有為 provider 的暫時性錯誤設計 fallback | provider 端逾時、rate limit 或模型輸出被安全過濾攔截 | 使用者收到空白回應,或整個 workflow 拋出未分類例外 |
| Tool Executor | 工具呼叫的回傳格式假設過於樂觀,未檢查分頁或截斷 | 工具回傳部分結果,卻沒有標示「不完整」 | agent 誤把部分結果當完整資料,後續推論建立在錯誤前提上 |
| Validator | 驗證規則只檢查格式,不檢查內容與來源是否一致 | 格式合法但事實錯誤的輸出通過驗證 | 錯誤答案直接送到使用者面前,且沒有任何訊號被記錄 |
這張表格最值得注意的一欄是最後一欄:越靠近管線末端的節點(Validator、Response),一旦沒有攔下 error,距離使用者只剩下最後一步,錯誤幾乎沒有機會被中途吸收。Validator 在 AI workflow 裡的地位,某種程度上等同於傳統系統裡的「最後一道防線」——它存在的意義不是再做一次 model 的工作,而是專門檢查前面每個節點可能各自合理、組合起來卻錯誤的結果。
把這張表格和 Day 7 的 Technical Success ≠ Semantic Success 放在一起看,會發現一個規律:越靠近管線前段(Gateway、Prompt)的 fault,觸發後通常會產生看得見的 error(exception、非 2xx 狀態碼);越靠近管線後段(Retriever、Model、Validator)的 fault,反而更容易產生「無聲」的 failure——技術上一路暢通,只有答案本身是錯的。這不是巧合:管線前段處理的是結構化、有明確規格的資料,後段處理的卻是自然語言這種沒有單一正確格式的資料,錯誤自然更難用一個布林值去框住。
這個系列反覆提到的 AI Reliability 七層框架(Infrastructure / Service / Dependency / Workflow / Semantic / Economic / Governance)不是七個互相獨立的抽屜,而是同一次事故從不同角度切出來的七種視角。Fault-Error-Failure 這條因果鏈,正好是貫穿這七層的一條「垂直軸」:同一個 fault,可能誕生在任何一層,而它演變成的 error 與 failure,往往會出現在完全不同的層:
Fault 誕生的層 Error 出現的層 Failure 被看見的層
Infrastructure Infrastructure Service(使用者收到 503)
(DNS 設定錯誤)
Dependency Service Workflow
(DB pool 上限過低) (worker 被佔滿) (多個下游同時變慢)
Workflow Semantic Governance
(chunking 策略切錯) (retriever 取回不完整) (使用者依錯誤答案行動,
上下文) 事後才被追責)
第三行是最容易被忽略的一種跨層演變:一個藏在 Workflow 層的設計缺陷(chunking 策略),觸發後產生的是 Semantic 層的 error(檢索到的上下文不完整),而它真正變成 failure、被人發現的時間點,卻可能遠在 Governance 層——例如一次法規遵循稽核,時間上可能是原始 fault 被引入之後好幾個月。這正是為什麼把「哪一層失敗了」和「哪一層修」混為一談會出問題:如果只盯著 Governance 層的稽核結果找答案,永遠不會查到根因其實躺在幾個月前一次不起眼的 chunking 邏輯調整裡。
反過來說,這也是為什麼本文第 ④ 節堅持「先減少 Failure,再追 Error,最後移除 Fault」這個順序要跨層執行,而不是要求同一個團隊一路追到底:Governance 層能做的通常是先暫停功能、發出警示,縮小的是 Failure;真正回頭去改 Workflow 層的 fault,往往是完全不同的團隊、在完全不同的時間點才能完成。七層框架存在的意義,某種程度上就是承認「誰能看見 failure」和「誰能修 fault」經常不是同一群人,需要一套共同語言讓兩邊的證據能夠對接。
跨層演變還有一個實務陷阱:每一層通常都有自己專屬的 telemetry 系統,彼此預設不互通。當 fault 從一層跨到另一層時,能不能順利接上證據,很大程度取決於團隊有沒有事先建立跨層的關聯欄位——例如讓 Semantic 層的 evaluation 紀錄也帶著 request_id,才有辦法回頭對到 Dependency 層那次 retrieval 呼叫的 trace。沒有這個關聯欄位,兩層各自的證據就是兩座孤島。
當 /policy/remote-work 回傳 503,不要在 incident channel 立刻寫「DB 掛了」。以下五條鏈在使用者畫面上都可能長得一模一樣——同樣的 status code、同樣的錯誤訊息、同樣讓 on-call 的手機震動——但它們的證據位置、mitigation 方式和誰該去修完全不同。這正是「一個告警名稱可以對應無數個 fault」這件事最直接的示範:如果值班手冊只寫著「看到 policy-api 503 就重啟 pod」,遇到情境 A(DNS 設定錯)重啟毫無幫助,遇到情境 C(deadline 沒設)重啟只能暫時緩解,真正該做的是改設定值。
在往下讀五個情境之前,先建立一個習慣:把每個情境都當成一個獨立的假設,而不是唯一的答案。實務上,一次真正的事故往往是好幾個情境同時發生一部分——例如 deployment 同時改了設定(情境 A 的種子)又剛好撞上流量高峰(情境 B 的種子),最後才引爆成使用者看到的 503。把單一情境當成排他選項,反而會讓調查在還沒收集齊證據前就停下來。
Fault:POLICY_STORE_URL 指向不存在的內部網域
↓
Error:DNS lookup failed
↓
Failure:使用者查不到政策,收到 503
證據應是 deployment diff、環境設定、DNS error 與受影響開始時間。剛 rollout 時,rollback 或修正設定往往比調高 DB connection limit 更有用。
這類情境的辨識訊號通常非常乾脆:error rate 幾乎是階梯式(step function)上升,不是漸進爬升,而且開始時間和某次 deployment 或設定變更幾乎完全對齊。一條可以先跑的 PromQL,是把 error rate 和最近一次 deployment 標記疊在同一張圖上看有沒有對齊:
sum(rate(policy_lookup_requests_total{outcome="temporary_unavailable"}[5m]))
/
sum(rate(policy_lookup_requests_total[5m]))
如果這條線在某個時間點幾乎垂直拉起,且該時間點對齊最近一次 rollout,這是支持「設定 Fault」假設的強證據,但仍只是證據不是結論——階梯式上升也可能來自情境 B。真正把兩者分開的,是去查連線層 error log 有沒有出現 NXDOMAIN 這類字樣;有,才真正鎖定是設定問題。
一份能證明這個假設的 deployment diff,通常長得很平淡,平淡到很容易在 code review 時被跳過:
environment:
- POLICY_STORE_URL: "http://policy-store.internal.svc:8080"
+ POLICY_STORE_URL: "http://policy-store.internal-v2.svc:8080"
一個字元的網域差異,在 review 畫面上可能只是一行「換了個比較新的服務名稱」的變更,審查者若沒確認 internal-v2 是否已存在、DNS 是否解析得到,這條 fault 就會直接被合併進主幹——這正是情境 A 最陰險的地方,不需要任何複雜的錯誤,一個打錯的字元就夠。
Fault:connection pool 上限與 worker concurrency 不相稱
↓
Error:等待 pool connection 超過 deadline
↓
Failure:高流量期間部分查詢逾時
要看 pool wait time、in-flight request、queue length 與 dependency span。DB CPU 正常,pool 仍可能耗盡;只盯著 CPU 的 dashboard 會錯過這一層。
這個情境最容易讓值班工程師誤判方向,因為第一直覺通常是打開 DB 的 dashboard 看 CPU 與 memory,而這兩條線往往完全正常——真正該看的指標在應用層,不在資料庫本身:
# pool 裡目前借出、尚未歸還的連線數,逼近上限就是警訊
policy_api_db_pool_in_use_connections
# 請求為了拿到一個連線,平均等了多久
histogram_quantile(0.95, rate(policy_api_db_pool_wait_seconds_bucket[5m]))
如果 pool wait time 的 P95 在流量高峰期間從幾毫秒跳到幾百毫秒甚至數秒,同時 in-use connections 貼著設定的上限,這就是資源 fault 的典型指紋。修法有兩種方向:把 pool 上限調大,或把 worker concurrency 調小——前者能立即緩解,但若 DB 承載量也接近極限,只是把瓶頸搬到資料庫層;後者更保守,卻可能犧牲吞吐量。這個決定屬於容量規劃,不該在事故現場臨時拍板,而是要在 postmortem 的 follow-up 裡正式討論。
一個粗略但實用的起手式,是把 pool 上限想成一個容量公式,而不是一個憑感覺挑出來的數字:
所需 pool 上限 ≈ 尖峰每秒請求數 × 每次請求平均占用連線的秒數 × 安全係數
例:尖峰 200 req/s,每次查詢平均占用連線 30ms,安全係數 1.5
所需上限 ≈ 200 × 0.03 × 1.5 = 9 條連線
若目前設定值遠低於這個估算(例如只設了 5),
高流量期間 pool wait time 飆升幾乎是必然,不是意外。
這個公式不追求精確,它的價值在於把「pool 上限該設多少」從一句直覺判斷,變成一個可以寫進 deployment 檢查清單、可以在流量成長時重新計算的具體算式。第 ④ 節提到的「把 pool 上限設定加進 deployment diff 的自動化檢查」,落地時通常就是把這個公式寫成一個 CI 檢查。
Fault:沒有 deadline、retry budget 或降級策略
↓
Error:policy store 延遲升高
↓
Failure:慢請求佔滿 worker,連健康查詢也被拖慢
這時候單次 timeout 數量只是前兆。等待中的 request 會增加,worker 被佔住,下一批 request 排隊更久。failure 從「部分資料讀不到」擴大成「整個 API 不回應」。
背後的機制其實很直白:多數 web framework 的 worker(thread 或 process)一次只能服務一個 request,等它呼叫 dependency 期間就同步卡住,沒辦法轉去處理別的 request。如果 dependency 沒有 deadline,一個原本只該花 50ms 的呼叫可能卡住這個 worker 好幾秒;worker 池總數固定,被卡住的越多,能接新 request 的就越少。到某個門檻之後,連原本幾毫秒就能回覆的健康檢查,都要排在一整串被拖慢的 request 後面才輪得到——這就是為什麼「資料庫本身沒有掛」,服務卻整個看起來停擺。設定合理的 per-call deadline、用 bulkhead(借自船艙的防水隔艙比喻)限制同時等待中的呼叫數量,是讓這種放大不要發生的基本手段:把「呼叫慢 dependency 的 worker」和「處理其他請求的 worker」用獨立 thread pool 或信號量隔開,一個 dependency 變慢就不會透過共用 worker pool 把傷害擴散到所有無關的請求。
判斷「這是情境 C」還是「單純情境 B 資源不足」,關鍵在於看健康檢查本身的行為。如果連 /healthz 這種完全不碰 DB、只回傳 {"status": "ok"} 的 endpoint 都開始逾時,那幾乎可以確定是 worker 被佔滿,而不是資源上限被打穿——因為健康檢查根本不需要任何資源,它慢下來只能是因為排在它前面等 worker 的請求太多。
沒有 deadline 的呼叫和有 deadline 的呼叫,寫成程式碼看起來差異不大,行為卻天差地遠:
# 沒有 deadline:worker 願意無限期等下去
def fetch_policy_unsafe(policy_id: str) -> dict:
return policy_store_client.get(policy_id) # 可能卡住任意長時間
# 有明確 deadline:worker 最多只被佔用固定時間
def fetch_policy_safe(policy_id: str) -> dict:
return policy_store_client.get(policy_id, timeout=0.5) # 500ms 後主動放棄
timeout=0.5 這一個參數,把「worker 最壞情況下會被卡住多久」從「不知道,取決於 dependency 的狀態」變成「已知的 500 毫秒上限」——這個上限可能還是太長或太短(後面談 SLO 時才會展開),但至少把一個無界的風險變成一個有界、可以拿來做容量規劃的數字。這正是為什麼情境 C 的 fault 描述特別強調「沒有 deadline」而不是「dependency 太慢」——dependency 變慢幾乎無法避免,但要不要讓這個變慢有一個明確的止損點,完全是本方系統的設計選擇。
Fault:handler 將任何讀取例外轉成沒有錯誤碼的 500
↓
Error:一次可恢復的 dependency timeout
↓
Failure:client 無法判斷可否重試,使用者只看到 Internal Server Error
這裡 dependency 可能幾秒後恢復,但 API 已把一個可理解的情況包成黑盒子。Failure 不只發生在底下的 service 掛掉,也可能發生在我們沒有把失敗說清楚。
這種 fault 常常藏在一段看起來「防禦性很強」的程式碼裡,例如:
@app.get("/policy/{policy_id}")
def read_policy(policy_id: str) -> dict[str, str]:
try:
return store.fetch_policy(policy_id)
except Exception:
# 立意良善:不想讓任何例外洩漏出去
raise HTTPException(status_code=500, detail="internal error")
寫這段程式的人多半是想避免 stack trace 洩漏到 client,出發點沒有錯,但 except Exception 把 TimeoutError(可重試)、ValidationError(不該重試)、PermissionError(重試也沒用)全部壓進同一個 500,client 唯一能做的判斷只剩下「這個服務目前不能用」。這是本文 ⑦ 節 DIY 練習要驗證的核心 contract:dependency 的暫時性錯誤,不該被降級成一個沒有任何線索的黑盒子 500。
同一段邏輯,把 except Exception 拆成按錯誤類型分流,就能讓 client 拿到真正可以採取行動的資訊:
@app.get("/policy/{policy_id}")
def read_policy(policy_id: str) -> dict[str, str]:
try:
return store.fetch_policy(policy_id)
except TimeoutError:
# 可重試:dependency 暫時沒回應
raise HTTPException(
status_code=503,
detail={"error": "temporary_unavailable"},
headers={"Retry-After": "5"},
)
except ValidationError:
# 不可重試:client 傳的參數本身有問題
raise HTTPException(status_code=400, detail={"error": "invalid_policy_id"})
except PermissionError:
# 不可重試:需要換一個有權限的身份,而非重試同一個請求
raise HTTPException(status_code=403, detail={"error": "not_authorized"})
三個 except 區塊回傳的 status code 各不相同,這不是巧合,而是刻意把「client 該做什麼」直接編碼進回應本身:503 代表稍後重試可能有用,400 代表換個 policy_id 才有意義,403 代表該去申請權限而非重試。相較於一律回傳 500,這種分流寫法沒有增加多少程式碼量,卻讓每一個錯誤都攜帶了它自己該如何被處理的線索——這正是第 ①節 contract 裡「可辨識、可重試」這句話在程式碼層級具體的樣子。
前四個情境都還能指向一段具體的程式碼或一項設定,但 fault 不一定長這樣。有一種更難防範的型態,是程式碼在絕大多數輸入下完全正常,只有在極少數邊界輸入下才暴露問題——這種 fault 甚至可能通過完整的 code review,因為審查的人同樣沒有想到那個邊界情況。
Fault:某段驗證邏輯假設欄位一定有值,
沒有對 null/空值欄位做防禦
↓ 上游一次正常的維運操作
(例如放開資料庫的欄位可見性)意外讓 null 欄位第一次出現
Error:驗證邏輯對 null 值解參照(dereference),
服務進入無法恢復的錯誤狀態並重複崩潰
Failure:所有依賴這段驗證邏輯的下游服務
同時喪失正常回應能力
這是把 2025 年 6 月一起大型雲端事故的結構簡化後套進本文模型:一套新功能在沒有 feature flag 保護的情況下直接上線,其中一段判斷邏輯假設某些欄位一定存在;當它第一次遇到欄位為空值的資料列時,觸發了對空指標的解參照,讓核心的配額驗證元件進入重複崩潰的迴圈——而所有依賴這個元件做請求驗證的服務,統一收到驗證失敗,只能一律以 5xx 回應使用者。
這個情境值得放進「五個故事」的原因,是它示範了一件和情境 A-D 都不同的事:fault 有時候不是「寫錯的程式碼」,而是「一段程式碼裡藏著一個沒被說出口的假設」,這個假設在過去所有已知輸入下都成立,直到某次完全合法的維運操作(例如資料庫權限調整),第一次讓假設不成立的資料出現。這正是為什麼 feature flag 與漸進式發布(canary)如此重要——它們不能防止 fault 存在,但能把 error 觸發後的爆炸半徑,從「全球所有流量」縮小到「一小撮 canary 流量」。
這類「隱性假設」的 fault,用程式碼表達出來通常平淡無奇,平淡到很難在 review 時被抓出來:
def validate_quota_record(record: dict) -> bool:
# 隱性假設:record["limit"] 一定存在且是數字
# 過去所有已知資料都符合這個假設,直到某次資料庫
# 欄位可見性調整,第一次出現 limit 為 null 的紀錄
return record["usage"] <= record["limit"] # None 與整數比較會拋出 TypeError
修法本身通常不困難——難的是想到要修:
def validate_quota_record(record: dict) -> bool:
limit = record.get("limit")
if limit is None:
# 明確處理「沒有設定上限」這個之前從未出現過的情境,
# 而不是讓它以未預期的方式撞進比較邏輯
return True
return record["usage"] <= limit
這個修正本身只有幾行,真正困難的是在邊界情況第一次出現「之前」就想到要防禦它。canary 與漸進式發布的價值,不在於讓程式碼變得更正確,而在於當這類想不到的邊界情況終究第一次出現時,損害範圍被限制在一小撮流量裡,而不是直接波及所有使用者。
同一個 symptom 可以有多個 fault 假設。保留假設與反證,不要把第一個合理故事寫成既定根因。
把五個情境攤開放在同一張表,會比逐段閱讀更容易看出:同一個 HTTP 狀態碼,root cause 的性質可以差異到近乎沒有共通點——有的靠一行設定就能修,有的要花好幾週重新設計容量規劃。
| 情境 | Fault 的位置 | 觸發條件 | 修復難度 | 對應的常見誤判 |
|---|---|---|---|---|
| A:設定 | 環境變數或設定檔打錯值 | 部署當下立即出現 | 低:改回正確值、加設定值驗證 | 「重啟後好了,所以是 instance 問題」 |
| B:資源 | 連線池、執行緒池等容量設計過小 | 流量達到某個門檻才出現 | 中:需要重新估算容量或加自動擴縮 | 「timeout 加大就更可靠」 |
| C:放大 | 缺少 deadline,一個慢請求拖垮整批 | 上游偶發變慢時被放大成全面故障 | 中:需要導入 deadline propagation 與 bulkhead | 「retry 三次就好」 |
| D:邏輯 | exception handling 範圍過廣,吞掉不該吞的錯誤 | 特定例外類型出現時 | 低到中:收斂 except 範圍、補測試 | 「error log 歸零,所以使用者沒事」 |
| E:假設 | 程式碼裡藏著沒說出口的資料假設 | 首次出現不符假設的合法輸入 | 高:需要 code review 找出隱性假設、補防禦性檢查 | 「這段程式碼一直沒改過,不可能是它」 |
這張表格最右邊那一欄刻意跟(下)的誤判逐一對應,因為兩者背後是同一個心理機制:每一種誤判之所以聽起來合理,是因為它精準命中了對應情境裡「最表面的那個症狀」,卻停在症狀往前一步就停止追問。這些誤判不是憑空捏造的謊言,而是「觀察到真實現象、卻用錯了因果方向」的結果,這也是它們特別危險的原因。
| 層次 | 要問的問題 | 典型動作 | 不可直接推論 |
|---|---|---|---|
| Failure | 哪些使用者工作無法完成? | 暫停 rollout、降級、切流、公告 | 使用者沒報錯不等於沒有影響。 |
| Error | 哪一段 path 正在異常? | 比對 trace、dependency health、log、時間線 | stack trace 第一行不等於 root cause。 |
| Fault | 哪些條件讓 error 出現或擴大? | 修程式、改設定、補測試、修容量 | 一次修補不代表所有 contributing factor 消失。 |
Google SRE 提醒 correlation 不等於 causation。production 環境常無法安全重現完整條件,因此需要觀察、反證或受控實驗來縮小假設;近因修復不必等 root cause 完全確定,但事後仍要留下可追溯的因果與 follow-up。Google SRE:Effective Troubleshooting
這不代表 on-call 要先寫論文才能救火。順序很簡單:
先讓使用者少受傷。
同時保留證據。
再決定哪個假設值得修。
例如 rollback 最近 rollout 處理的是 fault 假設;circuit breaker 限制慢 dependency 對使用者的傷害,是在縮小 failure;dependency span 協助定位,則在縮小 error。三件事可以平行做,但不要混成一張沒有 owner 的「修一下 timeout」ticket。
把這個順序放進一個具體的時間軸,會比抽象的表格更容易記住。假設 /policy/remote-work 的 503 比例在 10:02 開始上升:
10:02 告警觸發:policy-api 5xx 比例超過 5%
10:03 值班工程師確認影響範圍:哪些 endpoint、哪個環境、成長趨勢
→ 這一步在縮小 Failure:先搞清楚「使用者受傷多大」
10:05 查看最近 30 分鐘的 deployment 與 config 變更紀錄,
發現 09:55 有一次 rollout
→ 這一步同時服務兩個目的:既是在建立 Fault 假設,
也是在決定要不要立刻 rollback 這個「已知可疑」的變更
10:06 決定:先 rollback,不等診斷完成
→ 這是縮小 Failure 的動作,不是修 root cause
10:08 5xx 比例開始下降,同時開始拉 dependency span
比對 rollback 前後的 pool wait time
→ 這是在縮小 Error:用證據排除或支持前面的假設
10:15 5xx 比例回到基線,事故從 active 轉為 monitoring,
開一張 postmortem ticket 記錄假設與待驗證項目
→ 到這裡都還沒有人說出「root cause 是什麼」,
這是誠實的狀態,不是調查沒做完
留意 10:06 的 rollback 決定:它發生在「完全確認 root cause」之前,這是刻意的。等到 100% 確定才動手,意味著使用者要多承受好幾分鐘不必要的傷害;rollback 一個時間點可疑的變更,即使最後證明它不是根因,代價也只是浪費一次部署動作。這正是「先減少 failure,再追 error,最後移除 fault」這個順序在實務上的樣子。
rollback 讓 5xx 比例回到基線,只代表 failure 被止住了,不代表 fault 已經被找到、更不代表它已經被移除。如果 09:55 那次 rollout 其實完全無辜,真正的 fault 是情境 B 提到的 pool 容量問題,那麼下一次流量高峰,同樣的 503 還會捲土重來。這就是為什麼(下)的 postmortem 模板要求區分「已觀察 Error」與「Fault 假設」,並把尚未確認的部分明確標成 UNKNOWN——與其讓一個假設悄悄變成心照不宣的「事實」,不如誠實地把它留在未完成的狀態。
如果團隊只有一個 on-call rotation,這個順序看起來像是同一個人依序做三件事。但多數規模稍大一點的團隊,Failure、Error、Fault 這三層實際上分屬不同角色,事故當下如果沒有先講清楚「現在在處理哪一層、誰負責」,很容易演變成三個人同時對著同一個 dashboard,卻做著互相打架的事。
Failure 層 —— 通常是值班 SRE / incident commander 的責任
決定要不要降級、要不要對外公告、要不要暫停某個 feature flag
目標:止血,不是修 bug
Error 層 —— 通常是被 page 到的服務 owner 的責任
拉 trace、比對 log、確認是哪一段 call path 出問題
目標:把「哪裡不對勁」縮小到可以指認的範圍
Fault 層:通常由原始變更的作者或架構負責人,在事故穩定後接手
改程式、調設定、補測試、重新評估容量規劃
目標:讓同一個 fault 不會再被同一種方式觸發
一個常見的協調失誤,是 incident commander 在事故還沒止血時就追問「所以是誰的 commit 把它搞壞的」——這個問題屬於 Fault 層,在 Failure 層還沒穩定之前問出來,只會讓正在處理 Error 層的工程師分心。反過來,也有團隊犯相反的錯:Failure 一穩定就解散事故頻道,卻沒有指定任何人去接手 Error 與 Fault 層的後續調查,於是那張「postmortem ticket」就停在標題那一行。這也是為什麼(下)的 postmortem 模板要求「下一步」欄位填入負責人與時間,而不是只留一句「待觀察」。
2025 年 9 月,Transloadit 公開說明 US-East Redis connection exhaustion incident:高流量 internal endpoint 的特定 error path 沒有可靠關閉 Redis connection;大量 request 觸發後,共享 Redis server 的連線上限被耗盡,合法 worker 與 API path 拿不到連線,queue 變慢,部分 job 停滯。團隊以調高連線限制建立診斷空間、hotpatch 關閉洩漏連線,以及回收受影響 process 來恢復。Transloadit:Us-east incident caused by Redis connection exhaustion
用今天的模型重述:
Fault:特定 error path 未可靠釋放 Redis connection
↓
Error:connection churn 上升、client limit 耗盡、worker/API 被拒絕連線
↓
Failure:queue latency 升高,部分 job 停滯,webhook、notification 與部分 API 互動延遲
這個案例有兩個關鍵。第一,latent fault 需要觸發條件;低流量時不一定顯現。第二,調高 limit 是 mitigation,不是永久修復。它讓系統喘一口氣,卻不會讓漏水的水管自己長好。
Transloadit 也提到 success-path metrics 原本健康,但 error-path connection accounting 不足。這正是「成功率看起來正常」與「不存在故障條件」不同的原因。判斷範圍需要成功分母、error-path 訊號與使用者影響。
官方 postmortem 公開的處置節奏,也值得對照第 ④ 節「先減少 Failure,再追 Error,最後移除 Fault」這個順序看:
偵測 監控觀察到 queue latency 異常上升,觸發調查
↓ 先做:縮小 Failure
臨時處置 調高 Redis 連線上限,給系統多一點喘息空間,
讓受影響的 job 有機會重新排入佇列
↓ 同時進行:縮小 Error
定位 比對哪個 endpoint、哪條 code path 的連線數持續攀升,
鎖定到那段沒有可靠釋放連線的 error path
↓ 最後:移除 Fault
永久修復 hotpatch 修正該 error path 的連線釋放邏輯,
並回收已經卡住、佔用連線的受影響 process
留意「調高連線上限」發生在「定位是哪段程式碼漏放連線」之前——這不是調查沒做完就先亂改設定,而是刻意的順序:調高上限的代價低,能立即讓卡住的 job 繼續往前跑;真正找出哪個 error path 有問題需要更多時間,不該讓使用者在這段時間內乾等。
把這個案例拆成程式碼層級的樣貌,大概是這種形狀——正常路徑有 finally 保證連線一定會被釋放,但某條特定的 error path 因為提早 return,繞過了釋放邏輯:
def handle_upload(request):
conn = redis_pool.get_connection()
try:
payload = parse_upload(request)
except ValidationError:
# 提早 return,跳過下方的 conn.release()
return error_response("invalid payload")
process(conn, payload)
conn.release()
return success_response()
這種寫法在多數 code review 裡完全合理:ValidationError 是常見分支,寫的人多半只想著「快速回一個 400」,沒意識到順手繞過了釋放邏輯。這也是「連線洩漏」這類 fault 特別頑固的原因——單元測試通常只驗證分支「有沒有回傳正確的錯誤訊息」,不會驗證「連線池裡的連線數量有沒有隨呼叫次數持續上升」。要抓到這種 fault,往往得靠長時間資源趨勢觀察,而不是功能測試,這也是(下)的 DIY 練習裡 try/finally 被特別強調的原因。
Transloadit 的 fault 至少還寫在程式碼裡,只是沒被看見。Clerk 在 2025 年 9 月的資料庫事件,fault 甚至不是一段可以指出行數的程式碼。
Clerk 用 Postgres 搭配 Cloud Run,容器內連線設定了固定 15 分鐘的生命週期。9 月 14 日,雲端供應商替 Clerk 的 Postgres 做了一次例行次版本自動升級。升級前,Postgres 授予新連線的鎖管理器帶有 O(n²) 時間複雜度——這是個效能缺陷,從來沒有人把它設計成 rate limiter,但它多年來意外把「大量連線同時湧入」這件事自然拖慢、分散開來,使原本容易同步過期的連線幾乎從未真的同時過期過。升級把這段授予邏輯最佳化後,連線授予幾乎瞬間完成,這個沒有寫進任何設計文件的隱性保護跟著消失。Clerk:Postmortem: Database Incident (September 14–18, 2025)
Fault:固定 15 分鐘連線生命週期,隱性依賴一個從未被設計成節流機制的資料庫效能缺陷
↓ 該效能缺陷因升級被移除
Error:所有容器的連線逐漸同步過期(bus bunching),形成週期性連線風暴
↓
Failure:前端 API 請求失敗與延遲飆升,新登入/註冊間歇性失敗
bus bunching(公車擠成一團)是交通工程借來的比喻:一班公車稍微 delay,後面等車的乘客變多、上車時間變長,delay 被進一步放大,最後幾班公車擠在一起同時抵達,中間卻出現一段空窗。放進 Clerk 的場景,公車換成資料庫連線:連線建立的時間點原本因為授予邏輯的 O(n²) 缺陷被自然打散,到期時間也就自然錯開;升級移除這個效能缺陷後,連線授予變快,容器建立連線的時間點開始互相靠攏,十五分鐘後幾乎在同一刻集體到期、集體重連——這就是「連線風暴」規律地每 15 分鐘出現一次的原因:
升級前:連線建立時間被授予延遲自然打散
容器 A ──①────②────③──
容器 B ────①────②────③
容器 C ──────①────②────③
(到期時間也跟著錯開,資料庫任何一刻的重連量都很平均)
升級後:連線授予幾乎瞬間完成,建立時間互相靠攏
容器 A ─①──②──③
容器 B ─①──②──③
容器 C ─①──②──③
(15 分鐘後全部同時到期,資料庫瞬間收到集體重連的尖峰)
這個事件另一個值得注意的地方,是團隊一度以為已經解決:9 月 17 日凌晨部署 query 優化後,負載尖峰完全消失,看起來問題已排除;一小時後尖峰重現,而且變成規律的「每 15 分鐘一次」。真正根因——固定連線生命週期造成的同步過期——直到隔天才被確認,中間團隊還手動升級並擴容資料庫做為過渡處置。這正好對照(下)會談到的一個誤判:「重啟後好了」不是 fault 被移除的證據,只是觸發條件暫時改變了。
這個案例還有一個細節值得放大看:那個被拿掉的 O(n²) 鎖管理器邏輯,從未被設計成節流機制,卻長年意外掩蓋了「固定 15 分鐘連線生命週期」這個真正的 fault。這提醒我們:fault 不一定孤立存在,一個系統的穩定有時依賴另一個沒人知道自己在扮演這個角色的缺陷——任何「升級」或「優化」都該假設它可能改變系統的時序行為,而不是只驗證功能是否正確。
前兩個案例都發生在 2025 年、都圍繞著雲端資料庫,容易讓人以為 Fault-Error-Failure 這套模型是「AI 時代基礎設施」才特別需要的新東西。事實正好相反——這套因果鏈能解釋的事故,遠比雲端運算的歷史更久。2012 年 8 月 1 日,美國做市商 Knight Capital 在美股開盤前部署一套新的交易策略程式,45 分鐘內對紐約證交所送出大量錯誤方向的交易單,最終虧損約 4.4 億美元(約新台幣 130 億元),公司差點因此倒閉。美國證券交易委員會(SEC)的事後調查報告把根因追到一段沉睡多年的舊程式碼。SEC:In the Matter of Knight Capital Americas LLC
用今天的模型重述:
Fault:舊策略程式裡一個叫做 REPRICE_FLAG 的旗標,
多年前就已經停用,程式碼卻從未被移除;
新版部署把這個旗標重新賦予了不同的用途,
卻沒有人確認舊的旗標邏輯已經完全清乾淨
↓ 部署新程式到八台伺服器,其中一台漏掉更新
Error:那台伺服器上,舊的(死掉的)程式邏輯
重新被觸發啟動,開始用早已過時的規則
解讀市場訊號
Failure:系統以極高頻率送出方向錯誤的巨量交易單,
45 分鐘內造成公司近乎致命的財務損失
這個案例的關鍵不在於「舊程式碼」本身有多恐怖,而在於它精準示範了 fault 潛伏的性質:那段程式碼已沉睡近八年,直到一次把舊旗標重新賦予新意義的變更,加上一台伺服器沒跟上更新,兩個條件同時成立的那一刻,fault 才被觸發成 error。系統當下缺乏任何能在幾秒內偵測「交易行為明顯異常」並自動熔斷的機制,讓這個 error 有整整 45 分鐘毫無阻攔地演變成災難級的 failure。SEC 的調查報告顯示,最先注意到異常價格波動的是外部交易所與市場參與者反向詢問 Knight Capital,而不是內部監控——系統顯然記錄了每一筆成交,理論上足以支撐一條「單一帳戶短時間內異常交易量」告警,但這樣的規則當時並不存在,於是大量本可被看見的 error,安靜持續 45 分鐘,才演變成公司幾乎倒閉的 failure。
拿這三個案例並排看,會發現同一套因果鏈可以套進完全不同的時代、完全不同的產業:
| 案例 | Fault 的型態 | 觸發條件 | Failure 的規模 |
|---|---|---|---|
| Transloadit(2025) | 錯誤路徑未釋放連線 | 高流量端點被大量呼叫 | 佇列延遲、部分 job 停滯 |
| Clerk(2025) | 固定連線生命週期依賴一個未被設計的效能缺陷 | 資料庫例行升級移除該缺陷 | 週期性連線風暴、登入失敗 |
| Knight Capital(2012) | 死程式碼未清除、被重新賦予新用途 | 部署遺漏一台伺服器 | 45 分鐘內 4.4 億美元虧損 |
三個案例沒有一個是「有人故意寫錯」,全部是合理、甚至立意良善的決策(重構、升級、部署新策略)在某個沒被想到的邊界條件下,讓潛伏已久的 fault 第一次被觸發。這正是本文開頭想傳達的立場:把事故簡化成一句「誰的程式碼寫錯了」,幾乎每次都會錯過真正值得記取的教訓。
除了「fault 的型態」與「觸發條件」不同,這三個案例還有一條更值得注意的共同軸線:從 fault 被觸發成 error,到有人(或系統)察覺並開始止血,中間經過的時間長短,幾乎直接決定了 failure 最終的規模。Transloadit 靠 dashboard 主動發現並介入;Clerk 第一次被誤判為已經解決,多花一天才鎖定真正根因;Knight Capital 最致命的地方,是系統完全沒有設計自動熔斷機制——45 分鐘裡異常交易行為肉眼可辨,卻沒有任何自動化機制把訊號轉換成「立刻停止」的動作。
這條軸線呼應(下)即將談到的 telemetry 設計:偵測時間的長短不是取決於工程師反應快不快,而是取決於系統有沒有在事故發生「之前」就把正確的訊號與自動化反應準備好。Knight Capital 的悲劇,某種意義上是 observability 與自動化熔斷雙雙缺席的問題——一套能自動偵測異常並暫停的機制,也可能把 45 分鐘的 failure 壓縮到幾秒鐘。
(下)會把這條因果鏈接上 telemetry:Metrics、Logs、Traces 各自能回答哪一段問題;接著提供一份可動手做的 FastAPI fake-timeout DIY 練習,讓因果鏈從文字變成可以跑起來驗證的程式碼;最後整理幾個容易讓事故重演的誤判,並給一份可檢查的 postmortem 模板。
這篇是 Learning SRE for the AI Era 系列的一部分。
Build → Trace → Break → Measure → Evaluate → Recover → Improve.