可靠交付 (Reliable Delivery)
在分散式系統裡,故障不是例外,而是常態 —— 伺服器會崩、網路會丟包、依賴會抖動。真正的問題不是「如何防止故障」,而是「故障發生時系統如何正確地應對」。
可靠交付是一套由細到粗、層層疊加的防線:超時 → 重試 → 冪等性 → 退避加抖動 → 故障切換 → 降級回應。它們不是孤立技術,而是相互依存 —— 每一個都建立在前一個的基礎上。面試的高分不是逐一背名詞,而是在畫架構圖時,自然地對每一條線說出可靠性策略。
為什麼故障是必然的
你的服務正常運行時,它的每一個外部依賴(DB、快取、訊息佇列、下游服務)都有各自獨立的故障機率。把這些機率乘起來,再乘上每天幾百萬個請求,故障在統計上就是必然發生。
P(全鏈路正常) = P(DB ok) × P(cache ok) × P(queue ok) × P(下游 ok) × ...
每個 0.999,串 5 個 → 0.995;× 百萬請求/天 → 每天數千次失敗
所以可靠性設計的目標不是「零故障」,而是讓系統在故障發生時優雅降級、自動恢復、不放大災情。
六道防線總覽
| # | 防線 | 解決的問題 | 核心代價 / 前提 |
|---|---|---|---|
| 1 | 超時 Timeout | 卡住的依賴拖垮自己(級聯故障) | 設太短誤殺、設太長失去意義 |
| 2 | 重試 Retry | 暫時性故障(網路抖動、瞬間過載) | 只能重試暫時性錯誤 + 需冪等 |
| 3 | 冪等性 Idempotency | 重試導致重複副作用(重複下單) | 需冪等鍵 / 去重表 |
| 4 | 退避加抖動 Backoff+Jitter | 集體重試壓垮服務(驚群效應) | 增加單次恢復延遲 |
| 5 | 故障切換 Failover | 節點 / DB 永久掛掉 | 切換視窗 downtime、可能丟資料 |
| 6 | 降級回應 Fallback | 能力暫時不可用時還要能服務 | 回傳舊資料 / 預設值(非最新) |
重試 vs 退避:重試是「再試一次」,退避是「再試之前先等一下且每次等更久」。
故障切換 vs 降級:Failover 是「找一個健康的同類來替代」;Fallback 是「同類也沒了,用較簡陋但能用的替代方案撐過去」。
防線 1:超時 (Timeout)
最基本、也最常被遺忘的防線。 對外發請求若不設超時,等於假設對方一定會在合理時間內回應。對方卡住 → 你的連線懸著不釋放 → 大量發生 → 執行緒池 / 連線池耗盡 → 你自己也停止回應。一個依賴的故障透過資源耗盡把你一起拉下去 —— 這就是級聯故障 (cascading failure) 的標準路徑。
無超時:
你 ──請求──> 卡住的依賴(不回應)
thread 1 懸著 … thread 2 懸著 … thread N 懸著
→ thread pool 耗盡 → 你也停止回應 → 上游再被你拖垮(級聯)
有超時:
你 ──請求──> 依賴(卡住)
[等 3s] → 主動放棄 → 釋放執行緒 → 走降級邏輯
超時的四種類型
| 類型 | 含義 | 典型值 | 設定依據 |
|---|---|---|---|
| 連線超時 Connection | TCP 握手最長等待 | 幾百 ms ~ 幾秒 | 建連本身不該久,設短 |
| 讀取超時 Read | 連線後等對方回資料的最長時間 | 依下游 SLA 而定 | 最需調校,依 P99 延遲 |
| 寫入超時 Write | 把資料送出去的最長時間 | 幾秒 | 網路差 / 對方收得慢時觸發 |
| 整體請求超時 Overall | 端到端預算(含重試) | 對用戶 SLA 內 | 確保多次重試也不爆預算 |
怎麼設超時值(決策框架)
- 參考依賴方 SLA:下游 P99 是 200ms,設 2 倍緩衝 ≈ 400ms~2s 合理。設 100ms → 太多誤殺 (false positive);設 30s → 超時形同虛設。
- 考慮整體鏈路預算:若你對用戶承諾 200ms,給下游的超時不能超過這數字,還要留自己的處理時間。
「我會對這個外部支付 API 設 3 秒讀取超時。正常交易應在 1 秒內完成,3 秒提供足夠緩衝,同時確保一個卡住的依賴不會讓我們的執行緒被無限期佔用。超時值要在真實流量下持續監控調整,不是設一次就不管。」
防線 2:重試 (Retry)
超時或失敗後第一直覺是「再試一次」。重試是處理暫時性故障 (transient failure) 最直接的手段:網路丟包、瞬間高負載、依賴 GC 暫停 —— 這些通常短暫,稍等再試往往就成功。但用錯反而讓情況更糟。
該重試 vs 不該重試
| ✅ 該重試(暫時性) | ❌ 不該重試 |
|---|---|
網路暫時錯誤:連線/讀取超時、ECONNRESET |
4xx 客戶端錯誤(400/401/403/404):請求本身有問題 |
| 5xx 伺服器錯誤(503/502,有條件):通常暫時過載 | 業務邏輯錯誤:「庫存不足」「餘額不夠」是正確回應,非故障 |
| 資料庫連線失敗:連線池暫時耗盡 | 429 Too Many Requests:要退避而非狂打(可重試但須長退避) |
| — | 非冪等操作(未做冪等保護前):重試建單會建兩筆 |
無限重試 + 所有客戶端都在重試 = 把已過載的系統推向更深淵。通常 3 ~ 5 次 是合理上限,超過就讓請求失敗,交給上層的降級邏輯接管。
重試必須搭配退避 —— 不加退避的重試,等於在一個已過載的服務上繼續倒垃圾。
def call_with_retry(fn, max_retries=3, retryable_status=(503, 502, 429)):
for attempt in range(max_retries + 1):
try:
response = fn()
if response.status_code in retryable_status and attempt < max_retries:
continue # 觸發重試(實務上這裡要 sleep 退避)
return response
except (ConnectionError, TimeoutError):
if attempt == max_retries:
raise # 已達上限,讓錯誤往上傳
防線 3:冪等性 (Idempotency)
談退避之前,先解決重試帶來的最核心問題:重試一個操作,會不會重複做了同一件事?
冪等性:同一個操作執行多次,和只執行一次,產生完全相同的結果。
讀取 (GET) 天然冪等;問題出在寫入。
HTTP 方法的冪等性
| 方法 | 冪等? | 原因 |
|---|---|---|
| GET | ✅ 是 | 讀取不改變狀態 |
| PUT(完整替換) | ✅ 是 | 用相同資料 PUT 兩次,第二次不改變任何事,狀態已是目標值 |
| DELETE | ✅ 是 | 刪不存在的資源結果相同(第二次可能回 404,但系統狀態不變) |
| POST(建立新資源) | ❌ 否 | 建立兩次訂單 = 兩筆訂單,最需特別處理 |
冪等鍵 (Idempotency Key) — 讓 POST 變冪等
客戶端首次請求附上唯一 ID;重試時帶相同的 ID。伺服器處理前先查有沒有處理過這個鍵:有 → 直接回傳之前的結果;沒有 → 執行並存下結果。
Client Server Store
│ POST /payments Idemp-Key: K1 │ │
├──────────────────────────────────>│ get(K1) → miss │
│ ├──────────────────────>│
│ │ process_payment() │
│ │ set(K1, result, TTL) │
│<──────────── result ───────────────┤──────────────────────>│
│ (網路抖動,client 重試) │ │
│ POST /payments Idemp-Key: K1 │ get(K1) → hit │
├──────────────────────────────────>├──────────────────────>│
│<──────── 同一個 result(不重複扣款)┤ │
def create_payment(request):
key = request.headers.get("Idempotency-Key")
if key and (cached := idempotency_store.get(key)):
return cached # 直接回傳,不重複執行
result = process_payment(request.data)
if key:
idempotency_store.set(key, result, ttl=86400) # TTL 24h
return result
Stripe API 以冪等鍵為核心設計,每次建立收費客戶端提供一個鍵,Stripe 保證相同鍵不會被收費兩次。支付系統幾乎必備。
冪等性 × 訊息佇列(高頻考點)
非同步架構裡,訊息佇列的常見保證是 at-least-once(至少一次送達) —— 同一則訊息可能被消費多次。因此 Consumer 必須是冪等的,能安全處理重複訊息而不產生副作用。實作標準解法:去重表 (deduplication table)。
Consumer 收到訊息
└─ 在同一個 DB 事務裡:
1) 查去重表 WHERE message_id = ?
2) 已存在 → 跳過處理,直接 commit offset
3) 不存在 → 處理訊息 + INSERT 去重表(同事務)
真正的 exactly-once 跨系統極難且昂貴(需分散式交易 / 兩階段提交)。業界主流做法是 at-least-once 送達 + 冪等 Consumer = 等效於 exactly-once 的效果,複雜度低得多。這是面試的標準答案。詳見 09-Messaging-Coord/02-Queue、09-Messaging-Coord/03-Kafka。
防線 4:退避加抖動 (Backoff with Jitter)
不加退避的重試會讓問題指數級惡化。
驚群效應 (Thundering Herd)
外部 API 過載 → 回 503
100 個客戶端瞬間全失敗
└─ 1ms 後 100 個一起重試 → API 又收 100 個 → 又全失敗
└─ 又一起重試 …(循環把岌岌可危的服務徹底壓垮,根本沒機會恢復)
指數退避 (Exponential Backoff)
每次重試後等待時間加倍,給系統喘息:
第1次重試:等 1s 第2次:2s 第3次:4s 第4次:8s 第5次:16s(設上限如 30s)
delay = min(base_delay * 2**attempt, max_delay)
但仍不夠 —— 所有客戶端退避曲線相同,會在第 2s、4s、8s 同步重試,形成一波波「脈衝」打在過載服務上。
加上抖動 (Jitter) — 把脈衝打散
在退避時間上加隨機性,讓不同客戶端的重試時間錯開,把同步脈衝攤平成平滑流量。
無 jitter(同步脈衝): │█ █ █ (全部擠在 2s/4s/8s)
Full Jitter(打散): ░ ░░ ░ ░ ░░ ░ ░ ░░ ░ (隨機散在 0~cap)
# Full Jitter(AWS 推薦)
cap = min(base_delay * 2**attempt, max_delay)
delay = random.uniform(0, cap) # 在 0 ~ cap 之間隨機
| 策略 | 公式 | 特性 |
|---|---|---|
| 固定間隔 | delay = 常數 |
最差,仍同步 |
| 指數退避 | min(base*2^n, max) |
比固定好,但同步脈衝 |
| Full Jitter | uniform(0, min(base*2^n, max)) |
實務最常推薦,最簡單有效 |
| Decorrelated Jitter | 每次基於上一次等待時間 | 隨機性更強、效果更好,實作較複雜 |
說出「指數退避加抖動 (exponential backoff with jitter)」這幾個字,加上「它解決的是同步重試的驚群效應」,就已經是非常完整的回答。
防線 5:故障切換 (Failover)
超時、重試、退避處理的是暫時故障。若一個節點 / 伺服器 / 整個資料中心永久掛掉呢?需要 failover —— 把流量從掛掉的節點切到健康的節點。
LB 層健康檢查(無狀態服務的 failover)
LB 定期對每台後端發健康檢查:
TCP 檢查:能建 TCP 連線就算健康
HTTP 檢查:GET /health 回 200 算健康(可加 DB 連線正常、記憶體不超標等深度邏輯)
連續失敗 N 次 → 從輪換池移除,停止送新請求 → 恢復後再加回
整個過程對 client 透明(client 只打一個 LB 位址,不知背後拓撲變化)
upstream backend {
server server1.example.com:8080;
server server2.example.com:8080;
# 連續失敗 3 次標記不健康,30 秒後再重試
server server4.example.com:8080 max_fails=3 fail_timeout=30s;
}
資料庫故障切換(有狀態,複雜得多)
最常見架構:一個 Primary 接受所有寫入 + 一或多個 Replica 透過複製保持同步。Primary 掛掉時:
1. 偵測故障 監控 / 複製協定發現 Primary 無法回應
2. 選舉新主 從 Replica 中選資料最新者,提升為新 Primary
3. 更新連線 App(或連線代理 ProxySQL / RDS Proxy)切換到新 Primary
4. 原主恢復 重新加入但以 Replica 身份(先追上期間的資料差距)
⚠ 從偵測到選出新主 = 不可避免的 downtime window
RDS Multi-AZ / Cloud SQL 壓到幾秒~幾十秒
| 複製方式 | 故障切換時資料 | 寫入延遲 |
|---|---|---|
| 同步複製 | 無資料遺失(Primary 等 Replica 確認才回) | 更高 |
| 非同步複製 | 可能遺失未複製的寫入(replication lag) | 更低 |
主動-主動 vs 主動-被動
| 模式 | 運作 | 優點 | 缺點 |
|---|---|---|---|
| Active-Passive | 只有主節點服務,被動純備援 | 實作簡單、一致性強 | 資源利用率低 |
| Active-Active | 多節點同時服務、互相複製 | 利用率高、切換無縫 | 需解寫入衝突、複雜度大增(適合讀多寫少 / 可接受最終一致性) |
詳細複製機制與一致性取捨見 05-Database-Advanced/03-Replication。
防線 6:降級回應 (Fallback) 與熔斷器
故障切換是「找健康的同類替代」;降級是「同類也沒了,用較簡陋但能用的替代方案撐過去」。
降級策略
| 策略 | 例子 |
|---|---|
| 服役舊資料 serve stale | DB 掛 → 回傳快取裡的舊商品資訊(稍舊比錯誤頁好得多) |
| 預設值 / 靜態回應 | 推薦系統掛 → 回「熱門商品」靜態列表;個人化設定讀失敗 → 用系統預設 |
| 部分降級 | 評分服務超時 → 顯示「評分暫時不可用」而非整頁崩潰 |
def get_product(product_id):
try:
product = db.query(...) # 先試 DB 取最新
cache.set(f"product:{product_id}", product, ttl=300)
return product
except DatabaseError:
if cached := cache.get(f"product:{product_id}"):
return cached # DB 掛 → 回舊資料(serve stale)
raise # 快取也沒有才回錯誤
熔斷器 (Circuit Breaker) — 觸發降級的機制
降級需要一個機制來觸發。熔斷器監控對下游的呼叫,錯誤率超過閾值時自動停止打請求,立刻走降級 —— 讓失敗「快速失敗 (fail fast)」,不必每個請求都等到超時才釋放執行緒,下游也得到喘息恢復的機會。
失敗率超過閾值
┌──────────────────────┐
▼ │
Closed ───────────────> Open ────等冷卻時間───> Half-Open
(正常轉發, (直接走降級, (放一個探針請求)
監控失敗率) 不打下游) │ │
▲ ▲ 探針成功│ │探針失敗
│ └─────────────┘ │
└───────────────── 回 Closed ◄── 探針成功 ──────┘(失敗回 Open)
| 狀態 | 行為 |
|---|---|
| Closed(閉路) | 正常轉發所有請求,監控失敗率 |
| Open(開路) | 熔斷:請求不打下游,直接走降級(快取/預設值/錯誤) |
| Half-Open(半開) | 冷卻後放一個探針請求:成功 → 回 Closed;失敗 → 回 Open |
class CircuitBreaker:
def call(self, fn, fallback):
if self.state == "open":
if time.time() - self.last_failure_time > self.reset_timeout:
self.state = "half-open" # 冷卻夠了,試探
else:
return fallback() # 直接降級,不打下游
try:
result = fn()
if self.state == "half-open":
self.reset() # 探針成功,恢復正常
return result
except Exception:
self.record_failure() # 累積失敗,超閾值 → open
return fallback()
探針成功後逐步放量 (traffic ramp-up),防止突然的全流量把剛恢復的服務又打垮。這是 Deep Dive 常追問的細節。
六道防線如何串在一起
這六個概念不是獨立工具,而是一套協作的防線 —— 理解它們的關係比記住定義更重要:
請求發出
↓
設了超時 ──── 超時發生 ──── 觸發重試
↓ │
加上退避加抖動(防驚群效應)◄────┘
↓
重試是安全的嗎?──── 需要冪等性保證(冪等鍵 / 去重表)
↓
失敗率太高?──── 熔斷器觸發(fail fast)
↓
走降級路徑(快取舊資料 / 預設值)
↓
底層基礎設施故障?──── 故障切換到健康節點 / 新 Primary
面試話術
設計外部依賴時,主動一段話涵蓋多道防線,又完整又自然:
「這裡我們調用支付 API。我會設 3 秒讀取超時,對 5xx 做指數退避重試(最多 3 次,加 jitter 防驚群)。每個支付請求帶冪等鍵,確保重試不會重複扣款。若失敗率 30 秒內超過 50%,熔斷器打開,直接回『支付暫時不可用』,而不是讓用戶等到超時。」 —— 這一段就涵蓋了超時、重試、退避加抖動、冪等性、熔斷器。
常見情境一句話模板:
| 系統 | 可靠性回答 |
|---|---|
| Uber 叫車 | 司機位置每秒幾百萬次 → Kafka 緩衝;Consumer 處理位置更新冪等,所以 at-least-once 就夠,不需 exactly-once 的額外複雜度 |
| 通知系統 | 任務進佇列,Consumer 成功調 API 後才 commit offset(at-least-once);通知表加 notification_id + status 唯一索引識別已發送,不重複推播 |
| 電商訂單 | 建單 API 要求冪等鍵(前端點「下單」時生成,重試複用);訂單服務在 Redis 查 key,已存在就回之前的訂單 ID,避免重複下單 |
| DB 高可用 | PostgreSQL Multi-AZ 同步複製到 Standby;Primary 故障 RDS 自動提升 Standby(通常 60s 內);App 連 RDS Proxy,不需感知切換細節 |
常見 Deep Dive
這是測你是否真正理解冪等性,不只背定義。要說出具體實現:「Consumer 處理邏輯裡先查去重表 (deduplication table),用 message_id 做唯一鍵。已存在就跳過、直接 commit offset;不存在就處理訊息並在同一個事務裡寫入去重表。這樣同一則訊息投遞兩次,第二次會被冪等機制攔截。」
不要說「看情況」。給決策框架:「先測量依賴的 P99 延遲,假設 150ms,設超時為 P99 的 2~3 倍(約 400ms),提供合理緩衝又不讓異常慢請求佔資源太久。再考慮整體鏈路預算:若對用戶 SLA 是 500ms,這個依賴超時必須遠小於 500ms,留出自己和其他依賴的時間。超時值要在真實流量下持續監控調整。」
考察 Half-Open 理解:「Open 狀態等一個固定冷卻時間(如 30s)後進 Half-Open,放一個探針請求。成功 → 認為恢復,切回 Closed;失敗 → 重回 Open 並重置計時器。這讓系統無需人工介入自動偵測恢復。注意:Half-Open 探針成功後不要立刻放開全流量,應逐步放量 (ramp-up),防止突然全流量把剛恢復的服務又打垮。」
同步複製:Primary 確認寫入前必須等 Replica 也確認,failover 不丟資料,但每次寫入延遲更高。非同步複製:Primary 確認後才非同步複製,寫入延遲低,但 failover 時可能遺失尚未複製的寫入(replication lag 期間的資料)。選哪個取決於業務能否容忍少量資料遺失 —— 支付類選同步,社群動態類常選非同步。
不一定。重試只適用暫時性故障。三個陷阱:① 重試 4xx / 業務錯誤 —— 永遠不會成功,純浪費。② 重試非冪等操作且沒做冪等保護 —— 重複下單 / 重複扣款。③ 重試不加退避 —— 集體重試把過載服務徹底壓垮(驚群效應)。此外要小心重試風暴跨層放大:A 重試 3 次 → B 也重試 3 次 → 對最底層就是 9 倍流量。建議只在邊緣 / 最外層重試,內層 fail fast。
通常存在快速的 KV 儲存(Redis)或 DB 唯一索引。TTL 要長於客戶端可能重試的最大時間窗(含退避總和),常見 24 小時。太短 → 遲到的重試查不到記錄又重複執行;太長 → 浪費儲存。注意「先查再寫」之間有 race condition,高並發下要用原子操作(如 Redis SET NX)或 DB 唯一約束來保證只有一個請求真正執行。
自我測驗重點
| 問題 | 重點 |
|---|---|
| 為什麼故障是必然 | 各依賴故障機率相乘 × 百萬請求 → 統計上必然 |
| 無超時的危害 | 執行緒/連線池耗盡 → 級聯故障 |
| 四種超時 | Connection / Read / Write / Overall;Read 最需依 P99 調校 |
| 該重試 vs 不該 | 暫時性(5xx/網路)重試;4xx/業務錯誤/非冪等 不重試 |
| 冪等的 HTTP 方法 | GET/PUT/DELETE 冪等;POST 不冪等 → 用冪等鍵 |
| at-least-once 怎麼安全 | 冪等 Consumer + 去重表(不需 exactly-once) |
| 退避加抖動解決什麼 | 驚群效應(同步重試脈衝);Full Jitter 最常推薦 |
| 同步 vs 非同步複製 | 同步不丟資料但慢;非同步快但可能丟 lag 期資料 |
| Failover vs Fallback | 找健康同類替代 vs 用簡陋替代方案撐過去 |
| 熔斷器三狀態 | Closed → Open → Half-Open(探針 + 逐步放量) |
Related Notes
- 09-Messaging-Coord/02-Queue — at-least-once 送達 + 冪等 Consumer 是可靠交付在非同步架構的核心場景
- 09-Messaging-Coord/03-Kafka — Consumer 成功處理後才 commit offset;位置更新冪等 → at-least-once 就夠
- 09-Messaging-Coord/01-Data-Pipeline — 資料管線各環節都需重試 + 冪等 + DLQ 才能可靠交付
- 01-Networking/07-Fault-Handling — 級聯故障、健康檢查、冪等性的網路層觀點,與本篇互補
- 12-Ops-and-Reliability/02-Overload-Protection — 退避/熔斷與 load shedding、backpressure 同屬過載防護家族
- 12-Ops-and-Reliability/01-Dealing-With-Contention — 重複請求與冪等鍵也是處理並發競爭的一環
- 05-Database-Advanced/03-Replication — Primary/Replica 複製是資料庫故障切換的基礎,同步 vs 非同步取捨
- 01-Networking/06-Load-Balancing — LB 健康檢查把不健康節點移出輪換池,是無狀態服務的 failover
- 07-Caching-Storage/01-Caching — 快取舊資料 (serve stale) 是最常見的降級回應策略
- 02-Distributed-Systems/05-Numbers-to-Know — 設超時值要參考 P99 延遲與整體鏈路時間預算
- 03-API-Design/02-REST — HTTP 方法的冪等性語意(GET/PUT/DELETE 冪等、POST 非冪等)的出處