昨天我們完成了 Double-Check Pattern 的實作,當 putObject 發生 timeout 時,後端不再直接回傳失敗,而是使用 headObject 向 S3 查詢檔案是否已經存在。
這只解決了「PUT 失敗,但檔案其實已經成功存在」的狀況,然而,還有另一個問題。假設 HEAD 確認檔案真的不存在,前端要重試上傳。那麼重試時,應該使用什麼 Object Key?
如果每次都使用原始檔名,可能會發生:
最後 S3 裡面可能留下三份內容相同的檔案。前面產生的就變成孤兒檔案了,這也是今天要解決的問題:
客戶端可以重試,但重試不能製造重複資料。
冪等性指的是:同一個操作執行一次或執行多次,最終結果都應該相同。用數學概念表示:
f(f(x)) = f(x)
套用到上傳流程就是「同一個 uploadId 上傳一次」和「同一個 uploadId 上傳五次」結果都應該一樣,最終只保留同一個 Object Key
冪等性不是代表網路請求只會送出一次。實際上,網路重試可能真的送出多次:
冪等性要保證的是:即使請求真的送出多次,系統仍然能辨識它們是同一個業務操作。
如果直接將使用者上傳的檔名上傳到aws s3,它會面臨幾個問題:
因此,我們需要讓前端在第一次上傳前產生唯一的 uploadId,Ex.550e8400-e29b-41d4-a716-446655440000 且作為上傳到AWS S3的Object Key
完整流程如下:
前端產生 uploadId
↓
第一次上傳帶上 uploadId
↓
後端使用 uploadId 作為 S3 Object Key
↓
網路 Timeout
↓
後端執行 HEAD Double-Check
↓
如果不存在,前端使用同一個 uploadId 重試
↓
後端仍然寫入同一個 Object Key
後者會讓每一次重試都變成全新的上傳任務。
在 Controller 層要求前端透過 Header 傳送 uploadId(預設必填,沒帶就會被 Spring 直接擋在框架層):
@PostMapping("/async/upload/protected")
public CompletableFuture<ResponseEntity<String>> uploadAsyncProtected(
@RequestHeader("uploadId") String uploadId,
@RequestParam(value = "simulateScenario", defaultValue = "NORMAL") String simulateScenario,
@RequestParam("file") MultipartFile file)
throws IOException {
接著驗證它是不是合法 UUID:
final String normalizedUploadId;
try {
normalizedUploadId = UUID.fromString(uploadId).toString();
} catch (IllegalArgumentException ex) {
return CompletableFuture.completedFuture(
ResponseEntity.badRequest()
.body("uploadId must be a valid UUID"));
}
如果前端沒有傳合法 UUID 就要回傳
HTTP 400 Bad Request
uploadId must be a valid UUID
這樣可以在真正呼叫 S3 前,先擋掉格式錯誤的請求。
通過驗證之後,Controller 將 uploadId 與原始檔名一併傳給 Service:
uploadFuture = s3Service.uploadAsyncProtected(
bucketName,
normalizedUploadId,
file.getBytes(),
simulateScenario,
file.getOriginalFilename());
Service 建立 S3 request 時:
PutObjectRequest putRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key) //重點 !!!!
.metadata(Map.of("original-filename",
originalFilename != null && !originalFilename.isBlank() ? originalFilename : key))
.build();
.key(key),keu的內容要是uploadId不可以是來自使用者端的fileName
.metadata(...) 這一行,是今天要解決的另一個問題:用 uploadId 當 Key 之後,使用者下載時要怎麼看到自己原本的檔名?
把 S3 Object Key 換成 UUID 之後,會出現一個新的困擾,使用者點擊下載時,瀏覽器預設會把檔案存成 550e8400-e29b-41d4-a716-446655440000.png,完全看不出原本的檔名。
通常這樣的服務我們還會建立一個關聯式DB的資料庫,額外建一張 uploadId 對應「原始檔名」的對照表。但其實也有不需要資料庫的方法,因為 S3 物件本身就可以自訂的 Metadata,上傳時寫入、下載時讀回即可,這種設計的優點是:
但是它也有邊界:
因此目前的設計比較適合:純檔案傳輸、S3做為主要檔案來源,而不是和當完整訂單或是金流系統等等
PutObjectRequest putRequest = PutObjectRequest.builder()
.bucket(bucket)
.key(key)
.metadata(Map.of("original-filename",
originalFilename != null && !originalFilename.isBlank() ? originalFilename : key))
.build();
S3Service 新增一個 DownloadResult,把「檔案內容」與「還原後的檔名」包在一起回傳:
public record DownloadResult(byte[] content, String filename) {
}
public CompletableFuture<DownloadResult> downloadFileAsync(String bucket, String key) {
GetObjectRequest request = GetObjectRequest.builder().bucket(bucket).key(key).build();
return s3AsyncClient.getObject(request, AsyncResponseTransformer.toBytes())
.thenApply(responseBytes -> {
String originalFilename = responseBytes.response().metadata()
.getOrDefault("original-filename", key);
return new DownloadResult(responseBytes.asByteArray(), originalFilename);
});
}
responseBytes.response().metadata() 就是 S3 回應裡的使用者自訂 Metadata,如果找不到(例如上傳時沒有帶,或是舊資料),就退回用 Key 本身當檔名,不會壞掉。
原始檔名可能包含中文或空白,直接塞進 filename="..." 在部分瀏覽器會亂碼,因此加上filename*=UTF-8''... 編碼:
@GetMapping("/async/download/{key}")
public CompletableFuture<ResponseEntity<byte[]>> downloadAsync(@PathVariable String key) {
return s3Service.downloadFileAsync(bucketName, key)
.thenApply(result -> {
String encodedFilename = URLEncoder.encode(result.filename(), StandardCharsets.UTF_8)
.replace("+", "%20");
return ResponseEntity.ok()
.header("Content-Disposition",
"attachment; filename=\"" + result.filename() + "\"; filename*=UTF-8''" + encodedFilename)
.body(result.content());
});
}
這樣一來,S3 裡實體儲存的是安全、不會衝突的 UUID Key,但使用者下載時看到的,永遠是自己上傳時的原始檔名——完全不需要多一張資料庫表。
這裡還有一個重要的細節,當 S3 Object Key 相同時,後來的 PUT 會直接覆蓋前一次內容,我們在自己電腦新增資料夾的時候,如果遇到檔名重複系統可能會提醒你,或是直接幫你變成「新資料夾(1)」,但S3上傳的過程中,不會多一層判斷,如果有相同檔名(object key)後來上傳的會直接覆蓋舊的檔案內容
所以前端的規則必須是,同一個 uploadId 只能搭配同一個檔案內容重試。更進階的作法,是在 request 中加入內容雜湊值,例如 SHA-256(content)。後端可以驗證相同 uploadId 的內容 hash 是否一致,若不同就回傳409 Conflict
表示同一個 uploadId 不可以對應不同的檔案內容。
前端不應該無限重試。一個合理的重試策略通常包含:
最直覺的作法是try..catch中再建立一個迴圈,記錄重式的次數,不過會有幾個問題 :
for (int i = 0; i < 10; i++) {
upload(file);
}
比較合理的做法是,隨著重試次數增加,逐步拉長等待時間:
公式可以寫成:delay = min(initialDelay * 2^retryCount, maxDelay)
long backoff = Math.min(
200L * (1L << retryCount),
5000L);
long jitter = ThreadLocalRandom.current()
.nextLong(0, 200);
long delay = backoff + jitter;
如果所有 client 同時在固定時間重試,可能會再次造成流量尖峰,因此需要加入 Jitter,讓不同請求的重試時間稍微錯開。
通常可以重試:
408 Request Timeout
429 Too Many Requests
500 Internal Server Error
502 Bad Gateway
503 Service Unavailable
504 Gateway Timeout
以下錯誤通常不應該直接重試:
400 Bad Request
401 Unauthorized
403 AccessDenied
404 Not Found
409 Conflict
例如 AWS credentials 錯誤時,即使重試 100 次,credentials 也不會突然變正確。
目前 API 還加入了 Semaphore,建立 Controller 時,預設最多允許 50 個同時上傳:
private final Semaphore uploadSemaphore;
public S3Controller(
S3Service s3Service,
@Value("${s3.upload.max-concurrency:50}")
int maxUploadConcurrency) {
this.s3Service = s3Service;
this.uploadSemaphore = new Semaphore(maxUploadConcurrency);
}
當上傳數量達到上限時:
if (!uploadSemaphore.tryAcquire()) {
return CompletableFuture.completedFuture(
ResponseEntity.status(429)
.body("Too many concurrent uploads"));
}
前端收到 429 時,也可以使用同一個 uploadId 稍後重試。
我們故意帶入一個不合法的 UUID,例如 my_avatar_name:
發現系統回傳400,並且說明uuid格式錯誤
我們將pom.xml的檔名上傳,且給定一個uploadId,會發現上傳成功後S3上的object key是uploadId,且下載時也需要用到uploadId,但列出來metadata中還是可以在filename看到原本的檔名
昨天我們處理了上傳結果不確定的問題,今天我們處理了上傳失敗後如何安全重試的問題,完成了客戶端重試與冪等性設計,整套防禦機制可以整理成三層:
uploadId 重試,就會回到同一條 PUT 流程,不會產生新的 S3 物件
無論最後結果是哪一種,Semaphore permit 都會在流程結束時釋放,如果超過Semaphore設定的閾值又會發生什麼事呢?未來我們會透過混沌工程和壓力測試的方式再告訴大家調整完的架構又如何面對這樣的處境呢!
在高併發分散式系統中,真正可靠的 API 從來不是永遠不失敗,而是即使失敗,也能知道目前的狀態,並且安全地恢復,而目前我們只在 localhost 與 simulateScenario 參數後門下完成的模擬演練。後續我們會拋棄 simulateScenario 參數,進入混沌工程(Chaos Engineering)的領域家親手把本機的網路卡模擬成惡劣網路環境,來對微服務進行更貼近真是現況測試!
在此之前我們明天要先來了解一下 AWS S3 中有哪些預設的設定可以調整,可以讓檔案紀錄版控以及透過lifecycle來自動刪除一些孤兒檔案,來節省成本!