問題
Redis 這類共用依賴掛掉時,「照常放行」還是「拒絕服務」不是全域一個答案——同一個 client、同一個 process 裡,不同呼叫端的正確方向相反。
把方向決定放錯層,會出現兩種方向相反的事故:
- 登入直接 500:本來該 fail-open 的元件,被共用層丟上來的例外打斷。
- 黑名單失效照樣放行:本來該 fail-closed 的檢查,被共用層把例外吞掉。
這兩件事在快取篇和 Gateway 篇各講過一半(連結在文末)。這篇把判準完整攤開,再補上兩篇都沒細講的部分:怎麼確認你的 fail-open 真的是對的。
判準
只問一句:這個檢查擋的是「量」,還是「偽造/撤銷」?
| 擋什麼 | 方向 | 例 |
|---|---|---|
| 量(濫用速率) | fail-open | rate limit、IP 黑白名單、登入爆破計數 |
| 效能(可重算) | fail-open | 快取讀取、權限快取(退回 DB,DB 才是事實來源) |
| 撤銷(已收回的授權) | fail-closed | token 黑名單(放行=已登出的 token 復活) |
| 偽造(去重/驗章狀態) | fail-closed | webhook replay 去重、冪等鍵(去重表失效=防護消失) |
| 資料完整性 | fail-closed | queue 入列(丟了就是掉資料,回 503 讓 client 重試) |
擋量的放行,代價是那段時間限制放寬;擋效能的放行,代價是慢一點、多打幾次 DB。這兩種掛了都只是「少一層保護或少一點速度」。擋撤銷、偽造、完整性的放行,代價是那道防線在故障期間等於不存在——這不是降級,是失守。
三個工程要點
一、方向決定在呼叫端,不在共用抽象。
CacheService.HasAsync 這種方法,會同時被 fail-open 的 IP 名單和 fail-closed 的 token 黑名單呼叫。在 cache 層一律 fail-open,就等於把黑名單拆了;一律 fail-closed,則是一個快取讀取失敗就把登入打成 500。共用層只負責兩件事:把錯誤丟出來,或在已知斷線時提供快速路徑。要放行還是要拒絕,留給呼叫端。
二、有界等待。
fail-open 的意義是「掛了也不慢」。如果每次都要等 driver 預設逾時才決定放行,那只是「慢慢地放行」。兩個做法:
- driver 要設 connect/sync timeout,不用預設值。
- 已知斷線時走行程內的快速路徑(例如先看
IsConnected),根本不發指令。
少了這兩個,每一層各吃一次 driver 預設逾時,一個請求串四層就是十幾秒。
三、fail-open 的測試斷言要雙向。
測試必須同時斷言 allowed=true 且 denied=false。一句話記:繞過的是快取,不是授權。
只斷言 200 的話,「故障時一律放行」這種錯誤實作也會綠——權限快取掛了,結果連本來沒權限的人都被放進來,測試照樣過。
量測陷阱:停掉 Redis 也量不到的那一格
驗 fail-open 最直覺的做法,是「停 Redis,打真實端點」。對認證之後才會走到的 fail-open 元件,這個做法量不到東西。
原因是順序。在一套把 token 黑名單做成 fail-closed 的系統裡,黑名單掛在認證層的最前面。Redis 一停,所有已認證的請求在第一關就回 503,根本到不了下游的權限快取。不管權限快取寫得對不對,量到的永遠是黑名單的方向。
替代做法是選擇性故障注入:產品碼不動、HTTP 路徑不動,只把 cache 依賴換成一個替身——特定前綴的鍵拋連線例外,其他鍵照常運作。例如只讓權限快取的鍵失敗,黑名單的鍵正常。這樣故障就能精準落在要量的那一格,再配合上面的雙向斷言:認證後的端點仍然 200,而且原本該被拒絕的請求仍然被拒絕。
未認證的路徑(login、公開列表)不受這個限制,前面沒有 fail-closed 的關卡,停真 Redis 的實測仍然有效。
相關
- 設計 CacheService 抽象層:「例外:同一層被拿去存『狀態』的時候」那段是這個判準在快取層的版本,也說明了為什麼共用層只丟錯誤、不做決定。
- 把 JWT 驗證 offload 到 API Gateway:故障方向那幾段是撤銷那一列的實例,包括「目前是 fail-open、什麼時候該改」的取捨。
- 驗收假綠形態目錄:單向斷言、量錯對象這兩種假綠,就是第三個工程要點與量測陷阱的通用版本。