iT邦幫忙

2026 iThome 鐵人賽

DAY 12
1
Software Development

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

[ Day 12 ] 前端重試一百次也絕不產生髒資料!實作客戶端重試與 Idempotency 設計

  • 分享至 

  • xImage
  •  

昨天我們完成了 Double-Check Pattern 的實作,當 putObject 發生 timeout 時,後端不再直接回傳失敗,而是使用 headObject 向 S3 查詢檔案是否已經存在。

這只解決了「PUT 失敗,但檔案其實已經成功存在」的狀況,然而,還有另一個問題。假設 HEAD 確認檔案真的不存在,前端要重試上傳。那麼重試時,應該使用什麼 Object Key?

如果每次都使用原始檔名,可能會發生:

  • 第一次:photo.jpg
  • 第二次:photo-1.jpg
  • 第三次:photo-final.jpg

最後 S3 裡面可能留下三份內容相同的檔案。前面產生的就變成孤兒檔案了,這也是今天要解決的問題:

客戶端可以重試,但重試不能製造重複資料。

什麼是冪等性(Idempotency)?

冪等性指的是:同一個操作執行一次或執行多次,最終結果都應該相同。用數學概念表示:

f(f(x)) = f(x)

套用到上傳流程就是「同一個 uploadId 上傳一次」和「同一個 uploadId 上傳五次」結果都應該一樣,最終只保留同一個 Object Key

冪等性不是代表網路請求只會送出一次。實際上,網路重試可能真的送出多次:

  • 第一次 PUT:可能成功
  • 第二次 PUT:可能是前端重試
  • 第三次 PUT:可能是 proxy 重送

冪等性要保證的是:即使請求真的送出多次,系統仍然能辨識它們是同一個業務操作。

為什麼要用 uploadId 當 Object Key?

如果直接將使用者上傳的檔名上傳到aws s3,它會面臨幾個問題:

  1. 不同使用者可能上傳相同檔名(如同大家電腦中都一定有「新資料夾」、「1.png」)
  2. 同一個使用者重試時,無法判斷是不是同一次上傳
  3. 檔名可能包含特殊字元或路徑
  4. 檔名不是可靠的業務交易識別碼
  5. 多次重試可能被程式轉換成不同名稱

因此,我們需要讓前端在第一次上傳前產生唯一的 uploadId,Ex.550e8400-e29b-41d4-a716-446655440000 且作為上傳到AWS S3的Object Key

uploadId 的生命週期

完整流程如下:

前端產生 uploadId
        ↓
第一次上傳帶上 uploadId
        ↓
後端使用 uploadId 作為 S3 Object Key
        ↓
網路 Timeout
        ↓
後端執行 HEAD Double-Check
        ↓
如果不存在,前端使用同一個 uploadId 重試
        ↓
後端仍然寫入同一個 Object Key

後者會讓每一次重試都變成全新的上傳任務。

Controller 驗證 uploadId

在 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 前,先擋掉格式錯誤的請求。

Service 使用 uploadId 當 Object Key

通過驗證之後,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 Metadata 還原原始檔名

把 S3 Object Key 換成 UUID 之後,會出現一個新的困擾,使用者點擊下載時,瀏覽器預設會把檔案存成 550e8400-e29b-41d4-a716-446655440000.png,完全看不出原本的檔名。

通常這樣的服務我們還會建立一個關聯式DB的資料庫,額外建一張 uploadId 對應「原始檔名」的對照表。但其實也有不需要資料庫的方法,因為 S3 物件本身就可以自訂的 Metadata,上傳時寫入、下載時讀回即可,這種設計的優點是:

  • 架構簡單
  • 不需要額外 DB
  • 沒有 DB transaction overhead
  • 適合純 S3 proxy service
  • 可以水平擴展多個 Spring Boot instance

但是它也有邊界:

  1. 如果要記錄 uploadId 與內容 hash,需要額外狀態儲存。
  2. 如果要支援長時間未完成的 upload state,需要 Redis 或資料庫。
  3. 如果要追蹤上傳者、檔案版本與業務狀態,需要額外 metadata。
  4. 如果要限制 uploadId 只能使用一次,必須加入狀態管理。

因此目前的設計比較適合:純檔案傳輸、S3做為主要檔案來源,而不是和當完整訂單或是金流系統等等

上傳時寫入 Metadata

PutObjectRequest putRequest = PutObjectRequest.builder()
        .bucket(bucket)
        .key(key)
        .metadata(Map.of("original-filename",
                originalFilename != null && !originalFilename.isBlank() ? originalFilename : key))
        .build();

下載時讀回 Metadata

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 本身當檔名,不會壞掉。

Controller 組出正確的 Content-Disposition

原始檔名可能包含中文或空白,直接塞進 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,但使用者下載時看到的,永遠是自己上傳時的原始檔名——完全不需要多一張資料庫表。

同一個 Key 不代表內容一定相同

這裡還有一個重要的細節,當 S3 Object Key 相同時,後來的 PUT 會直接覆蓋前一次內容,我們在自己電腦新增資料夾的時候,如果遇到檔名重複系統可能會提醒你,或是直接幫你變成「新資料夾(1)」,但S3上傳的過程中,不會多一層判斷,如果有相同檔名(object key)後來上傳的會直接覆蓋舊的檔案內容

所以前端的規則必須是,同一個 uploadId 只能搭配同一個檔案內容重試。更進階的作法,是在 request 中加入內容雜湊值,例如 SHA-256(content)。後端可以驗證相同 uploadId 的內容 hash 是否一致,若不同就回傳409 Conflict

表示同一個 uploadId 不可以對應不同的檔案內容。

客戶端重試策略

前端不應該無限重試。一個合理的重試策略通常包含:

  • 最大重試次數
  • Exponential Backoff
  • Jitter
  • 只針對暫時性錯誤重試
  • 每次重試使用相同的 uploadId

不好的重試寫法

最直覺的作法是try..catch中再建立一個迴圈,記錄重式的次數,不過會有幾個問題 :

for (int i = 0; i < 10; i++) {
    upload(file);
}
  1. 重試次數過多
  2. 沒有等待時間
  3. 沒有保證每次使用相同 uploadId

Exponential Backoff

比較合理的做法是,隨著重試次數增加,逐步拉長等待時間:

  • 第一次失敗:等待 200ms
  • 第二次失敗:等待 400ms
  • 第三次失敗:等待 800ms
  • 第四次失敗:等待 1600ms

公式可以寫成: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,讓不同請求的重試時間稍微錯開。

哪些錯誤可以重試?

通常可以重試:

  • Connection timeout
  • Read timeout
  • 暫時性網路錯誤
  • HTTP 408 Request Timeout
  • HTTP 429 Too Many Requests
  • HTTP 500 Internal Server Error
  • HTTP 502 Bad Gateway
  • HTTP 503 Service Unavailable
  • HTTP 504 Gateway Timeout

以下錯誤通常不應該直接重試:

  • 400 Bad Request
  • 401 Unauthorized
  • 403 AccessDenied
  • 404 Not Found
  • 409 Conflict
  • uploadId 格式錯誤
  • bucket 名稱錯誤
  • AWS credentials 錯誤
  • 檔案格式驗證失敗

例如 AWS credentials 錯誤時,即使重試 100 次,credentials 也不會突然變正確。

Semaphore 與 Retry 的關係

目前 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 格式防呆校驗 (HTTP 400)

我們故意帶入一個不合法的 UUID,例如 my_avatar_name:
https://ithelp.ithome.com.tw/upload/images/20260907/20183864g9UTXNcu4N.png

發現系統回傳400,並且說明uuid格式錯誤

測試二:uploadId檔案對應filename

我們將pom.xml的檔名上傳,且給定一個uploadId,會發現上傳成功後S3上的object key是uploadId,且下載時也需要用到uploadId,但列出來metadata中還是可以在filename看到原本的檔名
https://ithelp.ithome.com.tw/upload/images/20260907/201838645WWCP4WHsO.png

總結

昨天我們處理了上傳結果不確定的問題,今天我們處理了上傳失敗後如何安全重試的問題,完成了客戶端重試與冪等性設計,整套防禦機制可以整理成三層:

  1. Semaphore Backpressure : 避免同一時間太多上傳請求壓垮服務,滿載就直接拋 429;沒滿載就執行 PUT
  2. Double-Check Pattern : 用 HEAD 核對 S3 真實狀態,物件存在就當作救回成功(200),真的不存在才回 500
  3. Idempotency : 保證前端只要用同一個 uploadId 重試,就會回到同一條 PUT 流程,不會產生新的 S3 物件

https://ithelp.ithome.com.tw/upload/images/20260907/20183864wFP9wp1u95.jpg

無論最後結果是哪一種,Semaphore permit 都會在流程結束時釋放,如果超過Semaphore設定的閾值又會發生什麼事呢?未來我們會透過混沌工程和壓力測試的方式再告訴大家調整完的架構又如何面對這樣的處境呢!

在高併發分散式系統中,真正可靠的 API 從來不是永遠不失敗,而是即使失敗,也能知道目前的狀態,並且安全地恢復,而目前我們只在 localhostsimulateScenario 參數後門下完成的模擬演練。後續我們會拋棄 simulateScenario 參數,進入混沌工程(Chaos Engineering)的領域家親手把本機的網路卡模擬成惡劣網路環境,來對微服務進行更貼近真是現況測試!

在此之前我們明天要先來了解一下 AWS S3 中有哪些預設的設定可以調整,可以讓檔案紀錄版控以及透過lifecycle來自動刪除一些孤兒檔案,來節省成本!


上一篇
[ Day 11 ] 實作 Double-Check Pattern:狀態的二次確認機制
下一篇
[ Day 13 ] AWS S3 清理 : 利用生命週期規則(Lifecycle Rules)與版本控制自動清理孤兒與歷史檔案
系列文
學校沒教的後端生存指南:30 天打造非同步 S3 微服務,部署 K8s 實現 HA 架構14
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言