iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

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

[ Day 20 ] 讀寫保護大不同 - ListObject API 加上 Semaphore 是反效果?快取機制才是王道

  • 分享至 

  • xImage
  •  

昨天我們深入剖析了 Timeout 設定與容量規劃的數學公式,證明了盲目放寬 Timeout 只會讓連線池乾涸。

今天,我們要將壓力測試的視角從PUT上傳轉向listObjects 查詢 API,帶大家實作保護型的listObject api,比較「無保護版本」與「具備 Resilience4j 斷路器 + Semaphore 限流的保護版本」在不同壓力條件下的行為差異,揭露當高併發遇上 Netty 連線池上限時,斷路器是如何扮演壓力閥救回系統的!

保護型 listObjects API 規劃

在 S3 的 API 體系中,列出 Bucket 內物件看似只是一個讀取(GET)操作,但它在生產環境中卻是記憶體與錢包殺手,原因很簡單:

  1. 記憶體風險(JVM Heap 爆掉):如果 Bucket 內有 100 萬個檔案,未加限制的 listObjects 會嘗試把 100 萬筆 Metadata 載入 Java 記憶體,直接引發 OOM (Out Of Memory)。當然目前都會有1000筆資料的
  2. S3 Class A 請求費用高昂:AWS S3 對 List/Put 等 Class A 請求的計費比普通 Get 讀取貴 12 倍!未受限的 List 狂轟會讓雲端帳單瞬間失控。

不加入 Semaphore

在壓測實驗中,我們發現當對 listObjects 套用 Semaphore(50) 時,1000 個併發請求有 950 個直接被 429 擋掉。這看似保護了系統,卻暴露出了三個致命的架構盲點:

1. 讀取操作無副作用 (Side-effect Free)

  • PUT 上傳寫入:具有副作用!如果發生 Timeout 或中斷,會產生孤兒檔案、重複碎片或資料不一致。因此必須用 Semaphore 嚴格控流。
  • List 檔案列表100% 無副作用(冪等且純讀取)!List 失敗最壞情況就是本次查詢沒拿到,不會在 S3 或資料庫寫入任何髒資料,保護層級與寫入完全不同。

2. 結果具備極高可快取性 (Cacheability)

  • PUT 每次都是新的資料:無法跨請求共用快取。
  • List 結果在短時間內高度重疊:1000 個使用者在 1 秒內呼叫 List,看到的檔案列表幾乎完全一樣!如果加上 10 秒的快取(Cache),這 1000 個併發請求在實際上只會打向 S3 1 次! 這比用 Semaphore 硬生生把 950 個人拒之門外(429)要高效且優雅數百倍!

3. Semaphore 會遮蔽斷路器 (Circuit Breaker) 的真實行為

  • 當 Semaphore(50) 攔截了 95% 的流量時,後方的 Resilience4j 斷路器根本看不到足夠的 S3 流量與真實延遲。
  • 這種過度防禦掩蓋了 S3 API 在高併發下的真實回應曲線,導致壓測結果失真。

listObject API 實作

Service 層非同步 List 實作 ()

我們在service資料夾下,新增一個ListGetway.java的檔案,作為獨立操作的Gateway Bean,邏輯與day說明的 S3PutGateway 一樣 @CircuitBreaker 必須放在獨立的 Bean。

package com.example.s3service.service;

import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.services.s3.S3AsyncClient;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Request;
import software.amazon.awssdk.services.s3.model.ListObjectsV2Response;

import java.util.concurrent.CompletableFuture;

@Component
public class S3ListGateway {

    @Autowired
    private S3AsyncClient s3AsyncClient;

    /**
     * 實際發出 S3 listObjectsV2 的 method,由 Resilience4j @CircuitBreaker 包裝。
     *
     * 斷路器統計此 method 的失敗率:
     * - 記錄:SdkException(S3 網路/服務異常)與 RuntimeException
     * - 忽略:IllegalArgumentException / NullPointerException(本地參數錯誤)
     *
     * 當失敗率超過 YAML 設定的閾值時,斷路器跳閘,後續呼叫直接觸發 fallback。
     */
    @CircuitBreaker(name = "s3ListCircuitBreaker", fallbackMethod = "s3ListObjectsFallback")
    public CompletableFuture<ListObjectsV2Response> listObjects(String bucket) {
        ListObjectsV2Request request = ListObjectsV2Request.builder().bucket(bucket).build();
        return s3AsyncClient.listObjectsV2(request);
    }

    /**
     * 斷路器跳閘時的 Fallback method。
     * 參數簽名必須與 listObjects 完全一致,末尾多加一個 Throwable。
     * 回傳 failedFuture,讓 S3Service 的 exceptionallyCompose 接手判斷。
     */
    public CompletableFuture<ListObjectsV2Response> s3ListObjectsFallback(
            String bucket,
            Throwable throwable) {
        boolean isCircuitOpen = throwable instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException;
        if (isCircuitOpen) {
            System.err.println("[" + Thread.currentThread().getName()
                    + "] ⚡ [斷路器 OPEN] List 請求被拒絕: " + throwable.getMessage());
        } else {
            System.err.println("[" + Thread.currentThread().getName()
                    + "] 📊 [斷路器統計失敗] List 失敗原因: " + throwable.getMessage());
        }
        return CompletableFuture.failedFuture(throwable);
    }
}

並且在 S3Service.java中補充加入:


@Autowired
    private S3ListGateway s3ListGateway;
...

 /**
     * 受保護 List 的結果。
     *
     * success=true 代表 List 成功。
     * fallbackMessage 不為 null 代表斷路器已跳閘,直接降級,未發出任何 S3 請求。
     */
    public record ProtectedListResult(
            List<String> keys,
            boolean success,
            String fallbackMessage) {

        /** 正常成功 */
        public static ProtectedListResult success(List<String> keys) {
            return new ProtectedListResult(keys, true, null);
        }

        /** 一般失敗(S3 回傳錯誤) */
        public static ProtectedListResult failed() {
            return new ProtectedListResult(null, false, null);
        }

        /** 斷路器跳閘降級 */
        public static ProtectedListResult circuitBreakerOpen(String message) {
            return new ProtectedListResult(null, false, message);
        }
    }
    
    /**
     * 非同步保護型列舉(帶有 Resilience4j 斷路器)。
     *
     * 當 S3 連續失敗超過閾值時,Resilience4j 斷路器跳閘(OPEN),
     * 後續請求直接被 s3ListObjectsFallback 攔截,回傳 503 降級訊息,
     * 不再對 S3 發射任何實體請求。
     *
     * List 是讀取操作,不需要 Double-Check 自癒(不像 PUT 存在「成功但
     * 回應遺失」的問題),也不需要 Semaphore 限流(無狀態且無副作用)。
     */
    public CompletableFuture<ProtectedListResult> listFilesAsyncProtected(String bucket) {
        System.out.println("[" + Thread.currentThread().getName()
                + "] 🚀 [LIST 啟動] 開始安全非同步列舉,Bucket: " + bucket);

        return s3ListGateway.listObjects(bucket)
                .thenApply(response -> {
                    List<String> keys = response.contents().stream()
                            .map(S3Object::key)
                            .collect(Collectors.toList());
                    System.out.println("[" + Thread.currentThread().getName()
                            + "] ✅ [LIST 成功] 共 " + keys.size() + " 個物件");
                    return ProtectedListResult.success(keys);
                })
                .exceptionally(throwable -> {
                    Throwable cause = throwable.getCause() != null ? throwable.getCause() : throwable;

                    if (cause instanceof io.github.resilience4j.circuitbreaker.CallNotPermittedException) {
                        System.err.println("[" + Thread.currentThread().getName()
                                + "] ⚡ [斷路器跳閘] " + cause.getMessage());
                        String msg = "⚡ [系統自動降級] AWS S3 服務暫時不可用,斷路器已啟動保護。請稍後再試。";
                        return ProtectedListResult.circuitBreakerOpen(msg);
                    }

                    System.err.println("[" + Thread.currentThread().getName()
                            + "] ❌ [LIST 失敗] " + cause.getMessage());
                    return ProtectedListResult.failed();
                });
    }

Controller 層宣告 API ()

加入屬於list的背壓限制,並且新增一個/async/files/protected的有保護的list api


/**
 * List 是無副作用的讀取操作,不需要 Semaphore 限流。
 * 依賴斷路器在 S3 持續失敗時快速降級,回傳 503;
 * 一般列舉失敗回傳 500。
 */
@GetMapping("/async/files/protected")
public CompletableFuture<ResponseEntity<?>> listFilesAsyncProtected() {
    return s3Service.listFilesAsyncProtected(bucketName)
        .thenApply(result -> {
            if (result.fallbackMessage() != null && !result.fallbackMessage().isBlank()) {
                return ResponseEntity.status(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE)
                    .<Object>body(result.fallbackMessage());
            }
            if (!result.success()) {
                return ResponseEntity.internalServerError()
                    .<Object>body("List failed; please retry later");
            }
            return ResponseEntity.ok().<Object>body(result.keys());
        });
    }

resilience4j設定檔調整

進入resources/application.yml檔案中,在s3底下加入list參數設定,以及在resilience4j底下和s3PutCircuitBreaker同一層的地方加入s3ListCircuitBreaker相關的設定檔


resilience4j:
  circuitbreaker:
    instances:
      s3PutCircuitBreaker:
        ....
      s3ListCircuitBreaker:
        slidingWindowType: COUNT_BASED
        slidingWindowSize: 20
        minimumNumberOfCalls: 10
        failureRateThreshold: 50
        slowCallRateThreshold: 80
        slowCallDurationThreshold: 15s
        waitDurationInOpenState: 15s
        permittedNumberOfCallsInHalfOpenState: 3
        automaticTransitionFromOpenToHalfOpenEnabled: true
        recordExceptions:
          - software.amazon.awssdk.core.exception.SdkException
          - java.util.concurrent.TimeoutException
          - java.lang.RuntimeException
        ignoreExceptions:
          - java.lang.IllegalArgumentException
          - java.lang.NullPointerException

Jmeter 壓力測試

Jmeter list protected api

我們只要複製原本的非同步的list api,然後將url最後加入protected即可
https://ithelp.ithome.com.tw/upload/images/20260915/201838642wgb7wMXPW.png

我們實驗考慮兩種狀況 :

  1. 正常無延遲狀況
  • 系統餘裕充沛,無保護與有保護 API 的錯誤率均為 0.00%。
  • 證明在平時正常狀況下,防禦層完全不會帶來額外負擔。
  1. 注入 2 秒網路延遲 (Chaos Engineering):
  • 當我們透過混沌工具注入 2 秒網路延遲後,併發壓力拉大時,無保護 API 的連線佇列開始超時,錯誤率開始噴出!
  • 而有保護的 API 則展現出了強大的平滑穩定度與自癒能力!

實驗結果

我們在容器環境下(1.5 核 CPU / 512MB RAM / Timeout 15s),針對 listObjects 進行不同併發與 2s 混沌延遲下的實彈測試:

測試情境 API 類型 總請求數 (#Samples) 平均延遲 (Avg) 最大延遲 (Max) 標準差 (Std. Dev.) 錯誤率 (Error %) 吞吐量 (Throughput)
正常無延遲環境 無保護 vs 有保護 500 次 ~200ms ~500ms 極小 0.00% vs 0.00% ~50.0/sec
混沌延遲 2s (併發 100, 重複 5 次) 無保護 (裸奔) 500 次 3,845 ms 15,017 ms 2,825 ms 1.00% 15.8/sec
混沌延遲 2s (併發 100, 重複 5 次) 保護型 API 500 次 3,780 ms 15,014 ms 2,760 ms 0.40% 15.5/sec
混沌延遲 2s (併發 200, 重複 5 次) 無保護 (裸奔) 1000 次 6,162 ms 15,020 ms 2,420 ms 2.00% 23.3/sec
混沌延遲 2s (併發 200, 重複 5 次) 保護型 API 1000 次 5,051 ms 9,579 ms 966 ms 0.00% 31.2/sec

Grafana

這張圖紀錄延遲兩秒的狀況下,並發200重複五次,第一個高峰是沒有保護的狀況,第二個高峰則是有保護api列出資料的狀況:

https://ithelp.ithome.com.tw/upload/images/20260915/20183864BH8rfVzvJs.png

  1. 錯誤率從 2.00% 降至 0.00% :
    • 在 200 併發 + 2s 延遲衝擊下,無保護 API 因為超時堆積導致 2% 請求失敗(15s 硬超時)。
    • 保護型 API 透過回應時間優化與資源調配,將錯誤率完美壓回 0.00%,吞吐量更是提升了34%(23.3/s ➔ 31.2/s)!
  2. 波動度大幅下降 (標準差 2420ms ➔ 966ms):
    • 保護型 API 的標準差(Std. Dev.)降到了 1 秒以內,最大延遲從 15 秒降至 9.57 秒,代表系統的響應時間極度可預測且穩定。
  3. Grafana 硬體指標解密:
    • JVM Heap:最高僅使用 242 MiB(目前 103 MiB,距離上限 384 MiB 餘裕充足)。
    • CPU Usage:Process CPU 峰值僅 15.92%,證實算力充沛,網路 I/O 與超時佇列才是真正的瓶頸。
    • Threads:Live 執行緒數穩定在 35~43 個,證明 Netty EventLoop 沒有發生執行緒飢餓。

讀取 API 的終極防線:快取 (Cache)

雖然本系列受限於篇幅不直接撰寫快取程式碼,但理解快取的架構定位是不可或缺的知識:

1. 本地快取 (In-Memory Local Cache)

  • 代表工具Caffeine / Guava Cache / Spring @Cacheable
  • 運作原理:直接將 S3 List 回傳的 JSON 結果存在 JVM 記憶體內。
  • 優勢:回應時間從 S3 遠端呼叫的 200ms 直接降至 0.1ms(納秒級),吞吞量瞬間翻倍!

2. 分散式快取 (Distributed Cache)

  • 代表工具Redis / Memcached
  • 運作原理:將 List 結果存在獨立的 Redis 伺服器中。
  • 優勢:當未來我們的微服務擴展成 K8s 10 個 Pod 時,所有 Pod 可以共享同一份 S3 List 快取,避免每個 Pod 都重複去拉取 S3。

3. 快取擊穿 (Cache Stampede / Singleflight) 防護

  • 情境:當 10 秒快取到期(Expire)的瞬間,正好有 1000 個併發衝進來。
  • 解決方案:透過 Singleflight 模式(單飛互斥鎖),只允許「第 1 個請求」去 S3 拉取最新列表並更新快取,其餘 999 個請求等待這 1 個請求完成後直接讀取新快取。

總結

經過這幾天的極限壓測與混沌實驗,我們歸納出了雲端原生微服務最核心的八字真言:寫入靠背壓 (Backpressure),讀取靠快取 (Cache)!

  • 對於 PUT 上傳重型寫入:使用 Semaphore 控流 + Resilience4j 斷路器 + HEAD 探針自癒,鎖死硬體邊界,確保不產生髒資料。
  • 對於 List 查詢重型讀取:使用 Cache 吸收 99% 的重複流量,避免防禦機制過度干擾讀取體驗。

明天先將專案放一邊,一起來思考一下不同的程式語言間會有哪些效能上或底層架構上的差異呢?了解為什麼非同步不是萬靈丹?非同步處理方式也能適用在node.js或python這些高階程式語言上嗎?


上一篇
[ Day 19 ] 記憶體與連線池殺手 : 放寬timeout限制會降低錯誤率還是會更卡 ?
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言