昨天我們用 JMeter 與 JConsole 進行了本地端的第一波大壓測,親眼見證了非同步 Netty 在面對高併發流量時,如何以極其穩定的執行緒表現,不過在現實世界中還必須考量到網路異常與分散式資料不一致的災難場景
很多人在剛寫出高吞吐量的非同步 API 後會非常興奮,卻忽略了在分散式系統中,網路是不可靠的!除了網路抖動造成的延遲,封包也有可能真實世界的物理與硬體限制被無情丟棄:
換言之當高併發遇上這些惡劣的網路環境(延遲、超時、遺失封包),我們這套 S3 API 會迎來怎樣的狀態不一致災難?今天我們不寫程式,先回到理論與架構層面,探討分散式事務的痛點,並為接下來的防禦機制打下地基!
在本機開發時(localhost),網路延遲幾乎是 0ms,封包傳輸成功率是 100%。但在雲端生產環境中,微服務與 AWS S3 遠端機房之間跨越了太平洋,網路充滿了不確定性。
當微服務向 S3 發起一個 HTTP 請求,而請求在規定的時間內沒有收到回應,系統就會噴出 TimeoutException(超時)。這時問題來了超時不代表失敗了,其實也有可能成功一半!在網路世界中,一個超時背後可能隱藏著三種截然不同結果:

你一定遇過這種情況:在外送平台(如 UberEats 或 Foodpanda)點餐時,按下「確認結帳」,畫面轉了 30 秒後跳出「網路超時,請重試」。你直覺地再按了一次。結果 30 分鐘後,兩個外送員同時抵達或是你的信用卡也被扣了兩次錢!
( 圖片來源 : Yahoo新聞)
這就是最經典的狀態不一致災難。系統遇到超時,沒有做狀態二次確認(Double-Check)也沒有做防重複機制(Idempotency),把超時武斷地視為失敗,最終讓使用者買單。當然我們可以說後續手動補救,客服協助退款,但是造成的是商譽受損,還是要再最初規劃階段就把程式架構設計好,來避免這樣的問題!
我們這次開發的微服務很簡單,只單純把前端的檔案轉傳給S3,沒有串接DB,這樣還會有狀態不一致的問題嗎?
當然會,而且非常嚴重!這裡的不一致,發生在客戶端 (Frontend/App)、微服務與AWS S3三者之間。下方是我們昨天寫為服務的上傳function,已經有寫了try...catch...還會有什麼問題嗎?
public ResponseEntity<String> uploadFileFlow(MultipartFile file) {
try {
// 直接調用 S3 引擎上傳檔案
s3Service.uploadSync(bucket, file.getOriginalFilename(), file.getBytes());
return ResponseEntity.ok("SUCCESS");
} catch (Exception e) {
// 只要發生異常(包含 Timeout),就直接告訴前端失敗
return ResponseEntity.status(500).body("FAILED");
}
}
一般正常環境下可能沒問題,但是如果在高併發或弱網路環境下,會引發兩大災難:
孤兒檔案(Orphan Files)—— 燒錢的無底洞
幽靈記錄(Phantom Records / 虛假成功)—— 崩潰的信任危機
404 NoSuchKey (Not Found)。使用者在論壇和客服瘋狂投訴:「系統明明跟我說成功了,為什麼點開什麼都沒有?!」,這種讓使用者看到幽靈成功的信任危機,是摧毀品牌商譽的最速路徑。以上的狀況都還是只有單純client使用者端和S3 api服務的狀況下,如果系統架構還有包含DB要記錄一些metadata或是商業邏輯更複雜一個API行為會包含到三四張不同資料表的變更,如同前面提到的外送平台例子一樣,沒有做好一致性的錯誤處理造成的後果就更嚴重了!
遇到這種跨節點的不一致問題,教科書上通常會教你使用分散式事務來處理,例如 三階段提交 (3PC-Three-Phase Commit)。
3PC 將整個事務提交過程拆解為三個步驟,並引入了超時自動 Commit 機制,試圖解決死鎖問題:
雖然實務上我們不愛用 3PC,但了解它的實作邏輯,能幫助你徹底明白它為什麼這麼慢。3PC 必須由一個協調者 (Coordinator) 來指揮所有參與者 (Participants)(例如資料庫 A、資料庫 B)。
以下是用 Java 虛擬碼呈現 3PC 協調者的核心邏輯。注意它如何透過三次網路來回(RTT)與超時機制來運作:
public class ThreePhaseCommitCoordinator {
// 階段 1:CanCommit (詢問大家準備好了沒?)
public boolean phase1CanCommit(List<Participant> participants, Transaction tx) {
for (Participant p : participants) {
// 只要有一個節點回覆 NO 或網路超時,整個事務直接取消
if (!p.askCanCommit(tx)) {
abortAll(participants);
return false;
}
}
return true;
}
// 階段 2:PreCommit (預先提交,鎖定資源)
public boolean phase2PreCommit(List<Participant> participants, Transaction tx) {
for (Participant p : participants) {
// 參與者在此刻會「鎖定 DB 資料行」並寫入 Redo Log,但還不生效
boolean ack = p.sendPreCommit(tx);
if (!ack) {
// 如果有人在這個階段當機,協調者發出 Rollback 廣播釋放鎖定
rollbackAll(participants);
return false;
}
}
return true;
}
// 階段 3:DoCommit (正式提交)
public void phase3DoCommit(List<Participant> participants, Transaction tx) {
for (Participant p : participants) {
// 發出最終指令。如果協調者在這裡當機沒發出指令,
// 參與者因為已經過了 PreCommit 階段,等 Timeout 一到,就會「自動提交」。
p.sendDoCommit(tx);
}
}
// 完整 3PC 執行流程
public void executeDistributedTransaction(Transaction tx) {
List<Participant> participants = getInvolvedNodes();
if (phase1CanCommit(participants, tx)) {
if (phase2PreCommit(participants, tx)) {
phase3DoCommit(participants, tx);
}
}
}
}
從前面的程式碼就可以看出一個簡單的上傳動作,在 3PC 架構下每一種API請求都必須等待 3 次完整的網路來回,且在階段二和階段三之間,資料庫的資源是被死死鎖住的,所以實務上不使用的原因主要有二:
效能極差(Latency Multiplier): 一個簡單的上傳動作,要在網路上來回溝通三次。在我們昨天的壓測場景中,如果每個請求都要走 3PC,系統的吞吐量 (QPS) 會瞬間雪崩,毫無效能可言。
AWS S3 根本不支援! S3 是 RESTful API 的物件儲存服務,它底層沒有Transaction的概念。無法對 S3 說:「嘿,我先上傳檔案做 PreCommit,等我確認好你再正式 DoCommit。」S3 只要傳輸完畢,檔案就直接生效了。
既然 3PC 這種強一致性(ACID)在雲端架構中不切實際且無法實作,我們該如何拯救我們的 S3 微服務?
我們不強求前端、微服務與 S3 在同一微秒內保持同步。我們允許系統在網路發生抖動時,處於極短暫的不一致狀態,但我們必須保證:「不管網路怎麼抖動、怎麼超時,系統在幾秒鐘後,會自動對齊狀態,徹底消滅孤兒檔案與幽靈記錄。」所以現在更採用實務權衡:拋棄強一致性,追求最終一致性 (Eventual Consistency)!
為此,我們在未來的章節中,將帶領大家親自實作一套輕量級的防禦機制二部曲:
Double-Check Pattern(狀態二次確認機制): 當微服務遇到超時或未知狀態時,不直接宣告失敗,而是主動發起一次 headObjec 輕量查詢,向 S3 進行二次確認,確定檔案是否真實存在。
Idempotent Client Retries(冪等性重試設計): 客戶端在遇到超時後可以放心重試。我們將在後端設計冪等性校驗,保證重試 100 次,S3 也只會留下一份檔案,絕不產生多餘的孤兒垃圾。
今天我們走出了 localhost 的烏托邦,看清了真實雲端網路中超時的危險,也理解了為什麼教科書上的分散式事務(3PC)在現代 API 型儲存服務前顯得如此無力。
明天,我們將重回 Java 的程式碼戰場,在實作Double-Check Pattern,為微服務打造第一面堅不可摧的自動狀態對齊盾牌!