Exactly-Once 語意

★★★★Reviewed

FromDDIA

📚 From the Books

Exactly-Once 語意Batch & Stream

「每筆訊息剛好處理一次」聽起來理所當然,實際上在會崩潰、會逾時的分散式系統裡幾乎不可能真正做到。可行的是 effectively-once:底層允許重送,但靠冪等與原子提交讓重複不產生可見的副作用。

🧠 Intuition

TIP

不要追求「訊息只送一次」——那需要完美的網路,不存在。要追求「就算送很多次,結果跟送一次一樣」。把目標從「不重複」改成「重複也無害」。

⚖️ 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

✍️ My Notes

2026-07-01

  • Q: 既然 Kafka/Flink 都宣稱 exactly-once,為什麼還說它其實是 effectively-once?
  • A: 因為底層投遞仍然是 at-least-once——訊息該重送還是會重送,沒有任何機制能保證「物理上只送一次」。它做到的是:把「處理輸出」和「提交消費位置 (offset)」包進同一個原子交易,重啟時要嘛整批重做、要嘛整批不算,外部看到的淨效果等同剛好一次。所以「exactly-once」描述的是可觀察結果,不是投遞次數。而且這個保證有邊界:只要副作用落在交易管得到的系統內(同一個 Kafka 叢集、Flink state)就成立;一旦你在處理過程中寄了封 email 或扣了第三方款,交易回滾收不回那封 email——這時就只能靠冪等的外部操作唯一 ID 去重來補。

📖 Further Reading

🔗 Dive Deeper