跳到主要內容

系統設計

把資料結構放大到好幾台機器

主題 · 容錯模式:逾時、重試、斷路器

容錯模式:逾時、重試、斷路器

下游一變慢,沒設逾時會讓執行緒全部卡住,盲目重試則會把它徹底壓垮:逾時、指數退避加隨機、斷路器、艙壁隔離,讓一個元件出事不會拖垮整個系統。

做法
100
×20
0%
整體成功率
75.7%
B(正常的依賴)
76.5%
p99 回應時間
5.2 s
每個 A 請求打到 A 幾次
1.00
A 佇列最長等待
0 ms
直接拒絕/快速失敗
0 / 0
事故:A 變慢第 0 秒:成功 103、失敗 0、平均 4.4 條執行緒在忙第 1 秒:成功 98、失敗 0、平均 3.5 條執行緒在忙第 2 秒:成功 68、失敗 0、平均 2.7 條執行緒在忙第 3 秒:成功 79、失敗 0、平均 3.6 條執行緒在忙第 4 秒:成功 123、失敗 0、平均 5.2 條執行緒在忙第 5 秒:成功 123、失敗 0、平均 4.5 條執行緒在忙第 6 秒:成功 106、失敗 0、平均 4.2 條執行緒在忙第 7 秒:成功 109、失敗 0、平均 4.0 條執行緒在忙第 8 秒:成功 97、失敗 0、平均 3.9 條執行緒在忙第 9 秒:成功 98、失敗 0、平均 3.6 條執行緒在忙第 10 秒:成功 48、失敗 0、平均 12.7 條執行緒在忙第 11 秒:成功 58、失敗 0、平均 20.0 條執行緒在忙第 12 秒:成功 43、失敗 0、平均 20.0 條執行緒在忙第 13 秒:成功 41、失敗 8、平均 20.0 條執行緒在忙第 14 秒:成功 3、失敗 46、平均 20.0 條執行緒在忙第 15 秒:成功 0、失敗 32、平均 20.0 條執行緒在忙第 16 秒:成功 0、失敗 51、平均 20.0 條執行緒在忙第 17 秒:成功 0、失敗 50、平均 20.0 條執行緒在忙第 18 秒:成功 0、失敗 53、平均 20.0 條執行緒在忙第 19 秒:成功 0、失敗 56、平均 20.0 條執行緒在忙第 20 秒:成功 0、失敗 305、平均 20.0 條執行緒在忙第 21 秒:成功 293、失敗 125、平均 17.2 條執行緒在忙第 22 秒:成功 100、失敗 0、平均 3.8 條執行緒在忙第 23 秒:成功 97、失敗 0、平均 4.5 條執行緒在忙第 24 秒:成功 80、失敗 0、平均 3.2 條執行緒在忙第 25 秒:成功 91、失敗 0、平均 3.9 條執行緒在忙第 26 秒:成功 113、失敗 0、平均 4.4 條執行緒在忙第 27 秒:成功 83、失敗 0、平均 3.5 條執行緒在忙第 28 秒:成功 90、失敗 0、平均 3.9 條執行緒在忙第 29 秒:成功 110、失敗 0、平均 4.5 條執行緒在忙第 30 秒:成功 5、失敗 0、平均 2.0 條執行緒在忙418/s忙碌0s10s20s30s
及時成功✕ 失敗或太慢忙碌的執行緒(共 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 成功p99A 呼叫倍數最多忙碌執行緒
什麼都沒有75.7%76.5%5.2 s1.0020 / 20
逾時84.1%96.9%2.4 s1.0020 / 20
逾時+立刻重試67.6%69.6%8.3 s1.2120 / 20
逾時+退避重試68.2%70.1%8.4 s1.1820 / 20
+斷路器81.7%100.0%575 ms0.6620 / 20
逾時+有界佇列80.5%94.0%1.0 s0.9320 / 20
逾時+艙壁隔離82.9%100.0%500 ms0.7214 / 20
全部一起79.9%100.0%60 ms0.6112 / 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)。