技術筆記 高可用防禦
SRE · 高可用

高可用防禦:限流、熔斷與快取雪崩

雪崩效應超時快取三兄弟限流演算法熔斷器三態重試風暴

一個下游服務變慢,為什麼會讓上游整個掛掉?這一頁把「高可用防禦」拆成一條因果鏈:先看雪崩是怎麼形成的,再依序看四道防線——超時、快取、限流、熔斷。重點不是背下 Resilience4j 有哪些註解,而是知道每一個機制在擋哪一種失敗,以及它們的順序為什麼不能顛倒。

高可用防禦的所有機制,都是在解決同一個問題。先把那個問題看清楚。

起點:下游只是「變慢」,不是「掛掉」

訂單服務 → 呼叫 → 會員服務(正常 50ms,現在變成 5 秒)

① 每個請求佔住一條 Tomcat 執行緒 5 秒(原本 50ms)
② 同樣的 QPS 下,同時在處理的請求數暴增 100 倍
③ Tomcat 執行緒池(預設 200)很快被佔滿
④ 新的請求開始排隊 → 連「首頁」這種完全無關的 API 也回不了
⑤ 上游的呼叫方逾時 → 重試 → 流量再放大 2~3 倍
⑥ 訂單服務整個沒有回應 → 它的上游也開始重複同樣的過程
關鍵在第 ④ 步:故障「擴散」了。壞掉的只有會員服務,但因為執行緒是共用資源,訂單服務裡所有其他功能一起陪葬——這才是雪崩真正可怕的地方。

為什麼「變慢」比「掛掉」更危險

  • 掛掉:連線立刻被拒絕,執行緒馬上釋放,影響範圍有限
  • 變慢:執行緒被佔住但不釋放,資源被慢慢吃光,而且監控上看起來一切正常(沒有錯誤率、沒有 5xx,只有延遲上升)

這跟 Java 後端底層 ch03 的連線池、ch05 的執行緒池是同一件事:共用的有限資源被慢速操作佔住。

四道防線,各擋一段

① 超時       —— 不讓單一請求佔住資源太久      (最基礎,也最常被忘記)
② 快取       —— 讓大部分請求根本不用碰到下游
③ 限流       —— 超過處理能力的流量直接擋在門外
④ 熔斷/隔離 —— 下游已經壞了就不要再打,並限制受害範圍
💡 順序不能顛倒。沒有設超時的話,後面三道防線都會被「一直不返回的請求」繞過——因為它們大多是在「請求結束後」才統計錯誤率或釋放額度。

在談熔斷與限流之前,先確認一件事:你的每一個外部呼叫,都有設超時嗎?

沒設超時等於沒有上限

大多數 HTTP client 的預設是「無限等待」或「非常長」:

RestTemplate(無設定)        → 無限等待 ★
OkHttp 預設                   → 連線 10s、讀取 10s
Feign(無設定)                → 依底層 client,常常也是無限
JDBC(無設定)                 → 依驅動,可能無限
一個沒有超時的呼叫,會讓上一張卡的雪崩「必然」發生——因為執行緒永遠不會被釋放。熔斷器也救不了:它靠「失敗次數」判斷,而一個永遠不返回的請求,既不算成功也不算失敗。

三種超時要分清楚

  • 連線逾時(Connect Timeout)——建立 TCP 連線的上限。可以設得很短(1~3 秒),因為連不上通常是立刻就知道的。
  • 讀取逾時(Read / Socket Timeout)——等待回應的上限。這是最關鍵的一個。
  • 整體逾時——包含重試在內的總時間上限。很多人只設前兩個,結果配上 3 次重試,實際最壞等待時間變成三倍。

怎麼決定讀取逾時的值

不要用「感覺」,用下游的 P99 延遲:

讀取逾時 ≈ 下游 P99 × 1.5 ~ 2

例:下游 P99 是 200ms  →  設 300~400ms
    而不是「保險起見設 30 秒」
✅ 設得太長比不設好一點,但差別沒有想像中大。30 秒的逾時在雪崩發生時,跟無限等待的效果幾乎一樣——因為執行緒池早在 30 秒之前就被塞滿了。

還要往上游對齊

使用者端逾時 30s
  > 閘道逾時 10s
    > 服務 A 逾時 5s
      > 服務 B 逾時 2s   ← 越下游越短

如果內層比外層長,外層放棄之後內層還在跑 —— 白白佔住資源

現象

攻擊者(或 bug)不斷查詢 id = -1 這種不存在的資料

① 查快取 → 沒有(因為資料本來就不存在)
② 查資料庫 → 也沒有
③ 因為查不到,所以「沒有東西可以寫進快取」
④ 下一個請求重複 ①②③

→ 每一個請求都直擊資料庫,快取形同虛設
穿透的特徵是「資料本來就不存在」——所以快取永遠不會被填上,這跟另外兩兄弟(資料存在但快取沒了)本質不同。

防禦一:快取空值

if (db 查不到) {
    redis.set(key, "NULL_PLACEHOLDER", Duration.ofMinutes(2));  // 短 TTL
}
  • 優點:實作最簡單
  • 缺點:如果攻擊者每次用不同的 id,會塞滿大量無用的 key。所以 TTL 一定要短(幾分鐘),而且最好用獨立的 key 前綴方便清理。

防禦二:布隆過濾器(Bloom Filter)

啟動時把所有存在的 id 灌進布隆過濾器

查詢時:
  filter.mightContain(id) == false  →  100% 不存在,直接返回,連 Redis 都不用查
  filter.mightContain(id) == true   →  「可能」存在,才往下查
布隆過濾器的特性要記清楚:
• 說「不存在」→ 一定不存在(沒有偽陰性)
• 說「存在」→ 可能是誤判(有偽陽性,機率可調)

所以它只能用來擋掉確定不存在的查詢,不能用來確認資料真的存在。

限制:標準布隆過濾器不支援刪除(刪掉一個 bit 可能影響其他元素),資料有刪除需求時要用 Counting Bloom Filter 或定期重建。

還有一個更基本的防禦

參數校驗。`id = -1` 這種明顯不合法的值,在進到快取邏輯之前就該被擋掉。很多「穿透攻擊」其實只是缺少基本的輸入驗證。

現象

某個超熱門商品的快取,TTL 到期的那一毫秒

① 5000 個並行請求同時查快取 → 全部 miss
② 5000 個請求同時衝向資料庫查同一筆資料
③ 資料庫瞬間被打爆
④ (如果撐過去)5000 個請求各自把結果寫回快取 —— 重複做了 4999 次白工
擊穿的特徵是「單一熱點 Key」+「同時失效」。資料是存在的,只是快取剛好在那一刻沒了。

防禦一:互斥鎖(只讓一個人去查)

String value = redis.get(key);
if (value != null) return value;

// 只有搶到鎖的那一個執行緒去查 DB
if (redis.setIfAbsent(lockKey, "1", Duration.ofSeconds(10))) {
    try {
        value = db.query(id);
        redis.set(key, value, ttl);
    } finally {
        redis.delete(lockKey);
    }
} else {
    Thread.sleep(50);          // 其他人稍等一下
    return redis.get(key);     // 再讀一次,此時應該已經有了
}
注意這裡必須用分散式鎖(Redis/Redisson),不能用 JVM 的 synchronized。多個 Pod 各有各的 JVM 鎖,等於沒鎖——這是 ch05 提過的頭號誤用。

另外鎖一定要設過期時間,否則持鎖的服務掛掉就永久死鎖。

防禦二:邏輯過期(不設真的 TTL)

存進快取的是一個包裝物件:
{ "data": {...}, "expireAt": 1754300000 }

查詢時:
  ① 一定拿得到(Redis 上沒有真正過期)
  ② 檢查 expireAt 是否已過
  ③ 若已過期 → 「先回傳舊資料」,同時開一個非同步任務去更新

→ 沒有任何請求需要等待,代價是短時間內會讀到稍舊的資料
💡 選擇準則:能接受短暫的舊資料就用邏輯過期(使用者體驗最好);必須強一致就用互斥鎖(會有少數請求等待)。

防禦三:熱點 Key 乾脆不過期

對於「首頁配置」「熱門排行」這種數量少但存取量極高的資料,直接不設 TTL,改由更新資料時主動刷新快取。這樣就沒有過期的瞬間。

兩種不同的雪崩

類型一:大量 Key 在同一時刻集體過期

系統啟動時預熱,一次寫入 10 萬個 key,TTL 全部設 1 小時
→ 一小時後,10 萬個 key 在同一秒集體失效
→ 所有流量同時打向資料庫

防禦:TTL 加隨機值。

// ❌ 全部一樣
redis.set(key, value, Duration.ofHours(1));

// ✅ 加上隨機抖動,把過期時間打散
long ttl = 3600 + ThreadLocalRandom.current().nextInt(600);   // 1 小時 ± 10 分鐘
redis.set(key, value, Duration.ofSeconds(ttl));

類型二:Redis 整個不可用

這個更嚴重——所有快取瞬間消失,全部流量直擊資料庫,而資料庫的處理能力通常只有快取的幾十分之一。

  • 高可用部署:Redis Sentinel 或 Cluster,避免單點
  • 多級快取:本地快取(Caffeine)+ Redis。Redis 掛了還有本地快取擋一層
  • 對資料庫做限流/熔斷:這是最後的保險——寧可讓一部分請求失敗,也不要讓資料庫被打死而全部失敗
最容易被忽略的一點:快取掛掉時,你的系統必須「還能活著」,而不是「跟著一起死」。
很多系統把 Redis 當成必要依賴——連不上就直接拋例外。正確的做法是降級:Redis 讀取失敗時記錄告警,然後走資料庫(並靠限流保護它),而不是讓整個請求失敗。

三兄弟的一句話分辨

穿透 —— 資料「本來就不存在」,快取永遠填不上
擊穿 —— 單一熱點 Key 剛好過期的那一瞬間
雪崩 —— 大量 Key 同時失效,或整個 Redis 掛掉

限流演算法不用背——它們是一條演進線,每一個都在解決前一個的缺陷。

① 固定窗口計數器(最直覺)

每分鐘最多 100 次。用一個計數器,每分鐘歸零。
缺陷:臨界問題。
00:59 打 100 次、01:01 又打 100 次——在跨越窗口的這 2 秒內實際通過了 200 次,是限制的兩倍。而系統崩潰往往就發生在這種瞬間尖峰。

② 滑動窗口(修掉臨界問題)

把一分鐘切成 6 個 10 秒的小格子,統計「最近 6 格」的總和。
時間往前走一格,就丟掉最舊的一格。
  • ✅ 沒有臨界問題,統計區間永遠是「最近一分鐘」
  • ❌ 格子切越細越精確,但記憶體成本越高
  • ❌ 還是不能平滑流量——一分鐘的額度可能在前 5 秒就被用光

③ 漏桶(修掉「不平滑」)

請求先進桶排隊,然後以「絕對恆定」的速率流出。
桶滿了就拒絕。
  • ✅ 輸出速率完全恆定,下游收到的壓力非常穩定
  • ❌ 無法應付突發流量——就算系統明明有餘力,也只能按固定速率放行

④ 令牌桶(修掉「無法吸收突發」)

以固定速率往桶裡「放令牌」,桶有容量上限。
請求要先拿到令牌才能通過。

關鍵:閒置時令牌會累積起來
→ 突然來一波流量時,可以一口氣消耗掉存量令牌
→ 既有長期的平均速率限制,又能吸收短時突發
✅ 令牌桶是實務上最常用的(Guava RateLimiter、Sentinel、Resilience4j 都是),因為真實流量本來就是有波峰的,完全恆定的輸出反而浪費了系統餘力。

單機限流 vs 分散式限流

Guava 的 RateLimiter 是「單機」限流。10 個 Pod 各限 100 QPS,總量其實是 1000 QPS。

要限制全域總量必須用集中式的計數器——通常是 Redis + Lua 腳本(保證原子性),或直接用 Sentinel、閘道層的限流元件。代價是每個請求多一次 Redis 往返。

限流保護的是自己(別讓太多流量進來);熔斷保護的是自己和下游(別再打一個已經壞掉的服務)。

為什麼「已經壞了就不要再打」很重要

  • 對自己:每個注定失敗的呼叫,都要先佔住執行緒等到逾時。快速失敗才能把資源留給還能成功的請求。
  • 對下游:一個正在掙扎的服務,如果還持續收到流量,永遠沒有機會恢復。

三個狀態

┌──────────┐  失敗率超過閾值   ┌────────┐
│  CLOSED  │ ───────────────→ │  OPEN  │
│ (正常)  │                  │(跳閘) │
└──────────┘                  └────┬───┘
      ↑                             │ 等待一段時間
      │ 測試成功                     ↓
      │                     ┌──────────────┐
      └──────────────────── │  HALF_OPEN   │
            測試失敗 → OPEN  │(放少量測試)  │
                            └──────────────┘
  • CLOSED——正常放行,同時統計失敗率
  • OPEN——直接失敗,連打都不打(Fail Fast),走降級邏輯
  • HALF_OPEN——等待時間到了之後,放幾個請求去探路。成功就回到 CLOSED,失敗就退回 OPEN 再等
HALF_OPEN 是這個設計最精巧的地方:它用極小的代價(幾個請求)來試探恢復,而不是「時間到了就全部放行」——後者會讓剛要恢復的下游立刻被再次打死。

Resilience4j 的關鍵參數

resilience4j.circuitbreaker:
  instances:
    memberService:
      slidingWindowType: COUNT_BASED      # 或 TIME_BASED
      slidingWindowSize: 100              # 統計最近 100 次呼叫
      minimumNumberOfCalls: 20            # ★ 至少 20 次才開始判斷
      failureRateThreshold: 50            # 失敗率 > 50% 就跳閘
      slowCallDurationThreshold: 2s       # ★ 超過 2 秒算「慢呼叫」
      slowCallRateThreshold: 50           # 慢呼叫率 > 50% 也跳閘
      waitDurationInOpenState: 30s        # OPEN 狀態等 30 秒
      permittedNumberOfCallsInHalfOpenState: 5
兩個最容易設錯的參數:
• minimumNumberOfCalls 太小 → 剛啟動時 2 次呼叫失敗就跳閘,服務永遠起不來
• 忘了設 slowCallDurationThreshold → 只看「失敗率」的話,一個「很慢但都會成功」的下游永遠不會觸發熔斷——而那正是第一張卡說的、最危險的那種故障

回到第一張卡的關鍵問題:故障之所以會擴散,是因為所有功能共用同一個執行緒池。

艙壁模式(Bulkhead)

名字來自船艙——船體被隔成多個防水艙,一個艙進水不會讓整艘船沉沒。

沒有隔離:
  [ 所有請求 ] → 共用 200 條執行緒
  會員服務變慢 → 200 條全被佔住 → 訂單、商品、首頁全部一起死

有隔離:
  會員服務  → 專屬 20 條
  商品服務  → 專屬 20 條
  訂單服務  → 專屬 20 條
  會員服務變慢 → 只有那 20 條被佔住 → 其他功能完全不受影響

兩種隔離方式

  • 執行緒池隔離——每個依賴一個獨立執行緒池。隔離最徹底(連逾時都能強制中斷),但有執行緒切換的成本,而且執行緒總數會變多。
  • 信號量隔離(Semaphore)——只用計數器限制「同時進行的數量」,不換執行緒。成本極低,但無法強制中斷已經在跑的呼叫——所以它更依賴超時設定。
💡 選擇準則:呼叫外部服務(網路 I/O、不可控)用執行緒池隔離;呼叫本地資源或極快的操作用信號量隔離。

降級:失敗時回傳什麼

@CircuitBreaker(name = "memberService", fallbackMethod = "getMemberFallback")
public Member getMember(Long id) {
    return memberClient.getById(id);
}

// 熔斷開啟或呼叫失敗時走這裡
private Member getMemberFallback(Long id, Throwable t) {
    log.warn("會員服務降級, id={}", id, t);
    return Member.guest();          // 回傳一個安全的預設值
}

降級要回傳什麼才對

  • ✅ 快取的舊資料——最好的降級,使用者幾乎無感
  • ✅ 安全的預設值——例如推薦清單回傳熱門商品、會員等級回傳「一般會員」
  • ✅ 明確的「暫時無法使用」——比讓使用者一直轉圈好
  • ❌ 回傳 null 然後讓上游 NPE——把一種故障換成另一種
  • ❌ 降級邏輯本身又呼叫另一個外部服務——降級路徑必須是最可靠的那條
核心原則:降級要「安全」而不是「正確」。寧可給一個保守的答案,也不要因為拿不到精確答案就整個失敗。

但涉及金額、庫存、權限的操作絕對不能降級成「假裝成功」——那會造成資料不一致,比直接失敗嚴重得多。

重試看起來是最無害的防禦——失敗了再試一次而已。實際上它是最容易造成災難的一個。

重試風暴(Retry Storm)

下游服務因為負載過高開始變慢
  → 上游逾時,重試 3 次
  → 下游收到的流量變成 3 倍  ★
  → 更慢、更多逾時
  → 更多重試 → 流量繼續放大
  → 下游徹底崩潰

★ 而這一切發生在「下游本來只是有點吃力」的時候
更糟的是多層架構下的乘法效應:
閘道重試 3 次 × 服務 A 重試 3 次 × 服務 B 重試 3 次 = 最底層收到 27 倍流量。

規則:整條呼叫鏈上只有一層應該重試——通常是最接近使用者的那一層,或最接近故障點的那一層,但絕對不是每一層都重試。

三個必要條件

① 只重試「可能會成功」的錯誤

  • ✅ 連線逾時、503、網路瞬斷 —— 重試有意義
  • ❌ 400 參數錯誤、401 未授權、業務規則失敗 —— 重試一萬次也不會成功,純粹浪費

② 指數退避 + 隨機抖動

// ❌ 固定間隔:所有失敗的請求會「同時」重試
retry after 1s, 1s, 1s

// ⚠️ 指數退避:好一些,但同一批請求仍然同步
retry after 1s, 2s, 4s

// ✅ 指數退避 + jitter:把重試時間打散
retry after 1s±0.5s, 2s±1s, 4s±2s

jitter(隨機抖動)是最容易被省略、卻最關鍵的一步。沒有它的話,一萬個同時失敗的請求會在 1 秒後「同時」再打一次——這跟原本的尖峰沒有差別。

③ 操作必須冪等

「逾時」不代表「沒執行成功」。
下游可能已經扣款成功,只是回應在網路上丟了。這時重試 = 扣兩次款。

可以安全重試:查詢(GET)、以及有冪等鍵保護的寫入
不可以直接重試:建立訂單、扣款、發送通知——必須先用冪等鍵(業務唯一鍵 + 資料庫唯一索引)保證重複請求不會重複執行

重試與熔斷要一起設定

熔斷器開啟時不應該再重試——那等於繞過了熔斷。Resilience4j 的裝飾順序(由外到內)是:

Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 實際呼叫

重試在最外層 = 每次重試都會先問過熔斷器
熔斷器 OPEN 時,重試會立刻拿到 CallNotPermittedException 而不是真的去打下游

這一頁講了六種機制。但每一種都有成本——全部套上去的系統,可能比完全不設防更難維護。

成本一:複雜度與除錯難度

一個請求失敗時,你必須先釐清:是被限流擋掉、被熔斷器拒絕、被艙壁排隊逾時、走了降級邏輯,還是真的失敗了?每多一層防禦,就多一種「請求沒有正常完成」的可能。

成本二:錯誤的參數比沒有設定更糟

  • 限流閾值設得太低 → 正常流量被誤殺,而且看起來像是服務故障
  • minimumNumberOfCalls 太小 → 剛啟動就跳閘,服務永遠起不來
  • 降級邏輯有 bug → 平常不會執行,出事時才第一次跑,然後跟著一起爆
降級路徑一定要有測試覆蓋。它是「只在最糟的時候才會執行」的程式碼——如果沒有測試,你等於在故障當下才第一次驗證它。

成本三:掩蓋真正的問題

熔斷器讓系統「不會掛掉」,但也讓「下游一直很慢」這件事變得不痛不癢。防禦機制應該搭配告警——熔斷器跳閘、限流被觸發、降級被執行,這些都該送出通知,而不是靜靜地讓系統帶病運行。

建議的導入順序

  1. 先設超時——所有外部呼叫,一個都不能漏。投報率最高的一步。
  2. 再加監控——沒有數據就沒辦法設定後面任何一個閾值
  3. 對真的會被打爆的入口加限流——不是每個 API 都需要
  4. 對真的不穩定的依賴加熔斷——不是每個呼叫都需要
  5. 只在有實際隔離需求時才用艙壁——它的成本最高
🚨 不要因為「架構圖上有比較好看」就把所有機制都套上。沒有超時的系統加上熔斷器,仍然會雪崩;有超時但沒有監控的系統,你連該把閾值設多少都不知道。順序比齊全更重要。

一句話收尾

這一頁所有的機制,都在回答同一個問題:當依賴的東西不可靠時,我要怎麼「有限度地失敗」,而不是「無限度地一起死」。

同一波突發流量,漏桶與令牌桶的差異
快取三兄弟:成因、特徵與防禦
問題資料存在嗎觸發情境主要防禦
穿透 Penetration❌ 本來就不存在查詢不合法或不存在的 id,快取永遠填不上參數校驗 → 快取空值(短 TTL)→ 布隆過濾器
擊穿 Breakdown✅ 存在單一熱點 Key 過期的那一瞬間,大量並行請求同時 miss分散式互斥鎖/邏輯過期/熱點 Key 不設 TTL
雪崩 Avalanche✅ 存在大量 Key 同時過期,或 Redis 整個掛掉TTL 加隨機抖動/多級快取/Redis 高可用/對 DB 限流
四種限流演算法:每個都在補前一個的洞
演算法解決了什麼還有什麼問題實務
固定窗口計數器最簡單,能限制總量臨界問題:跨窗口的 2 秒內可能通過兩倍流量只適合對精度要求不高的場景
滑動窗口修掉臨界問題,統計永遠是「最近 N 秒」格子越細越吃記憶體;仍無法平滑流量Sentinel 的預設實作
漏桶 Leaky Bucket輸出速率絕對恆定,下游壓力最穩無法應付突發——有餘力也只能按固定速率放行適合對下游速率有硬性要求時
令牌桶 Token Bucket閒置時累積令牌,可吸收短時突發突發量受桶容量限制,需要調參✅ 最常用:Guava RateLimiter、Resilience4j
Resilience4j 元件與裝飾順序(由外到內)
順序元件擋什麼最容易設錯的地方
① 最外層Retry暫時性失敗多層都重試 → 乘法放大;沒有 jitter → 同步重試尖峰
②CircuitBreaker已經壞掉的下游,快速失敗minimumNumberOfCalls 太小 → 剛啟動就跳閘;忘了設 slowCallDurationThreshold
③RateLimiter超過處理能力的流量單機限流 × Pod 數 ≠ 全域限流
④TimeLimiter太久不返回的呼叫沒設 = 前面所有防線都可能被繞過
⑤ 最內層Bulkhead限制並行數,避免故障擴散執行緒池隔離成本高;信號量隔離無法中斷已在跑的呼叫

練習題 點選選項查看解析

0 / 10
01 / 10
為什麼下游服務「變慢」比「直接掛掉」更危險?
A 因為變慢會造成資料不一致
B 掛掉時連線立刻被拒絕、執行緒馬上釋放;變慢則佔住執行緒不放,資源被慢慢吃光,而且監控上看起來沒有錯誤率
C 因為變慢會觸發更多 GC
D 兩者危險程度相同
解析
變慢的可怕之處在於故障會「擴散」:壞掉的只有一個依賴,但因為執行緒是共用資源,同一個服務裡所有其他功能一起陪葬。而且沒有 5xx、沒有錯誤率,只有延遲上升,很容易被忽略。
02 / 10
四道防線的正確順序是什麼?為什麼順序不能顛倒?
A 熔斷 → 限流 → 快取 → 超時,因為熔斷最重要
B 超時 → 快取 → 限流 → 熔斷/隔離;沒有超時的話後面三道都會被「一直不返回的請求」繞過
C 限流 → 超時 → 熔斷 → 快取
D 順序無所謂,同時設定即可
解析
熔斷器靠「失敗次數」判斷,而一個永遠不返回的請求既不算成功也不算失敗。限流與隔離大多也是在請求結束後才釋放額度。所以沒設超時的話,其他機制形同虛設。
03 / 10
快取穿透跟擊穿的本質差別是什麼?
A 穿透發生在 Redis,擊穿發生在資料庫
B 穿透是「資料本來就不存在」所以快取永遠填不上;擊穿是資料存在,只是單一熱點 Key 剛好過期的那一瞬間
C 穿透是惡意攻擊,擊穿是正常流量
D 兩者是同一件事的不同說法
解析
這個差別決定了防禦方式:穿透要用參數校驗、快取空值或布隆過濾器(擋住不存在的查詢);擊穿要用互斥鎖或邏輯過期(避免同時重建)。
04 / 10
布隆過濾器的判斷特性是什麼?
A 說「存在」一定存在,說「不存在」可能誤判
B 說「不存在」一定不存在(無偽陰性),說「存在」可能誤判(有偽陽性)
C 兩種判斷都 100% 準確
D 兩種判斷都可能誤判
解析
所以它只能用來擋掉「確定不存在」的查詢,不能用來確認資料真的存在。另外標準布隆過濾器不支援刪除,有刪除需求要用 Counting Bloom Filter 或定期重建。
05 / 10
解決快取擊穿用互斥鎖時,為什麼不能用 JVM 的 synchronized?
A 因為 synchronized 效能太差
B 因為多個 Pod 各有各的 JVM 鎖,彼此不知道對方在做什麼,等於沒鎖——必須用 Redis/Redisson 分散式鎖
C 因為 synchronized 不支援逾時
D 其實可以用,這是誤解
解析
這跟 backend ch05 提到的頭號並發誤用是同一件事。另外分散式鎖一定要設過期時間,否則持鎖的服務掛掉就變成永久死鎖。
06 / 10
固定窗口計數器的「臨界問題」是什麼?
A 計數器可能溢位
B 在跨越窗口的短暫時間內(例如 00:59 和 01:01),實際通過的流量可能是限制的兩倍
C 無法統計超過一分鐘的流量
D 多執行緒下計數不準確
解析
而系統崩潰往往就發生在這種瞬間尖峰。滑動窗口把窗口切成多個小格子、統計「最近 N 格」來修掉這個問題,但它仍然無法平滑流量——一分鐘的額度可能在前 5 秒就用光。
07 / 10
為什麼令牌桶是實務上最常用的限流演算法?
A 因為它的實作最簡單
B 因為閒置時令牌會累積,既有長期平均速率限制、又能吸收短時突發,而真實流量本來就有波峰
C 因為它能保證輸出速率絕對恆定
D 因為它不需要任何參數
解析
漏桶的輸出速率完全恆定,代價是就算系統有餘力也只能按固定速率放行。令牌桶保留了吸收突發的彈性。Guava RateLimiter、Sentinel、Resilience4j 都是令牌桶。
08 / 10
熔斷器的 HALF_OPEN 狀態為什麼重要?
A 為了記錄失敗次數
B 用極小的代價(放幾個請求探路)試探下游是否恢復,避免「時間到就全部放行」把剛要恢復的下游再次打死
C 為了讓熔斷器可以手動關閉
D 為了統計慢呼叫比率
解析
這是熔斷器設計最精巧的地方。成功就回 CLOSED,失敗就退回 OPEN 繼續等。permittedNumberOfCallsInHalfOpenState 控制探路的請求數。
09 / 10
熔斷器只設定 failureRateThreshold(失敗率)會漏掉什麼情況?
A 下游回傳 4xx 的情況
B 「很慢但都會成功」的下游永遠不會觸發熔斷——而那正是最危險的一種故障;要同時設 slowCallDurationThreshold
C 下游完全無法連線的情況
D 不會漏掉任何情況
解析
呼應第一張卡:變慢比掛掉更危險。只看失敗率的話,一個每次都成功但要 10 秒的下游會持續佔住你的執行緒,而熔斷器完全不作動。slowCallDurationThreshold + slowCallRateThreshold 就是為此而設。
10 / 10
關於重試,下列哪個做法會把小故障放大成大故障?
A 只重試連線逾時和 503
B 在閘道、服務 A、服務 B 每一層都設定重試 3 次
C 使用指數退避加隨機抖動
D 重試前先確認操作是冪等的
解析
多層重試會產生乘法效應:3 × 3 × 3 = 最底層收到 27 倍流量。規則是整條呼叫鏈上只有一層應該重試。另外沒有 jitter 的話,一萬個同時失敗的請求會在 1 秒後同時再打一次,跟原本的尖峰沒有差別。

點擊卡片翻面查看答案,共 18 張。

QUESTION
雪崩效應的關鍵一步是什麼?
點擊翻面
ANSWER
故障「擴散」——壞掉的只有一個依賴,但執行緒是共用資源,被佔滿之後同一個服務裡所有其他功能一起陪葬。
點擊翻回
QUESTION
為什麼「變慢」比「掛掉」更危險?
點擊翻面
ANSWER
掛掉時連線立刻被拒絕、執行緒馬上釋放;變慢則佔住執行緒不放,資源慢慢被吃光,而且監控上沒有錯誤率、只有延遲上升,很容易被忽略。
點擊翻回
QUESTION
★ 四道防線與順序?
點擊翻面
ANSWER
① 超時(不讓單一請求佔住資源太久)② 快取(大部分請求不用碰下游)③ 限流(超量流量擋在門外)④ 熔斷/隔離(壞了就別打,並限制受害範圍)。順序不能顛倒——沒有超時,後面三道都會被繞過。
點擊翻回
QUESTION
讀取逾時該設多少?
點擊翻面
ANSWER
下游 P99 × 1.5~2,而不是「保險起見設 30 秒」。30 秒的逾時在雪崩時跟無限等待幾乎一樣,因為執行緒池早就被塞滿了。另外逾時要往下游遞減對齊。
點擊翻回
QUESTION
快取三兄弟一句話分辨?
點擊翻面
ANSWER
穿透=資料本來就不存在,快取永遠填不上;擊穿=單一熱點 Key 剛好過期的瞬間;雪崩=大量 Key 同時失效或 Redis 整個掛掉。
點擊翻回
QUESTION
布隆過濾器的判斷特性與限制?
點擊翻面
ANSWER
說「不存在」一定不存在(無偽陰性),說「存在」可能誤判(有偽陽性)。只能擋掉確定不存在的查詢。標準版不支援刪除,需要刪除時用 Counting Bloom Filter 或定期重建。
點擊翻回
QUESTION
解決快取擊穿的兩種主要做法怎麼選?
點擊翻面
ANSWER
能接受短暫舊資料 → 邏輯過期(先回舊值、非同步更新,沒有請求需要等待);必須強一致 → 分散式互斥鎖(少數請求會等待)。鎖一定要設過期時間。
點擊翻回
QUESTION
Redis 掛掉時系統該怎麼反應?
點擊翻面
ANSWER
降級,不是跟著死。Redis 讀取失敗時記錄告警然後走資料庫(並靠限流保護它),而不是讓整個請求失敗。很多系統把 Redis 當必要依賴,連不上就拋例外——那是錯的。
點擊翻回
QUESTION
★ 四種限流演算法的演進線?
點擊翻面
ANSWER
固定窗口(有臨界問題)→ 滑動窗口(修掉臨界,但不平滑)→ 漏桶(絕對恆定,但無法吸收突發)→ 令牌桶(累積令牌,可吸收突發)。每一個都在補前一個的洞。
點擊翻回
QUESTION
Guava RateLimiter 的限制?
點擊翻面
ANSWER
它是單機限流。10 個 Pod 各限 100 QPS,總量其實是 1000。要限全域總量必須用 Redis + Lua(原子性)或 Sentinel,代價是每個請求多一次 Redis 往返。
點擊翻回
QUESTION
熔斷器三態與 HALF_OPEN 的意義?
點擊翻面
ANSWER
CLOSED(正常統計)→ 失敗率超標 → OPEN(直接失敗不打)→ 等待後 → HALF_OPEN(放幾個探路)→ 成功回 CLOSED、失敗退回 OPEN。HALF_OPEN 用極小代價試探恢復,避免把剛要恢復的下游再次打死。
點擊翻回
QUESTION
熔斷器兩個最容易設錯的參數?
點擊翻面
ANSWER
① minimumNumberOfCalls 太小 → 剛啟動 2 次失敗就跳閘,服務永遠起不來 ② 忘了設 slowCallDurationThreshold → 「很慢但都成功」的下游永遠不會觸發熔斷,而那正是最危險的故障。
點擊翻回
QUESTION
執行緒池隔離 vs 信號量隔離?
點擊翻面
ANSWER
執行緒池:隔離最徹底、能強制中斷,但有切換成本、執行緒總數變多 → 適合外部服務呼叫。信號量:只用計數器限制並行數、成本極低,但無法中斷已在跑的呼叫 → 適合本地資源或極快的操作。
點擊翻回
QUESTION
降級該回傳什麼?不該回傳什麼?
點擊翻面
ANSWER
該:快取舊資料(最好)、安全預設值、明確的「暫時無法使用」。不該:null 讓上游 NPE、降級邏輯又呼叫另一個外部服務。原則是「安全」而非「正確」——但金額、庫存、權限絕不能降級成假裝成功。
點擊翻回
QUESTION
重試的三個必要條件?
點擊翻面
ANSWER
① 只重試可能成功的錯誤(4xx 重試一萬次也沒用)② 指數退避 + jitter(沒有 jitter 就是同步重試尖峰)③ 操作必須冪等——逾時不代表沒執行成功,重試可能扣兩次款。
點擊翻回
QUESTION
為什麼多層重試是災難?
點擊翻面
ANSWER
乘法效應:閘道 3 次 × 服務 A 3 次 × 服務 B 3 次 = 最底層收到 27 倍流量,而這發生在下游本來只是有點吃力的時候。規則:整條呼叫鏈上只有一層應該重試。
點擊翻回
QUESTION
Resilience4j 的裝飾順序(由外到內)?
點擊翻面
ANSWER
Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 實際呼叫。重試在最外層代表每次重試都會先問熔斷器,OPEN 時立刻拿到 CallNotPermittedException 而不會真的打下游。
點擊翻回
QUESTION
防禦機制的建議導入順序?
點擊翻面
ANSWER
① 先設超時(投報率最高,一個都不能漏)② 加監控(沒數據就無法設閾值)③ 對真的會被打爆的入口加限流 ④ 對真的不穩定的依賴加熔斷 ⑤ 有實際隔離需求才用艙壁。順序比齊全更重要。
點擊翻回