在前幾天的文章中,我們在微服務中建構了多道盾牌,從透過headObject 探針解決網路超時問題的 Double-Check Pattern,實作 Idempotency 與 Semaphore 併發控流、配置 AWS S3 Lifecycle 規則,低成本自動清理歷史版本與分段碎片,再到透過混沌工程(pfctl / Clumsy) 進行實體網路丟包與延遲。
然而現實狀況中,還有可能出現一個狀況,不是網路丟包 30%,而是 AWS S3 遠端機房斷線、或是遭遇嚴重的 503 SlowDown 限流崩潰,當 S3 徹底癱瘓時,我們的微服務會發生什麼事?又或是如果你的服務串接的地端DB出問題了,那又該怎麼處理?
今天,我們要了解分散式系統中最核心的保險絲 : Resilience4j 斷路器(Circuit Breaker)與降級(Fallback)機制!
或許有人好其,我們已經限制了 Semaphore(50),不就代表 S3 壞掉時頂多只是 50 個請求卡住而已嗎?答案是也不是 :
當遠端依賴服務(AWS S3)因為區域性大斷線、或遭遇暴增流量噴出 503 SlowDown 徹底癱瘓時,一個缺乏斷路保護的微服務會經歷以下連鎖崩潰過程:
即使我們做了本地的 Semaphore 限流,也無法阻止遠端依賴服務持續失敗時,微服務資源被長時間無謂消耗所導致的雪崩。所以我們必須建立一個當遠端依賴持續失敗時,能秒級主動切斷流量的防火牆 ── 這就是 斷路器(Circuit Breaker) 的核心價值。
斷路器的概念源自於我們家中的保險絲。它的核心原理是在微服務與遠端服務(AWS S3)之間架設一個狀態機監控器,實時統計呼叫的成功與失敗率。維服務中,斷路器會在背景以環形滑動視窗(Sliding Window) 統計過去一段時間(或過去 N 次)的呼叫狀態(如成功次數、失敗次數、慢呼叫比例、連線超時數量)一旦失敗率超過門檻,例如 50%,斷路器會立刻切換為 OPEN 狀態,保護後端依賴不再被繼續打爆。通常會有三種狀態:
CallNotPermittedException。
經過斷路器攔截後,最重要的是絕對不要讓系統噴出讓人摸不著頭緒的 500 Internal Server Error 或崩潰堆疊(Stack Trace)。
在高可用微服務架構中,Fallback(降級)並非直接丟Exception,而是保留交易上下文,讓使用者與客戶端知道發生了什麼,並具備明確的重試指引。而在 S3 上傳場景中,當斷路器處於 OPEN 狀態時,理想的 Fallback 行為是:
這種 Fallback 設計讓系統在失敗時依然保有可讀性、可預測性與冪等可恢復性。
現在,我們打開 Spring Boot 專案,為我們的非同步 S3 微服務裝上這道終極保險絲。
在 Spring Boot 3 環境下,我們引入 Resilience4j 的 Starter 與 Spring AOP 支援:
<dependencies>
<!-- Resilience4j Spring Boot 3 Starter -->
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>2.2.0</version>
</dependency>
<!-- Spring AOP (斷路器切面支援) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
</dependencies>
這樣 Spring AOP 就能把 @CircuitBreaker 切到目標 method 上。
我們在 app/src/main/resources/application.yml 裡補上設定檔,且要注意不是所有 Exception 都應該觸發熔斷跳閘!
ignoreExceptions,來避免前端傳入了一個不合法的 UUID,導致 Controller 拋出 IllegalArgumentException的問題,因為這屬於客戶端參數錯誤,遠端 AWS S3 根本沒壞!resilience4j:
circuitbreaker:
instances:
s3PutCircuitBreaker:
# 滑動視窗類型:計數型,統計最近 N 次呼叫
slidingWindowType: COUNT_BASED
slidingWindowSize: 10
# 觸發跳閘前的最少呼叫次數(不足此數不計算失敗率)
minimumNumberOfCalls: 5
# 失敗率閾值:10 次中失敗 5 次(50%)即跳閘
failureRateThreshold: 50
# 慢呼叫閾值:超過 2s 視為慢呼叫,慢呼叫率超過 60% 也會跳閘
slowCallRateThreshold: 60
slowCallDurationThreshold: 2s
# OPEN 狀態維持時間,10s 後自動轉為 HALF_OPEN 試探
waitDurationInOpenState: 10s
# HALF_OPEN 放行的試探請求數
permittedNumberOfCallsInHalfOpenState: 3
# 自動從 OPEN 轉為 HALF_OPEN,不需要手動觸發
automaticTransitionFromOpenToHalfOpenEnabled: true
# 只將遠端依賴/網路異常納入失敗率統計
recordExceptions:
- software.amazon.awssdk.core.exception.SdkException
- java.util.concurrent.TimeoutException
- java.lang.RuntimeException
# 忽略本地參數錯誤,避免前端壞請求誤觸保險絲
ignoreExceptions:
- java.lang.IllegalArgumentException
- java.lang.NullPointerException
Resilience4j 的 @CircuitBreaker 透過Spring AOP proxy運作。AOP proxy 只能攔截從外部 Bean 進來的呼叫,如果把 @CircuitBreaker 加在 S3Service 裡的某個 method,然後在同一個 class 內部用 this.method() 呼叫它,proxy 完全被繞過,斷路器永遠不會生效。
因此正確做法是建立一個獨立的 S3PutGateway Bean,讓 S3Service 透過 Spring 注入它:
// S3PutGateway.java — 獨立 Bean,讓 AOP proxy 能正確攔截
@Component
public class S3PutGateway {
@Autowired
private S3AsyncClient s3AsyncClient;
@CircuitBreaker(name = "s3PutCircuitBreaker", fallbackMethod = "s3PutObjectFallback")
public CompletableFuture<PutObjectResponse> putObject(
String bucket, String key,
PutObjectRequest putRequest, byte[] content,
String simulateScenario) {
if ("INBOUND_LOST".equalsIgnoreCase(simulateScenario)) {
return s3AsyncClient.putObject(putRequest, AsyncRequestBody.fromBytes(content))
.thenCompose(resp -> CompletableFuture.failedFuture(
new RuntimeException("Simulated TimeoutException (Inbound Lost)")));
} else if ("OUTBOUND_LOST".equalsIgnoreCase(simulateScenario)) {
return CompletableFuture.failedFuture(
new RuntimeException("Simulated Connection Timeout (Outbound Lost)"));
} else {
return s3AsyncClient.putObject(putRequest, AsyncRequestBody.fromBytes(content));
}
}
// Fallback 參數簽名必須與 putObject 完全一致,末尾多加一個 Throwable
public CompletableFuture<PutObjectResponse> s3PutObjectFallback(
String bucket, String key,
PutObjectRequest putRequest, byte[] content,
String simulateScenario,
Throwable throwable) {
System.err.println("[...] ⚡ [斷路器觸發降級] 原因: " + throwable.getMessage());
return CompletableFuture.failedFuture(throwable);
}
}
Spring AOP Proxy 是 Spring 框架在你的 Bean 前面自動插入的一層包裝物件。當你在 method 上加了 @CircuitBreaker、@Transactional、@Cacheable 等注解,Spring 不會直接給你原本的 S3PutGateway 物件,而是給你一個自動產生的子類別,它長這樣:
呼叫 s3PutGateway.putObject(...)
↓
[Spring AOP Proxy] ← Spring 自動插入的中間層
先執行 @CircuitBreaker 的邏輯(檢查斷路器狀態)
斷路器 CLOSED → 放行,呼叫真正的 putObject
斷路器 OPEN → 攔截,直接呼叫 fallback
↓
真正的 S3PutGateway.putObject(...)
我們無法將因為S3PutGateway和S3Service寫在一隻程式碼裡面,要分開的原因就是 this.s3PutObject() 直接存取的是原始物件,完全繞過了 Spring 幫你包裝的那層 proxy,注解上的邏輯永遠不會執行。
換言之,要從S3Service 注入 S3PutGateway 並呼叫它,AOP proxy 才能正確攔截:
@Autowired
private S3PutGateway s3PutGateway;
// uploadAsyncProtected 裡
return s3PutGateway.putObject(bucket, key, putRequest, content, simulateScenario)
.thenApply(response -> {
System.out.println("[...] ✅ [PUT 成功] 直接返回 success=true");
return ProtectedUploadResult.success(key);
})
.exceptionallyCompose(throwable -> {
Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;
// 斷路器跳閘:直接降級,不做 HEAD Double-Check
if (cause instanceof CallNotPermittedException) {
String msg = "⚡ [系統自動降級] AWS S3 服務暫時不可用,斷路器已啟動保護。請保存 uploadId 並稍後再試。";
return CompletableFuture.completedFuture(ProtectedUploadResult.circuitBreakerOpen(key, msg));
}
// 一般網路異常:進入 Double-Check 自癒流程
// ...(原有的 HEAD 邏輯)
});
這個設計讓斷路器跳閘和 Double-Check 自我修復各司其職,互不干擾,S3 短暫異常時進 Double-Check 嘗試救回;S3 持續崩潰達到閾值時斷路器跳閘,直接降級保護系統。
return uploadFuture.handle((result, error) -> {
try {
if (error != null) {
return ResponseEntity.internalServerError().body(
"Upload failed; retry with the same uploadId");
}
// 斷路器跳閘降級:回傳 503
if (result.fallbackMessage() != null && !result.fallbackMessage().isBlank()) {
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
.body(result.fallbackMessage());
}
if (!result.success()) {
return ResponseEntity.internalServerError().body(
"Upload failed; retry with the same uploadId");
}
String message = result.confirmedByHead()
? "Upload success (confirmed by S3 HEAD): " + normalizedUploadId
: "Upload success: " + normalizedUploadId;
return ResponseEntity.ok(message);
} finally {
uploadSemaphore.release();
}
});
啟動 Spring Boot 服務,打開終端機。直接用迴圈帶入 imulateScenario=OUTBOUND_LOST連續對 API 發射 10 次請求,並列出請求時間:
for i in {1..10}; do
echo "=== 第 $i 次 ==="
curl -is -o /dev/null -w "HTTP %{http_code} | 耗時 %{time_total}s\n"\
-X POST \
-H "uploadId: $(uuidgen | tr '[:upper:]' '[:lower:]')" \
-F "file=@pom.xml" \
"http://localhost:8080/api/s3/async/upload/protected?simulateScenario=OUTBOUND_LOST"
echo ""
done
500
503提醒AWS S3 服務暫時不可用=== 第 1 次 === HTTP 500 | 耗時 1.323999s ← 等 S3 模擬超時 + HEAD 探針
=== 第 2 次 === HTTP 500 | 耗時 0.265944s ← 等 HEAD 探針
=== 第 3 次 === HTTP 500 | 耗時 0.275518s
=== 第 4 次 === HTTP 500 | 耗時 0.271008s
=== 第 5 次 === HTTP 500 | 耗時 0.517822s
=== 第 6 次 === HTTP 503 | 耗時 0.006550s ← 斷路器跳閘,直接攔截
=== 第 7 次 === HTTP 503 | 耗時 0.004027s
=== 第 8 次 === HTTP 503 | 耗時 0.003245s
=== 第 9 次 === HTTP 503 | 耗時 0.002943s
=== 第 10 次 === HTTP 503 | 耗時 0.002492s
會發現十次請求的時間從斷路器跳閘攔截後,大幅減少,跳閘前每次請求耗時 270ms~1.3s(等待 S3 模擬超時加上 HEAD 探針往返),跳閘後降到 2.5~6.5ms。這個差距在高併發下會被放大幾十倍——50 個 Semaphore permit 原本全部卡死在等待,跳閘後瞬間全部釋放,其他 API 立刻恢復正常。
等待約 10 秒後,斷路器自動進入 HALF_OPEN,放行 3 個試探請求;若 S3 恢復正常,斷路器合閘回 CLOSED,服務自動恢復。
從log也可以看出前五次都是異常,第六次開始因為觸發降級了!
到今天為止,我們為我們的非同步 S3 微服務築起了應用程式層面無懈可擊的 「四重鋼鐵防線」:
| 防禦層級 | 採用技術 | 解決的核心痛點 | 狀態回應 |
|---|---|---|---|
| 第一層 | Semaphore(50) 門閥 | 限制本地併發,避免頻寬飽和與 503 限流 | HTTP 429 Too Many Requests |
| 第二層 | Double-Check (HEAD) 探針 | 薛丁格超時自癒,避免重複上傳與孤兒檔案 | HTTP 200 OK |
| 第三層 | uploadId 冪等性令牌 | 保證客戶端重試 100 次也絕不產生髒資料 | 覆蓋同一 Key,0 垃圾檔案 |
| 第四層 | Resilience4j 斷路器 | S3 徹底癱瘓時秒級跳閘降級,杜絕雪崩效應 | HTTP 503 Service Unavailable |
我們從最簡單的 S3 呼叫,一步步演進到具備自我修復、防呆、背壓與熔斷能力的企業級微服務。分散式一致性與惡劣網路的考驗,到此正式完美結束!
但是,這套微服務目前依然只跑在我們本地的 IDE 裡面。當多人協作或是要部署到雲端生產環境時,如何告別在我電腦可以跑,上線就炸掉的噩夢?還有如何將它打包成一個極輕量容器?明天,我們將正式進入下個階段,來介紹Docker容器化與單機極限壓測相關的內容!