一個下游服務變慢,為什麼會讓上游整個掛掉?這一頁把「高可用防禦」拆成一條因果鏈:先看雪崩是怎麼形成的,再依序看四道防線——超時、快取、限流、熔斷。重點不是背下 Resilience4j 有哪些註解,而是知道每一個機制在擋哪一種失敗,以及它們的順序為什麼不能顛倒。
高可用防禦的所有機制,都是在解決同一個問題。先把那個問題看清楚。
訂單服務 → 呼叫 → 會員服務(正常 50ms,現在變成 5 秒)
① 每個請求佔住一條 Tomcat 執行緒 5 秒(原本 50ms)
② 同樣的 QPS 下,同時在處理的請求數暴增 100 倍
③ Tomcat 執行緒池(預設 200)很快被佔滿
④ 新的請求開始排隊 → 連「首頁」這種完全無關的 API 也回不了
⑤ 上游的呼叫方逾時 → 重試 → 流量再放大 2~3 倍
⑥ 訂單服務整個沒有回應 → 它的上游也開始重複同樣的過程這跟 Java 後端底層 ch03 的連線池、ch05 的執行緒池是同一件事:共用的有限資源被慢速操作佔住。
① 超時 —— 不讓單一請求佔住資源太久 (最基礎,也最常被忘記)
② 快取 —— 讓大部分請求根本不用碰到下游
③ 限流 —— 超過處理能力的流量直接擋在門外
④ 熔斷/隔離 —— 下游已經壞了就不要再打,並限制受害範圍在談熔斷與限流之前,先確認一件事:你的每一個外部呼叫,都有設超時嗎?
大多數 HTTP client 的預設是「無限等待」或「非常長」:
RestTemplate(無設定) → 無限等待 ★
OkHttp 預設 → 連線 10s、讀取 10s
Feign(無設定) → 依底層 client,常常也是無限
JDBC(無設定) → 依驅動,可能無限不要用「感覺」,用下游的 P99 延遲:
讀取逾時 ≈ 下游 P99 × 1.5 ~ 2
例:下游 P99 是 200ms → 設 300~400ms
而不是「保險起見設 30 秒」使用者端逾時 30s
> 閘道逾時 10s
> 服務 A 逾時 5s
> 服務 B 逾時 2s ← 越下游越短
如果內層比外層長,外層放棄之後內層還在跑 —— 白白佔住資源攻擊者(或 bug)不斷查詢 id = -1 這種不存在的資料
① 查快取 → 沒有(因為資料本來就不存在)
② 查資料庫 → 也沒有
③ 因為查不到,所以「沒有東西可以寫進快取」
④ 下一個請求重複 ①②③
→ 每一個請求都直擊資料庫,快取形同虛設if (db 查不到) {
redis.set(key, "NULL_PLACEHOLDER", Duration.ofMinutes(2)); // 短 TTL
}啟動時把所有存在的 id 灌進布隆過濾器
查詢時:
filter.mightContain(id) == false → 100% 不存在,直接返回,連 Redis 都不用查
filter.mightContain(id) == true → 「可能」存在,才往下查參數校驗。`id = -1` 這種明顯不合法的值,在進到快取邏輯之前就該被擋掉。很多「穿透攻擊」其實只是缺少基本的輸入驗證。
某個超熱門商品的快取,TTL 到期的那一毫秒
① 5000 個並行請求同時查快取 → 全部 miss
② 5000 個請求同時衝向資料庫查同一筆資料
③ 資料庫瞬間被打爆
④ (如果撐過去)5000 個請求各自把結果寫回快取 —— 重複做了 4999 次白工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); // 再讀一次,此時應該已經有了
}存進快取的是一個包裝物件:
{ "data": {...}, "expireAt": 1754300000 }
查詢時:
① 一定拿得到(Redis 上沒有真正過期)
② 檢查 expireAt 是否已過
③ 若已過期 → 「先回傳舊資料」,同時開一個非同步任務去更新
→ 沒有任何請求需要等待,代價是短時間內會讀到稍舊的資料對於「首頁配置」「熱門排行」這種數量少但存取量極高的資料,直接不設 TTL,改由更新資料時主動刷新快取。這樣就沒有過期的瞬間。
系統啟動時預熱,一次寫入 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));這個更嚴重——所有快取瞬間消失,全部流量直擊資料庫,而資料庫的處理能力通常只有快取的幾十分之一。
穿透 —— 資料「本來就不存在」,快取永遠填不上
擊穿 —— 單一熱點 Key 剛好過期的那一瞬間
雪崩 —— 大量 Key 同時失效,或整個 Redis 掛掉限流演算法不用背——它們是一條演進線,每一個都在解決前一個的缺陷。
每分鐘最多 100 次。用一個計數器,每分鐘歸零。把一分鐘切成 6 個 10 秒的小格子,統計「最近 6 格」的總和。
時間往前走一格,就丟掉最舊的一格。請求先進桶排隊,然後以「絕對恆定」的速率流出。
桶滿了就拒絕。以固定速率往桶裡「放令牌」,桶有容量上限。
請求要先拿到令牌才能通過。
關鍵:閒置時令牌會累積起來
→ 突然來一波流量時,可以一口氣消耗掉存量令牌
→ 既有長期的平均速率限制,又能吸收短時突發RateLimiter 是「單機」限流。10 個 Pod 各限 100 QPS,總量其實是 1000 QPS。限流保護的是自己(別讓太多流量進來);熔斷保護的是自己和下游(別再打一個已經壞掉的服務)。
┌──────────┐ 失敗率超過閾值 ┌────────┐
│ CLOSED │ ───────────────→ │ OPEN │
│ (正常) │ │(跳閘) │
└──────────┘ └────┬───┘
↑ │ 等待一段時間
│ 測試成功 ↓
│ ┌──────────────┐
└──────────────────── │ HALF_OPEN │
測試失敗 → OPEN │(放少量測試) │
└──────────────┘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: 5minimumNumberOfCalls 太小 → 剛啟動時 2 次呼叫失敗就跳閘,服務永遠起不來slowCallDurationThreshold → 只看「失敗率」的話,一個「很慢但都會成功」的下游永遠不會觸發熔斷——而那正是第一張卡說的、最危險的那種故障回到第一張卡的關鍵問題:故障之所以會擴散,是因為所有功能共用同一個執行緒池。
名字來自船艙——船體被隔成多個防水艙,一個艙進水不會讓整艘船沉沒。
沒有隔離:
[ 所有請求 ] → 共用 200 條執行緒
會員服務變慢 → 200 條全被佔住 → 訂單、商品、首頁全部一起死
有隔離:
會員服務 → 專屬 20 條
商品服務 → 專屬 20 條
訂單服務 → 專屬 20 條
會員服務變慢 → 只有那 20 條被佔住 → 其他功能完全不受影響@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(); // 回傳一個安全的預設值
}重試看起來是最無害的防禦——失敗了再試一次而已。實際上它是最容易造成災難的一個。
下游服務因為負載過高開始變慢
→ 上游逾時,重試 3 次
→ 下游收到的流量變成 3 倍 ★
→ 更慢、更多逾時
→ 更多重試 → 流量繼續放大
→ 下游徹底崩潰
★ 而這一切發生在「下游本來只是有點吃力」的時候// ❌ 固定間隔:所有失敗的請求會「同時」重試
retry after 1s, 1s, 1s
// ⚠️ 指數退避:好一些,但同一批請求仍然同步
retry after 1s, 2s, 4s
// ✅ 指數退避 + jitter:把重試時間打散
retry after 1s±0.5s, 2s±1s, 4s±2sjitter(隨機抖動)是最容易被省略、卻最關鍵的一步。沒有它的話,一萬個同時失敗的請求會在 1 秒後「同時」再打一次——這跟原本的尖峰沒有差別。
熔斷器開啟時不應該再重試——那等於繞過了熔斷。Resilience4j 的裝飾順序(由外到內)是:
Retry → CircuitBreaker → RateLimiter → TimeLimiter → Bulkhead → 實際呼叫
重試在最外層 = 每次重試都會先問過熔斷器
熔斷器 OPEN 時,重試會立刻拿到 CallNotPermittedException 而不是真的去打下游這一頁講了六種機制。但每一種都有成本——全部套上去的系統,可能比完全不設防更難維護。
一個請求失敗時,你必須先釐清:是被限流擋掉、被熔斷器拒絕、被艙壁排隊逾時、走了降級邏輯,還是真的失敗了?每多一層防禦,就多一種「請求沒有正常完成」的可能。
minimumNumberOfCalls 太小 → 剛啟動就跳閘,服務永遠起不來熔斷器讓系統「不會掛掉」,但也讓「下游一直很慢」這件事變得不痛不癢。防禦機制應該搭配告警——熔斷器跳閘、限流被觸發、降級被執行,這些都該送出通知,而不是靜靜地讓系統帶病運行。
這一頁所有的機制,都在回答同一個問題:當依賴的東西不可靠時,我要怎麼「有限度地失敗」,而不是「無限度地一起死」。
| 問題 | 資料存在嗎 | 觸發情境 | 主要防禦 |
|---|---|---|---|
| 穿透 Penetration | ❌ 本來就不存在 | 查詢不合法或不存在的 id,快取永遠填不上 | 參數校驗 → 快取空值(短 TTL)→ 布隆過濾器 |
| 擊穿 Breakdown | ✅ 存在 | 單一熱點 Key 過期的那一瞬間,大量並行請求同時 miss | 分散式互斥鎖/邏輯過期/熱點 Key 不設 TTL |
| 雪崩 Avalanche | ✅ 存在 | 大量 Key 同時過期,或 Redis 整個掛掉 | TTL 加隨機抖動/多級快取/Redis 高可用/對 DB 限流 |
| 演算法 | 解決了什麼 | 還有什麼問題 | 實務 |
|---|---|---|---|
| 固定窗口計數器 | 最簡單,能限制總量 | 臨界問題:跨窗口的 2 秒內可能通過兩倍流量 | 只適合對精度要求不高的場景 |
| 滑動窗口 | 修掉臨界問題,統計永遠是「最近 N 秒」 | 格子越細越吃記憶體;仍無法平滑流量 | Sentinel 的預設實作 |
| 漏桶 Leaky Bucket | 輸出速率絕對恆定,下游壓力最穩 | 無法應付突發——有餘力也只能按固定速率放行 | 適合對下游速率有硬性要求時 |
| 令牌桶 Token Bucket | 閒置時累積令牌,可吸收短時突發 | 突發量受桶容量限制,需要調參 | ✅ 最常用:Guava RateLimiter、Resilience4j |
| 順序 | 元件 | 擋什麼 | 最容易設錯的地方 |
|---|---|---|---|
| ① 最外層 | Retry | 暫時性失敗 | 多層都重試 → 乘法放大;沒有 jitter → 同步重試尖峰 |
| ② | CircuitBreaker | 已經壞掉的下游,快速失敗 | minimumNumberOfCalls 太小 → 剛啟動就跳閘;忘了設 slowCallDurationThreshold |
| ③ | RateLimiter | 超過處理能力的流量 | 單機限流 × Pod 數 ≠ 全域限流 |
| ④ | TimeLimiter | 太久不返回的呼叫 | 沒設 = 前面所有防線都可能被繞過 |
| ⑤ 最內層 | Bulkhead | 限制並行數,避免故障擴散 | 執行緒池隔離成本高;信號量隔離無法中斷已在跑的呼叫 |
點擊卡片翻面查看答案,共 18 張。