「每筆訊息剛好處理一次」聽起來理所當然,實際上在會崩潰、會逾時的分散式系統裡幾乎不可能真正做到。可行的是 effectively-once:底層允許重送,但靠冪等與原子提交讓重複不產生可見的副作用。
🧠 Intuition
TIP
不要追求「訊息只送一次」——那需要完美的網路,不存在。要追求「就算送很多次,結果跟送一次一樣」。把目標從「不重複」改成「重複也無害」。
- at-most-once:最多一次,可能丟(送了不重試)——簡單但會掉資料。
- at-least-once:至少一次,可能重(送了沒收到 ack 就重送)——不丟但會重複,最常見的底層保證。
- exactly-once:效果上剛好一次,靠在 at-least-once 之上消除重複的副作用達成。
⚖️ Tradeoffs
三種語意的真義
- at-most-once:發送方不重試。崩在 ack 之前 → 訊息永遠遺失。延遲最低、邏輯最簡單,適合可容忍丟失的指標。
- at-least-once:沒收到 ack 就重送。絕不丟,但「ack 在路上丟了」會導致已成功的訊息被重送 → 重複。
- exactly-once:不是魔法。它是 at-least-once(保證不丟)+ 去重/冪等(保證不重)的組合效果,所以更準確的說法是 effectively-once。
冪等 (Idempotence) — 最乾淨的解法
- 冪等操作=執行一次和執行多次效果相同(
SET x = 5冪等;x += 1不冪等)。 - 只要處理邏輯天生冪等,重複投遞就自動無害,根本不需要去重表。
- 設計手法:用自然主鍵 upsert取代盲目 insert、把「增量」改寫成「設定絕對值」、操作帶上唯一 request id。
去重 (Deduplication) — 當操作無法冪等
- 每筆訊息帶唯一 ID,consumer 記錄「已處理過的 ID」,看到重複就跳過。
- 成本:去重狀態會無限長大 → 必須設保留視窗(只記近 N 小時),賭重複不會晚於視窗才到。
- 本質上是「把冪等性外掛在框架層」,比天生冪等脆,但通用。
原子提交 — offset 與輸出綁在一起
- 重複的真正根源:處理輸出和推進 offset是兩步。輸出成功、推 offset 前崩潰 → 重啟後重做 → 重複。
- 解法:把「寫輸出」和「提交 offset」放進同一個原子交易,要嘛都成功要嘛都回滾。
- Kafka transactions / Flink checkpoint 就是這麼做:輸出與 offset 一起 commit,達成 effectively-once。
- 限制:只在輸出也參與同一交易系統時成立。一旦副作用跑到外部(寄 email、呼叫第三方 API),交易管不到 → 只能退回冪等或去重。
🔑 Takeaways
- 真正的「只送一次」需要完美網路、不存在;底層幾乎都是 at-least-once(不丟但會重)。
- exactly-once 是效果不是投遞次數,準確說法是 effectively-once:at-least-once + 消除重複副作用。
- 消除重複兩條路:冪等(最乾淨,重複天生無害)與去重(外掛唯一 ID,狀態要設保留視窗)。
- 框架的 exactly-once 靠把輸出與 offset 原子提交;但外部副作用(email、第三方 API)逃出交易邊界,仍須冪等或去重收尾。
2026-07-01
- Q: 既然 Kafka/Flink 都宣稱 exactly-once,為什麼還說它其實是 effectively-once?
- A: 因為底層投遞仍然是 at-least-once——訊息該重送還是會重送,沒有任何機制能保證「物理上只送一次」。它做到的是:把「處理輸出」和「提交消費位置 (offset)」包進同一個原子交易,重啟時要嘛整批重做、要嘛整批不算,外部看到的淨效果等同剛好一次。所以「exactly-once」描述的是可觀察結果,不是投遞次數。而且這個保證有邊界:只要副作用落在交易管得到的系統內(同一個 Kafka 叢集、Flink state)就成立;一旦你在處理過程中寄了封 email 或扣了第三方款,交易回滾收不回那封 email——這時就只能靠冪等的外部操作或唯一 ID 去重來補。