剛開始寫程式時,我們都活在一個美好的世界裡:網路永遠暢通,API 總是秒回,伺服器從不宕機。
這種「天真的信任」,就是程式碼裡最重的一股臭味。
看看這段程式碼,是不是很熟悉?
// 🔴 臭味:過於信任的網路呼叫
async function getUserProfile(userId) {
// 天真地相信,這行程式碼永遠會成功
const response = await api.fetch(`/users/${userId}`);
return response.data;
}
在語法上,它完全沒有錯。它看起來很乾淨、很直接,也確實能完成「取得使用者資料」這個任務。
問題不在於程式碼「寫錯了」,而在於它 沒寫的部分。
根植於一個隱藏的、天真的假設:「await api.fetch(...)
這一行永遠會順利地在短時間內回傳我們想要的資料。」
在實際情況裡,背後可能藏著無數個會讓你的應用崩潰的陷阱:
如果對方服務很慢怎麼辦? 你的程式會卡在這裡,一直等到天荒地老,佔用著資源,讓後面的使用者也跟著一起等。
如果對方服務剛好掛了怎麼辦? 你的程式會直接崩潰,使用者會看到一個醜陋的錯誤頁面。
如果只是網路抖了一下,失敗了千分之一秒呢? 你的程式還是會失敗,但其實只要再試一次就好了。
專業的工程師,會預設所有外部依賴都會「背叛」他。
在寫任何 try/catch
或重試邏輯之前,先區分錯誤:「這是哪種失敗?」
失敗只有兩種,必須像對待不同物種一樣對待它們:
永久性錯誤 (Permanent Errors):
定義: 重試一萬次也沒用的錯誤。
例子: HTTP 404 Not Found
, 403 Forbidden
, 401 Unauthorized
。你用錯誤的 ID 去請求一個不存在的用戶,再試幾次它也不會 magically appear。
處理方式: 立即失敗。 記錄日誌,拋出例外,讓呼叫方知道「這件事沒戲了」。不要重試,那是在浪費資源。
暫時性錯誤 (Transient Errors):
定義: 現在不行,但等一下可能就好了。
例子: HTTP 503 Service Unavailable
, 500 Internal Server Error
, 504 Gateway Timeout
, 網路連線逾時。服務可能正在重啟,網路可能只是抖動了一下。
處理方式: 只有這種情況,才值得重試。
臭味:無止盡的等待。
動作:為每一次外部呼叫設定一個合理的等待時間,比如 2-3 秒。時間一到,我們就直接「掛電話」,防止整個應用程式被拖垮。
臭味:因為暫時的小問題就徹底放棄。
動作:只對「暫時性」錯誤(如網路不穩)進行有限次數的、有禮貌的(等待間隔逐漸拉長)重試。
指數退避 (Exponential Backoff): 不要每隔 1 秒就重試一次。第一次失敗後等 1 秒,第二次等 2 秒,第三次等 4 秒... 這種逐漸拉長間隔的方式,能給對方服務喘息的空間,避免在它最脆弱的時候壓垮它。
抖動 (Jitter): 如果有個情況是,你的 100 個服務實例都因為同一個問題失敗了,然後它們都按照完全相同的指數退避演算法,在同一毫秒甦醒,同時發起重試。這會形成可怕的「重試風暴 (Thundering Herd)」。為了解決這個問題,我們需要在等待時間上加入一個微小的隨機值,也就是「抖動」。這能將重試請求在時間上打散,避免形成流量洪峰。
臭味:把醜陋的系統錯誤直接丟給使用者。
動作:當確定外部服務不行了,提供預設資料、使用舊快取,或明確告知使用者部分功能暫時無法使用,優雅降級 (Graceful Degradation)。
回傳快取資料: 如果之前有快取過這位用戶的資料,即使是幾分鐘前的舊資料,也比顯示錯誤頁面好。
提供預設值: 前端請求使用者頭像失敗時,Fallback 可以是顯示一個預設的灰色頭像,而不是讓圖片區域出現一個破圖示。
簡化版回應: 在電商網站,如果個人化推薦服務掛了,Fallback 可以是回傳一個固定的、所有人都一樣的「熱銷商品列表」,確保頁面功能依然完整。
臭味:明知山有虎,偏向虎山行。
動作:當偵測到某個服務在短時間內大量失敗,就暫時「跳閘」,讓後續的呼叫立即失敗(直接走 Fallback 流程),不再浪費資源去嘗試,給自己和對方喘息的空間。
Closed
(閉合): 正常狀態,所有請求都直接通過。
Open
(斷開): 當失敗率超過閾值,保險絲「跳閘」,在接下來的一段時間內,所有請求都會被立即拒絕,直接執行備案 (Fallback)。
Half-Open
(半開): 斷開狀態持續一段時間後,保險絲會進入此狀態,放行一筆「探測」請求。如果成功,代表服務已恢復,狀態切回 Closed
;如果失敗,則重新回到 Open
狀態繼續等待。
這個機制把所有可能的失敗都當成了正常流程的一部分來處理。
看到最上面那張複雜的圖,你可能會想:「天啊,我要自己寫這麼多 if/else
嗎?」
答案是:絕對不要。
別急著自己去實現那個重試迴圈和指數退避,輪子已經有了,而且比你造的更好。
使用成熟的函式庫來執行這些策略。
我們的責任是理解這些策略,並為函式庫提供正確的設定。
// // 🟢 好味道:真正的專業手法,是「聲明」你的策略,讓函式庫去執行那張複雜的流程圖
const professionalPolicy = createPolicy([
Timeout.of(1500),
Retry.of({ limit: 3, backoff: 'exponential' }),
CircuitBreaker.of({ failureRate: 0.5 }),
Fallback.of(getProfileFromFallback)
]);
// 業務程式碼保持極度乾淨,只關心它該關心的事
async function getProfessionalProfile(api, id) {
return await professionalPolicy.execute(() => api.fetch(`/users/${id}`));
}
第一步永遠是區分錯誤類型。 在寫任何 catch
之前,先問自己:這個錯誤是 403
還是 503
?如果是前者,直接失敗,記錄日誌。
用函式庫,別自己發明輪子。 這些模式的狀態管理很容易出錯。找一個小而美、經過檢驗的函式庫來做這件事。
從簡單的開始。 不是所有呼叫都需要一套完整的 Circuit Breaker。先從 Timeout 和針對「暫時性錯誤」的有限次數重試(帶 Exponential Backoff)開始。只有當這個依賴的故障會引發連鎖反應(雪崩)時,才需要引入 Circuit Breaker。
Fallback 的回傳值必須是「合法」的。 它回傳的資料結構,必須和成功時的回傳一模一樣,即使是空的。回傳一個空的使用者物件,也比回傳一個 null
導致呼叫方 TypeError
要好一萬倍。
消除程式碼的臭味,不只是為了好看,更是為了責任感。
菜鳥的程式碼,在風和日麗時能跑。
專業的程式碼,在狂風暴雨中也能存活。
把每一次外部呼叫都當成一個潛在的叛徒,用這四個步驟為你的程式碼建立防禦。
Timeout、Retry、Fallback、Circuit Breaker。