iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
Software Development

學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構系列 第 15

[ Day 15 ] S3 癱瘓的彈性容錯 : 實作 Resilience4j 斷路器與降級(Fallback)機制

  • 分享至 

  • xImage
  •  

在前幾天的文章中,我們在微服務中建構了多道盾牌,從透過headObject 探針解決網路超時問題的 Double-Check Pattern,實作 Idempotency 與 Semaphore 併發控流、配置 AWS S3 Lifecycle 規則,低成本自動清理歷史版本與分段碎片,再到透過混沌工程(pfctl / Clumsy) 進行實體網路丟包與延遲。

然而現實狀況中,還有可能出現一個狀況,不是網路丟包 30%,而是 AWS S3 遠端機房斷線、或是遭遇嚴重的 503 SlowDown 限流崩潰,當 S3 徹底癱瘓時,我們的微服務會發生什麼事?又或是如果你的服務串接的地端DB出問題了,那又該怎麼處理?

今天,我們要了解分散式系統中最核心的保險絲 : Resilience4j 斷路器(Circuit Breaker)與降級(Fallback)機制!

沒有熔斷器時,S3 一躺平,整個微服務會一起崩

或許有人好其,我們已經限制了 Semaphore(50),不就代表 S3 壞掉時頂多只是 50 個請求卡住而已嗎?答案是也不是 :

沒有斷路器的連鎖崩潰過程

當遠端依賴服務(AWS S3)因為區域性大斷線、或遭遇暴增流量噴出 503 SlowDown 徹底癱瘓時,一個缺乏斷路保護的微服務會經歷以下連鎖崩潰過程:

  1. S3 遠端機房大斷線:所有連往 S3 的 putObject 請求全部卡死在 2000ms 超時
  2. Semaphore 通行證快速耗盡:第 1 到第 50 個請求全部卡在非同步等待中,5Semaphore 瞬間被佔滿
  3. 服務端持續重試、HEAD 探針、異常回收:接下來所有進來的上傳請求,全部被 Semaphore 秒拒(429 Too Many Requests)
  4. 執行緒池、記憶體、CPU 逐步被耗盡 : JVM 執行緒與記憶體持續燃燒,微服務不斷在背景進行重試與 HEAD 探針發射,Tomcat / Netty 執行緒池被長時間卡住。
  5. 連帶效應(雪崩):這台微服務裡其他的 API(例如:會員登入、首頁輪播圖、商品瀏覽)因為共享同一台伺服器的 CPU、記憶體與 Tomcat 執行緒池,整個微服務瞬間無預警卡死崩潰!

即使我們做了本地的 Semaphore 限流,也無法阻止遠端依賴服務持續失敗時,微服務資源被長時間無謂消耗所導致的雪崩。所以我們必須建立一個當遠端依賴持續失敗時,能秒級主動切斷流量的防火牆 ── 這就是 斷路器(Circuit Breaker) 的核心價值。

斷路器(Circuit Breaker)

斷路器的概念源自於我們家中的保險絲。它的核心原理是在微服務與遠端服務(AWS S3)之間架設一個狀態機監控器,實時統計呼叫的成功與失敗率。維服務中,斷路器會在背景以環形滑動視窗(Sliding Window) 統計過去一段時間(或過去 N 次)的呼叫狀態(如成功次數、失敗次數、慢呼叫比例、連線超時數量)一旦失敗率超過門檻,例如 50%,斷路器會立刻切換為 OPEN 狀態,保護後端依賴不再被繼續打爆。通常會有三種狀態:

1. 常閉狀態 (CLOSED - 正常通電)

  • 行為:所有請求正常通過微服務發往 AWS S3。
  • 統計:斷路器在背景以 環形滑動視窗(Sliding Window) 記錄過去 N次呼叫的結果。
  • 觸發:當滑動視窗內的失敗率(或慢呼叫率)超過預設閾值(例如 50%)時,斷路器判斷 S3 已經癱瘓,立刻跳閘切換至 OPEN 狀態!

2. 開路狀態 (OPEN - 跳閘斷電)

  • 行為:微服務完全不向 AWS S3 發射任何實體網路請求!所有發往 S3 的呼叫在 0.1ms 內直接被Fail-fast 攔截,拋出 CallNotPermittedException
  • 保護:這瞬間保護了我們的微服務 CPU、記憶體與 Netty 執行緒池,防止雪崩蔓延。
  • 降級 (Fallback):攔截後,微服務不會噴 500 報錯,而是立刻執行備用降級方法(Fallback),回傳友善的降級資訊或預設資料。

3. 半開狀態 (HALF_OPEN - 試探恢復)

  • 行為:在 OPEN 狀態維持預設的等待時間(例如10s)後,斷路器自動進入 HALF_OPEN。
  • 試探:斷路器僅放行極少量的試探請求(例如 3 個)去連線 S3:
    • 成功:代表 S3 已經修復,斷路器合閘恢復 CLOSED。
    • 失敗:代表 S3 還沒好,斷路器重新進入 OPEN,繼續跳閘保護。

https://ithelp.ithome.com.tw/upload/images/20260910/20183864dK6okSqx0B.jpg

降級 Fallback

經過斷路器攔截後,最重要的是絕對不要讓系統噴出讓人摸不著頭緒的 500 Internal Server Error 或崩潰堆疊(Stack Trace)。

在高可用微服務架構中,Fallback(降級)並非直接丟Exception,而是保留交易上下文,讓使用者與客戶端知道發生了什麼,並具備明確的重試指引。而在 S3 上傳場景中,當斷路器處於 OPEN 狀態時,理想的 Fallback 行為是:

  1. 回傳明確的 HTTP 狀態碼:HTTP 503 Service Unavailable(明確告知是下游服務暫時不可用,非程式碼 Bug)。
  2. 保留關鍵的 uploadId 交易上下文:告知客戶端目前雲端儲存服務繁忙,請妥善保存目前的 uploadId,切勿更換 UUID,稍後直接使用相同的 uploadId 進行安全重試。

這種 Fallback 設計讓系統在失敗時依然保有可讀性、可預測性與冪等可恢復性。

本地調整:Spring Boot + Java 整合 Resilience4j

現在,我們打開 Spring Boot 專案,為我們的非同步 S3 微服務裝上這道終極保險絲。

1. 引入 Maven 依賴 (pom.xml)

在 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 上。

2. 設定 breaker 規則,配置 YAML 斷路器參數 (application.yml)

我們在 app/src/main/resources/application.yml 裡補上設定檔,且要注意不是所有 Exception 都應該觸發熔斷跳閘!

  • 透過配置 ignoreExceptions,來避免前端傳入了一個不合法的 UUID,導致 Controller 拋出 IllegalArgumentException的問題,因為這屬於客戶端參數錯誤,遠端 AWS S3 根本沒壞!
  • 如果我們把本地參數錯誤也算進斷路器失敗率,惡意使用者只要連續發送 5 個錯的請求,就會把我們連往 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

3. Service 層整合 @CircuitBreaker 與 Fallback

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 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 持續崩潰達到閾值時斷路器跳閘,直接降級保護系統。

4. Controller 層:把 fallback 轉成 503

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
  1. 前 5 次呼叫(CLOSED 狀態:嘗試連線並記錄失敗)
  • 理論上剛開始斷路器不會被觸發,每次都進 Double-Check 流程
  • 應該要自我修復後,Head確認物件不存在後回傳500
  1. 第 6 次呼叫起(OPEN 狀態:觸發熔斷跳閘!)
  • 失敗率突破 50%,Resilience4j 瞬間跳閘,後續請求 0.1ms 內被攔截:
  • 因為觸發自動降級,所以應該回傳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也可以看出前五次都是異常,第六次開始因為觸發降級了!
https://ithelp.ithome.com.tw/upload/images/20260911/20183864XrWl0LGJS0.png

總結

到今天為止,我們為我們的非同步 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容器化與單機極限壓測相關的內容!


上一篇
[ Day 14 ] 初探混沌工程(Chaos Engineering ):本地端用 tc 製造網路延遲與封包遺失
下一篇
[ Day 16 ] 容器化服務 : 撰寫 Dockerfile 與多階段建置 (Multi-stage build) 環境隔離
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構17
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言