昨天我們說明了網路世界最殘酷的一面。當微服務呼叫 AWS S3 上傳檔案時,如果發生 TimeoutException時不能直接斷言上傳一定失敗,也有可能是上傳成功但回傳封包失敗又或是等待時間太久連線不上。
而今天的內容我們將實作昨天提到的最終一致性!將延續 Day 8 從引擎到微服務:用 Spring Boot 同時封裝 Apache 與 Netty S3 API 的程式碼,在 Spring Boot 環境中,實作headObject來達到分散式防禦的第一個核心 ── Double-Check Pattern(狀態二次確認模式)。
昨天也有提到在實務中只寫try...catch...是不夠的,很多程式碼是在網路 100% 正常、毫無延遲的溫室環境下看起來完全沒有問題。但在真實的惡劣網路(如高延遲、封包遺失)中,當微服務收到 TimeoutException 時,程式會無情地進入 catch 區塊,回傳 500 Internal Server Error 給前端。使用者看到失敗,理所當然會重新整理、再次點擊上傳。
結果就是 S3 bucket堆滿了重複的、沒有任何資料庫記錄關聯的孤兒檔案(Orphan Files),增加無謂的成本,面對Timeout的正確做法絕對不是一刀切地回傳失敗,而是主動發起狀態二次確認。微服務必須搞清楚自己當前面對的到底是「成功、失敗、還是未知」,這就是 Double-Check Pattern。
其核心概念非常樸實且優雅:

當微服務遇到 TimeoutException 或其他未知的網路中斷時,我們需要去確認該檔案在 S3 雲端到底存在與否。有些人或許會直覺的呼叫下載(getObject)看有沒有 404 ,或是直接重新 putObject(上傳)一次覆蓋它。或許簡單的服務沒問題,但遇到高併發或大檔案的情境就出事了
我們不需要重新下載整個檔案,也不需要再次執行一次完整的 PUT,因為太耗費效能了。反觀使用headObject(等同於 HTTP 的 HEAD 方法)是最好的選擇,只會向S3查詢物件的 metadata,例如:
headObject 完全不傳輸任何檔案實體內容(Payload-free),不論你的檔案是 100KB 還是 10GB,它不會把檔案內容下載回來,因此比重新執行上傳或下載檔案輕量許多
HeadObjectRequest request = HeadObjectRequest.builder()
.bucket(bucket)
.key(uploadId)
.build();
return s3AsyncClient.headObject(request);
現在,我們打開專案中的 S3Service.java,實作這個具備安全自我修復能力的非同步上傳流程。
先定義一個結果物件:
public record ProtectedUploadResult(
String uploadId, // 這次上傳使用的唯一識別碼
boolean success, // 最終是否判定上傳成功
boolean confirmedByHead // 是否是透過 HEAD 二次確認成功
) {
}
在 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);
});
});
}
現在,我們在 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 會直接作為 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 不是伺服器自動產生的,而是由呼叫端(前端或上游服務)在發起請求前就先產生好,並透過 Header 帶進來。Controller 只負責驗證格式,不負責產生。
原因跟這個 pattern 的目的有關:uploadId 扮演的其實是冪等鍵(Idempotency Key)的腳色,不是單純的檔名,詳細明天的章節會說明。現在只有知道如果改成伺服器自動產生,重試時就會拿到不同的 uploadId,變成兩個不同的 S3 Key,Double-Check 也就失去意義了!
所以正確的做法是:同一次使用者操作,重試時必須沿用同一個 uploadId,這件事只有呼叫端自己知道「這是不是同一次操作的重試」,伺服器無法從單一 request 判斷。實務上常見的產生位置:
crypto.randomUUID() 產生一個 uploadId 並存到這次操作的 state 裡;逾時重試就重用同一個值再打一次。或許寫過java或spring專案的人會問,既然要測試超時,為什麼不用 Mockito 寫個 JUnit 單元測試,而非得在實體 API 裡面寫 simulateScenario 參數呢?
當我們在實測故障注入(Fault Injection)與整合測試(Integration Testing)時通常會有下列考量:
simulateScenario 參數,是一種在混亂工程中非常高階的整合測試手段。它讓我們維持服務運行的狀態下,一邊呼叫真實的 S3 網路,一邊驗證我們的非同步自我修復。simulateScenario 參數設為 `INBOUND_LOST,讓s3上傳成功後,封包無法正常的回傳給微服務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"

回到console就可以看到,明明API有嚴重的超時異常,但因底層自我修復成功向 S3 確認了其實檔案已經安全抵達,微服務傳回 HTTP 200 OK,前端使用者完全沒有受到任何超時影響,避免了重複上傳!反觀如果是前幾天建立的沒有任何保護的上傳API遇到這樣的情況通常就直接收到{"status":500,"error":"Internal Server Error", ...}但實際上s3已經有檔案了
接著如果我們上傳同樣的uploadId且設定為將 simulateScenario 設為 OUTBOUND_LOST,讓第一次上傳的封包連S3都到不了,此時系統也會再用headObject去確認檔案有沒有存在

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

現在,我們測試如果封包連去程都丟失了(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"

會發現檔案上傳失敗,拋回 500 錯誤。這很正常,因為去程丟失的狀況下 S3 bucket裡根本沒有這個檔案,Double-Check 也無能為力。此時就需要更完善的retry機制來解決這個問題!
今天我們實作了 Double-Check Pattern,解決了上傳檔案遇到Timeout結果未知的問題,透過 putObject 加上 headObject 的二次確認,我們可以處理:
不過 Double-Check 只能解決一個問題:這次上傳到底有沒有成功?面對剛剛實驗B的測試根本無能為力,這時當前端收到 500,我們就必須**「重新上傳(Retry)」**。如果前端在收到失敗重試時,每一次重試都隨機產生不同的 UUID 會有什麼後果? 我們依然會在 S3 裡堆滿無數因重試而產生的垃圾孤兒檔案。
因此,要達到真正的 100% 最終一致性,客戶端與後端之間必須建立起冪等性重試設計(Idempotency Retries)與微服務第一道背壓控流閘(Semaphore)。明天我們將實作 Idempotency 與 Semaphore 控制器,保證前端就算重試一百次,S3 也只會永遠留下同一份檔案,徹底杜絕重複儲存的計費惡夢!