iT邦幫忙

2026 iThome 鐵人賽

DAY 11
0

昨天我們說明了網路世界最殘酷的一面。當微服務呼叫 AWS S3 上傳檔案時,如果發生 TimeoutException時不能直接斷言上傳一定失敗,也有可能是上傳成功但回傳封包失敗又或是等待時間太久連線不上。

而今天的內容我們將實作昨天提到的最終一致性!將延續 Day 8 從引擎到微服務:用 Spring Boot 同時封裝 Apache 與 Netty S3 API 的程式碼,在 Spring Boot 環境中,實作headObject來達到分散式防禦的第一個核心 ── Double-Check Pattern(狀態二次確認模式)。

Double-Check Pattern

昨天也有提到在實務中只寫try...catch...是不夠的,很多程式碼是在網路 100% 正常、毫無延遲的溫室環境下看起來完全沒有問題。但在真實的惡劣網路(如高延遲、封包遺失)中,當微服務收到 TimeoutException 時,程式會無情地進入 catch 區塊,回傳 500 Internal Server Error 給前端。使用者看到失敗,理所當然會重新整理、再次點擊上傳。
https://ithelp.ithome.com.tw/upload/images/20260904/20183864gNQ8R2qub5.jpg

結果就是 S3 bucket堆滿了重複的、沒有任何資料庫記錄關聯的孤兒檔案(Orphan Files),增加無謂的成本,面對Timeout的正確做法絕對不是一刀切地回傳失敗,而是主動發起狀態二次確認。微服務必須搞清楚自己當前面對的到底是「成功、失敗、還是未知」,這就是 Double-Check Pattern。

其核心概念非常樸實且優雅:

  1. 先執行主要操作:putObject
  2. 如果主要操作明確成功,直接回傳成功
  3. 如果主要操作發生 timeout 或未知網路錯誤,執行第二次確認
  4. 呼叫 headObject 查詢檔案是否真的存在
  5. 根據 HEAD 的結果判斷最終狀態

https://ithelp.ithome.com.tw/upload/images/20260904/20183864kcFdTXHJNA.jpg

為什麼要使用headObject ?

當微服務遇到 TimeoutException 或其他未知的網路中斷時,我們需要去確認該檔案在 S3 雲端到底存在與否。有些人或許會直覺的呼叫下載(getObject)看有沒有 404 ,或是直接重新 putObject(上傳)一次覆蓋它。或許簡單的服務沒問題,但遇到高併發或大檔案的情境就出事了

  • 重新 putObject:會再次耗費寶貴的本地對外上傳頻寬,傳輸重複的二進位大檔案,雪上加霜
  • 呼叫 getObject:會將整個檔案實體拉回微服務的記憶體中。如果檔案是 50MB,高併發打進來時,微服務的網卡和 JVM Heap 記憶體會瞬間被榨乾

我們不需要重新下載整個檔案,也不需要再次執行一次完整的 PUT,因為太耗費效能了。反觀使用headObject(等同於 HTTP 的 HEAD 方法)是最好的選擇,只會向S3查詢物件的 metadata,例如:

  • 物件是否存在(存在返回 200,不存在返回 404)
  • 檔案大小 (Content-Length)
  • 唯一雜湊值 (ETag)
  • 檔案類型 (Content-Type)

headObject 完全不傳輸任何檔案實體內容(Payload-free),不論你的檔案是 100KB 還是 10GB,它不會把檔案內容下載回來,因此比重新執行上傳或下載檔案輕量許多

HeadObjectRequest request = HeadObjectRequest.builder()
        .bucket(bucket)
        .key(uploadId)
        .build();

return s3AsyncClient.headObject(request);

Service 層實作 Double-Check

現在,我們打開專案中的 S3Service.java,實作這個具備安全自我修復能力的非同步上傳流程。

定義防禦結果記錄 (Result Record)

先定義一個結果物件:

public record ProtectedUploadResult(
        String uploadId,         // 這次上傳使用的唯一識別碼
        boolean success,         //	最終是否判定上傳成功
        boolean confirmedByHead  // 是否是透過 HEAD 二次確認成功
) {
}

Service 核心程式碼

在 S3Service 中使用 exceptionallyCompose 串聯且特別設計了一個 simulateScenario(故障注入場景參數),來實作 CompletableFuture:

@Autowired
    private S3AsyncClient s3AsyncClient;

   /**
     * 非同步保護型上傳 帶有 Double-Check 自我修復與超時模擬
     */
    public CompletableFuture<ProtectedUploadResult> uploadAsyncProtected(
            String bucket, String key, byte[] content, String simulateScenario) {

        PutObjectRequest putRequest = PutObjectRequest.builder()
                .bucket(bucket)
                .key(key)
                .build();

        String startThread = Thread.currentThread().getName();
        System.out.println("[" + startThread + "] 🚀 [PUT 啟動] 開始安全非同步上傳,Key: " + key + " (模擬情境: " + simulateScenario + ")");

        // 1. 執行非同步 putObject 任務
        CompletableFuture<PutObjectResponse> putFuture;

        if ("INBOUND_LOST".equalsIgnoreCase(simulateScenario)) {
            // 💡 情況 B & C(回程遺失/處理中超時):我們先偷偷把檔案傳上去 S3 (確保 S3 真的收到了)!
            // 然後,我們「故意」拋出一個網路超時異常,強迫微服務在背景啟動 Double-Check 自我修復!
            putFuture = s3AsyncClient.putObject(putRequest, AsyncRequestBody.fromBytes(content))
                    .thenCompose(resp -> CompletableFuture.failedFuture(
                            new RuntimeException("Simulated TimeoutException (Inbound Lost)")
                    ));
        } else if ("OUTBOUND_LOST".equalsIgnoreCase(simulateScenario)) {
            // 💡 情況 A(去程遺失):完全不連線 S3 傳輸,直接拋出例外,模擬封包根本沒抵達東京機房
            putFuture = CompletableFuture.failedFuture(
                    new RuntimeException("Simulated Connection Timeout (Outbound Lost)")
            );
        } else {
            // 正常無干擾上傳
            putFuture = s3AsyncClient.putObject(putRequest, AsyncRequestBody.fromBytes(content));
        }

        // 2. 利用 CompletableFuture 串聯自我修復
        return putFuture
                .thenApply(response -> {
                    // 【正常成功】:收到 S3 的 200 OK,正常返回結果
                    System.out.println("[" + Thread.currentThread().getName() + "] ✅ [PUT 成功] 直接返回 success=true");
                    return new ProtectedUploadResult(key, true, false);
                })
                .exceptionallyCompose(throwable -> {
                    // 【自我修復啟動!】:PUT 發生任何異常,立刻發射 HEAD 偵察兵
                    System.out.println("[" + Thread.currentThread().getName() + "] ⚠️ [PUT 異常] 觸發自我修復,啟動 HEAD Double-Check...");

                    HeadObjectRequest headRequest = HeadObjectRequest.builder()
                            .bucket(bucket)
                            .key(key)
                            .build();

                    // 呼叫極輕量的 headObject 探針核對 S3 遠端實際狀態
                    return s3AsyncClient.headObject(headRequest)
                            .thenApply(headResponse -> {
                                // 🌟 [自我修復成功!]:雖然 PUT 報超時了,但 HEAD 成功在 S3 撈到了檔案!
                                System.out.println("[" + Thread.currentThread().getName() + "] 🎉 [自我修復成功] HEAD 二次確認:檔案已安然躺在 S3!");
                                return new ProtectedUploadResult(key, true, true);
                            })
                            .exceptionally(headEx -> {
                                // ❌ [自我修復失敗]:S3 拋回 404 NoSuchKey,代表檔案真的沒上傳成功
                                System.err.println("[" + Thread.currentThread().getName() + "] ❌ [自我修復失敗] HEAD 回報物件不存在!檔案確實遺失。");
                                return new ProtectedUploadResult(key, false, false);
                            });
                });
    }

Controller 層回傳結果

現在,我們在 S3Controller.java 中開發全新的 API。允許透過 Query 參數傳入 simulateScenario,讓前端或 cURL 可以安全、可重複地在本地驗證我們設計的流程:

 @PostMapping("/async/upload/protected")
    public CompletableFuture<ResponseEntity<String>> uploadAsyncProtected(
            @RequestHeader("X-Upload-ID") String uploadId,
            @RequestParam(value = "simulateScenario", defaultValue = "NORMAL") String simulateScenario,
            @RequestParam("file") MultipartFile file) throws IOException {

        byte[] fileBytes = file.getBytes();

        // 呼叫自我修復上傳引擎
        return s3Service.uploadAsyncProtected(bucketName, uploadId, fileBytes, simulateScenario)
                .thenApply(result -> {
                    if (result.success()) {
                        if (result.confirmedByHead()) {
                            // 自我修復成功返回:HTTP 200 (透過 HEAD 二次確認成功)
                            return ResponseEntity.ok(
                                    "Upload success (confirmed by S3 HEAD): " + result.uploadId()
                            );
                        } else {
                            // PUT 直接成功返回:HTTP 200
                            return ResponseEntity.ok(
                                    "Upload success: " + result.uploadId()
                            );
                        }
                    } else {
                        // 徹底失敗:HTTP 500,並溫和地提醒前端使用同一個 uploadId 進行安全重試
                        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                                .body("Upload failed; please retry with the same uploadId: " + result.uploadId());
                    }
                });
    }

uploadId 必須是合法的 UUID

uploadId 會直接作為 S3 Object Key,所以在進入限流與上傳流程之前,Controller 會先用 UUID.fromString(uploadId) 驗證格式:

final String normalizedUploadId;
try {
    normalizedUploadId = UUID.fromString(uploadId).toString();
} catch (IllegalArgumentException ex) {
    return CompletableFuture.completedFuture(
            ResponseEntity.badRequest().body("uploadId must be a valid UUID"));
}

只要 Header 傳進來的字串不是合法 UUID 格式(例如打錯字、隨便傳一個檔名當 uploadId),就會直接回傳:

HTTP 400 Bad Request
uploadId must be a valid UUID

驗證放在最前面,是為了避免不合法的 key 進到限流與 S3 呼叫流程,浪費 permit 與網路資源。至於後面接著出現的併發限流(Semaphore、429)與 permit 釋放時機,屬於明天要細講的內容,這裡先不展開。

uploadId 該由誰產生?

uploadId 不是伺服器自動產生的,而是由呼叫端(前端或上游服務)在發起請求前就先產生好,並透過 Header 帶進來。Controller 只負責驗證格式,不負責產生。

原因跟這個 pattern 的目的有關:uploadId 扮演的其實是冪等鍵(Idempotency Key)的腳色,不是單純的檔名,詳細明天的章節會說明。現在只有知道如果改成伺服器自動產生,重試時就會拿到不同的 uploadId,變成兩個不同的 S3 Key,Double-Check 也就失去意義了!

所以正確的做法是:同一次使用者操作,重試時必須沿用同一個 uploadId,這件事只有呼叫端自己知道「這是不是同一次操作的重試」,伺服器無法從單一 request 判斷。實務上常見的產生位置:

  • 前端產生(最常見):使用者按下上傳時,前端用 crypto.randomUUID() 產生一個 uploadId 並存到這次操作的 state 裡;逾時重試就重用同一個值再打一次。
  • 呼叫端服務產生:微服務對微服務呼叫時,由上游服務在發起請求前先產生好,retry/backoff 邏輯也在上游服務那邊維護同一個值。

為什麼不在這裡寫 JUnit / Mockito 測試?

或許寫過java或spring專案的人會問,既然要測試超時,為什麼不用 Mockito 寫個 JUnit 單元測試,而非得在實體 API 裡面寫 simulateScenario 參數呢?

當我們在實測故障注入(Fault Injection)與整合測試(Integration Testing)時通常會有下列考量:

  1. Mockito 的盲點:Mockito 單體測試只能模擬「我們自己寫的 Java 程式分支對不對」(例如:我們有沒有去呼叫 headObject)。但它阻斷了真正的網路。它無法驗證我們跟 AWS S3 遠端機房之間的真實 TCP 連線、IAM 權限簽章、以及 S3 實體伺服器拋回 404/200 的真實物理行為。
  2. 故障注入的威力: 我們在 API 裡設計 simulateScenario 參數,是一種在混亂工程中非常高階的整合測試手段。它讓我們維持服務運行的狀態下,一邊呼叫真實的 S3 網路,一邊驗證我們的非同步自我修復。

本地測試

測試 A:模擬回程遺失 / 處理中超時(起死回生)

  • 我們將 simulateScenario 參數設為 `INBOUND_LOST,讓s3上傳成功後,封包無法正常的回傳給微服務
  • 透過curl的方式來上傳檔案,並且建立一個唯一的uuid值和設定好上傳檔案的路徑:
curl -i -X POST \
  -H "X-Upload-ID: 550e8400-e29b-41d4-a716-446655440000" \
  -F "file=@{your_test.txt}" \
  "http://localhost:8080/api/s3/async/upload/protected?simulateScenario=INBOUND_LOST"

https://ithelp.ithome.com.tw/upload/images/20260907/20183864puWxZ21uwF.png

回到console就可以看到,明明API有嚴重的超時異常,但因底層自我修復成功向 S3 確認了其實檔案已經安全抵達,微服務傳回 HTTP 200 OK,前端使用者完全沒有受到任何超時影響,避免了重複上傳!反觀如果是前幾天建立的沒有任何保護的上傳API遇到這樣的情況通常就直接收到{"status":500,"error":"Internal Server Error", ...}但實際上s3已經有檔案了

接著如果我們上傳同樣的uploadId且設定為將 simulateScenario 設為 OUTBOUND_LOST,讓第一次上傳的封包連S3都到不了,此時系統也會再用headObject去確認檔案有沒有存在

https://ithelp.ithome.com.tw/upload/images/20260907/20183864kFOxQiE3SK.png

透過GET方法列出所有的objects就可以看到剛剛指定的pom.xml檔案已經變成550e8400-e29b-41d4-a716-446655440000的檔名上傳到s3啦,至於顯示uploadId的檔名和我們實際上傳檔名不同的這個部分,就留到明天再詳細說明!

https://ithelp.ithome.com.tw/upload/images/20260907/20183864AkpeD77T18.png

測試 B:模擬去程遺失 / 徹底失敗

現在,我們測試如果封包連去程都丟失了(S3 上完全沒有檔案)。我們將 simulateScenario 設為 OUTBOUND_LOST,並且上傳新的uploadId測試:

curl -i -X POST \
  -H "X-Upload-ID: 550e8400-e29b-41d4-a716-446655440001" \
  -F "file=@test.txt" \
  "http://localhost:8080/api/s3/async/upload/protected?simulateScenario=OUTBOUND_LOST"

https://ithelp.ithome.com.tw/upload/images/20260907/20183864lvogj1u9Kc.png

會發現檔案上傳失敗,拋回 500 錯誤。這很正常,因為去程丟失的狀況下 S3 bucket裡根本沒有這個檔案,Double-Check 也無能為力。此時就需要更完善的retry機制來解決這個問題!
https://ithelp.ithome.com.tw/upload/images/20260907/20183864G3BCToF1RN.png

總結

今天我們實作了 Double-Check Pattern,解決了上傳檔案遇到Timeout結果未知的問題,透過 putObject 加上 headObject 的二次確認,我們可以處理:

  • 回程封包遺失
  • S3 處理時間過長
  • Proxy 或防火牆造成的回應延遲
  • Netty 非同步請求完成但 callback 沒有即時回來

不過 Double-Check 只能解決一個問題:這次上傳到底有沒有成功?面對剛剛實驗B的測試根本無能為力,這時當前端收到 500,我們就必須**「重新上傳(Retry)」**。如果前端在收到失敗重試時,每一次重試都隨機產生不同的 UUID 會有什麼後果? 我們依然會在 S3 裡堆滿無數因重試而產生的垃圾孤兒檔案。

因此,要達到真正的 100% 最終一致性,客戶端與後端之間必須建立起冪等性重試設計(Idempotency Retries)與微服務第一道背壓控流閘(Semaphore)。明天我們將實作 IdempotencySemaphore 控制器,保證前端就算重試一百次,S3 也只會永遠留下同一份檔案,徹底杜絕重複儲存的計費惡夢!


上一篇
[ Day 10 ] 網路世界的殘酷真相:封包遺失、超時處理與最終一致性
下一篇
[ Day 12 ] 前端重試一百次也絕不產生髒資料!實作客戶端重試與 Idempotency 設計
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言