容錯模式:逾時、重試、斷路器
下游一變慢,沒設逾時會讓執行緒全部卡住,盲目重試則會把它徹底壓垮:逾時、指數退避加隨機、斷路器、艙壁隔離,讓一個元件出事不會拖垮整個系統。
做法
100
×20
0%
整體成功率
75.7%
B(正常的依賴)
76.5%
p99 回應時間
5.2 s
每個 A 請求打到 A 幾次
1.00
A 佇列最長等待
0 ms
直接拒絕/快速失敗
0 / 0
及時成功✕ 失敗或太慢忙碌的執行緒(共 20 條)
A 一變慢,每次呼叫都在等,等著 A 的執行緒別人就不能用。很快 20 條全卡住,連完全正常的 B 的請求也排在後面:B 只有 76.5% 成功。這就是連鎖故障。
假設:20 條執行緒;一半請求呼叫 A、一半呼叫 B(平常約 40 ms 和 30 ms);A 有 20 個 worker 和一個先進先出的佇列,在第 10 到 20 秒之間每次呼叫慢 20 倍。超過 2.0 s 才回應算失敗——使用者已經走了。逾時 500 ms;最多重試 3 次;退避從 100 ms 起跳、加完全隨機(full jitter);斷路器在最近 20 次呼叫(至少 10 次)失敗達 50% 時打開;p99 是 99% 請求都比它快的回應時間(第 99 百分位)。
亮起來的是這一步執行的程式碼
type CallResult = { ok: boolean; ms: number }; // Full jitter: wait a random time up to an exponentially growing cap.function backoffDelay(attempt: number, baseMs: number, capMs: number, random: () => number): number { return random() * Math.min(capMs, baseMs * 2 ** attempt);} function callWithRetry(call: () => CallResult, retries: number, timeoutMs: number, baseMs: number, capMs: number, random: () => number, sleep: (ms: number) => void): boolean { for (let attempt = 0; attempt <= retries; attempt++) { const result = call(); if (result.ok && result.ms <= timeoutMs) return true; if (attempt < retries) sleep(backoffDelay(attempt, baseMs, capMs, random)); } return false;} class CircuitBreaker { state: "closed" | "open" | "half-open" = "closed"; private outcomes: boolean[] = []; private openedAt = 0; private probing = false; constructor(private window = 20, private minCalls = 10, private rate = 0.5, private cooldownMs = 2000) {} allow(now: number): boolean { if (this.state === "open" && now - this.openedAt >= this.cooldownMs) { this.state = "half-open"; this.probing = false; } if (this.state === "closed") return true; if (this.state === "half-open" && !this.probing) { this.probing = true; return true; } return false; } record(ok: boolean, now: number): void { if (this.state === "half-open") { this.probing = false; if (ok) { this.state = "closed"; this.outcomes = []; } else { this.state = "open"; this.openedAt = now; } return; } if (this.state !== "closed") return; this.outcomes.push(ok); if (this.outcomes.length > this.window) this.outcomes.shift(); const failures = this.outcomes.filter((o) => !o).length; if (this.outcomes.length >= this.minCalls && failures / this.outcomes.length >= this.rate) { this.state = "open"; this.openedAt = now; this.outcomes = []; } }} // Separate pools: one slow dependency can hold at most `limit` threads.class Bulkhead { private inUse = 0; constructor(private limit: number) {} tryAcquire(): boolean { if (this.inUse >= this.limit) return false; this.inUse++; return true; } release(): void { this.inUse--; }} // A bounded queue: refuse new work now rather than serve it too late.function admit(queueLength: number, limit: number): boolean { return queueLength < limit;}模型假設與範圍
- 這是可重現的教學模型;延遲、容量、故障率與工作負載是設定或樣本,不能直接當作正式系統的效能承諾。
- 有限執行緒、兩個相依服務與種子化事故;timeout 不代表遠端工作已被取消,retry 仍須配合冪等與資源隔離。
什麼時候用
- 每一個跨網路的呼叫都要有逾時:沒有逾時的呼叫,遲早會在下游出事時把呼叫方一起拖垮。
- 重試只用在暫時性的錯誤(逾時、503),而且要加退避和隨機;操作要冪等,否則重試會重複執行。
- 依賴可能整個掛掉時用斷路器;同一個服務呼叫好幾個依賴時用艙壁隔離,讓一個慢的不會吃光所有執行緒。
和其他主題的關係
時間與空間複雜度(Big O)
| 操作 | 平均 | 最差 |
|---|---|---|
| 逾時、艙壁、佇列檢查 每個請求一次比較或計數 | O(1) | O(1) |
| 斷路器記錄一次結果 W 是視窗大小;用環狀計數器可降到 O(1) | O(W) | O(W) |
| 重試 r 次 下游最多收到 r + 1 倍的呼叫 | O(r) | O(r) |
空間:O(W + L),斷路器的視窗 W,加上佇列上限 L
和其他做法比
| 整體成功 | B 成功 | p99 | A 呼叫倍數 | 最多忙碌執行緒 | |
|---|---|---|---|---|---|
| 什麼都沒有 | 75.7% | 76.5% | 5.2 s | 1.00 | 20 / 20 |
| 逾時 | 84.1% | 96.9% | 2.4 s | 1.00 | 20 / 20 |
| 逾時+立刻重試 | 67.6% | 69.6% | 8.3 s | 1.21 | 20 / 20 |
| 逾時+退避重試 | 68.2% | 70.1% | 8.4 s | 1.18 | 20 / 20 |
| +斷路器 | 81.7% | 100.0% | 575 ms | 0.66 | 20 / 20 |
| 逾時+有界佇列 | 80.5% | 94.0% | 1.0 s | 0.93 | 20 / 20 |
| 逾時+艙壁隔離 | 82.9% | 100.0% | 500 ms | 0.72 | 14 / 20 |
| 全部一起 | 79.9% | 100.0% | 60 ms | 0.61 | 12 / 20 |
同一串每秒 100 個請求、A 在事故期間慢 20 倍。什麼都不做時,連正常的 B 也只剩 76.5%;只加重試反而更糟(B 69.6%、p99 8.3 s)。讓 B 不受影響的是不讓 A 佔住所有執行緒的做法:斷路器、艙壁隔離。全部一起時 p99 只有 60 ms。
真實世界裡的它
- Netflix 的 Hystrix(現在多用 Resilience4j)把斷路器和艙壁隔離普及成標準做法。
- AWS(Amazon Web Services)的 SDK(Software Development Kit,軟體開發套件)預設用指數退避加隨機重試;Envoy、Istio 等服務網格把逾時、重試、斷路器做成設定。
- Google 的 SRE(Site Reliability Engineering)書裡的 load shedding:過載時寧可拒絕一部分請求,也不要讓全部變慢。
取捨與陷阱
- 每一層都重試 3 次:五層的呼叫鏈最底層會收到 3⁵ = 243 倍的流量。重試應該只在一層做。
- 逾時設得比下游正常的 p99 還短,平常就會不斷失敗;設得太長又等於沒設。
- 沒有隨機的退避會讓所有用戶端在同一時刻一起重試,形成一波一波的尖峰(thundering herd)。