可靠交付 (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 內 確保多次重試也不爆預算

怎麼設超時值(決策框架)

面試話術(超時)

「我會對這個外部支付 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 是冪等鍵的範本

Stripe API 以冪等鍵為核心設計,每次建立收費客戶端提供一個鍵,Stripe 保證相同鍵不會被收費兩次。支付系統幾乎必備。

冪等性 × 訊息佇列(高頻考點)

非同步架構裡,訊息佇列的常見保證是 at-least-once(至少一次送達) —— 同一則訊息可能被消費多次。因此 Consumer 必須是冪等的,能安全處理重複訊息而不產生副作用。實作標準解法:去重表 (deduplication table)

Consumer 收到訊息
  └─ 在同一個 DB 事務裡:
       1) 查去重表 WHERE message_id = ?
       2) 已存在 → 跳過處理,直接 commit offset
       3) 不存在 → 處理訊息 + INSERT 去重表(同事務)
為什麼通常用 at-least-once 而非 exactly-once?

真正的 exactly-once 跨系統極難且昂貴(需分散式交易 / 兩階段提交)。業界主流做法是 at-least-once 送達 + 冪等 Consumer = 等效於 exactly-once 的效果,複雜度低得多。這是面試的標準答案。詳見 09-Messaging-Coord/02-Queue09-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()
Half-Open 不要一次放開全流量

探針成功後逐步放量 (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


自我測驗重點

問題 重點
為什麼故障是必然 各依賴故障機率相乘 × 百萬請求 → 統計上必然
無超時的危害 執行緒/連線池耗盡 → 級聯故障
四種超時 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(探針 + 逐步放量)