🎯 什麼情境該想到我

當你「訊息可能因『至少一次投遞』或重送而重複到達,你想確保同一則訊息重複收到時只產生一次效果」的時候。

⚙️ 怎麼用(步驟 / 公式)

意圖:讓接收端在收到重複訊息時,處理結果與只收到一次相同(冪等),以因應重複投遞。

做法 / 結構要點:

  • 為訊息帶上唯一識別(如訊息 ID)。
  • 接收端記錄已處理過的 ID,收到已見過的就略過或只回覆先前結果。
  • 或把處理設計成本質冪等(如「設為 X」而非「加 1」)。

★★ 這是整個 EIP 型錄裡最不可省略的一個模式

理由:所有實務上的 broker 都是「至少一次」,而「恰好一次」在有外部副作用時不存在。
→ ★ 重複一定會發生,問題只是「你有沒有處理它」。

★ 重複的六個來源(比多數人想的多):

① 生產端重試(★ Kafka 的 enable.idempotence 只解決這一個)
② ★ 應用程式重啟後重發同一則
③ ★★ 處理時間超過 ack 期限/visibility timeout ⇒ broker 重送
   → ★ SQS 預設 30 秒(2026-09-25 查證)。這是「明明沒重送卻出現重複」的頭號原因
④ ★ 消費者崩潰在「處理完成」與「ack」之間
⑤ ★ DLQ 重放、人工重跑、回填
⑥ ★ consumer group rebalance(Kafka 重新分配分區時,未提交的會被重讀)

★★ 三種冪等做法,按可靠度排序

// ★★ ① 天然冪等的操作(最好 —— 不需要任何額外狀態)
UPDATE orders SET status = 'paid' WHERE id = $1          // ✅ SET,重複執行結果相同
UPDATE orders SET retry_count = retry_count + 1 …        // ★★ INCREMENT,重複就錯
 
INSERT … ON CONFLICT (id) DO UPDATE SET …                // ✅ UPSERT
INSERT …                                                  // ★ 重複會撞唯一鍵(★ 其實也可用,見 ②)
 
// ★★ ② 去重表 + 唯一索引(最通用)
_, err := tx.Exec(ctx, `INSERT INTO processed (message_id) VALUES ($1)`, msg.ID)
if isUniqueViolation(err) { return nil }                 // ★ 處理過了
// …然後在「同一個交易裡」做業務變更
 
// ★ ③ 版本號/條件更新(順便解掉亂序)
UPDATE read_model SET status=$1, version=$2 WHERE id=$3 AND version < $2
// ★★ 影響 0 列 = 重複或過舊 → 都是正確的「什麼都不做」

★★ ③ 是最被低估的一個:它同時解決重複、亂序、並行三個問題
(見 訊息序列)。只要你的訊息帶單調遞增的版本號,就優先用它。

★★ 去重表最關鍵的三條規則

① ★★ 去重紀錄與業務變更必須在「同一個資料庫、同一個交易」裡
   ❌ 去重放 Redis、業務寫 Postgres
      → ★★ Redis 記了但交易回滾 ⇒ 那則訊息永遠不會被處理,而且沒有任何錯誤
   ✅ 同一個交易 ⇒ 「commit 成功但 ack 失敗」的窗口自動安全(見 [[交易式用戶端]])

② ★★ 不要用「先 SELECT 再 INSERT」
   兩個 goroutine 同時 SELECT 都說「沒處理過」 ⇒ 都處理一次
   ✅ 直接 INSERT,靠唯一索引擋(★ 讓資料庫做併發控制)

③ ★★ 保留期 = broker retention + DLQ retention + 人工重放窗口 + 緩衝
   設太短 → ★ 重放三十天前的訊息時去重失效,副作用重複發生
   → ★ 一定要有清理(否則表無限成長),但清理的門檻要算過

★★ 最重要的界線:冪等的邊界在哪裡

★ 訊息處理包含三件事:
  ① 寫自己的資料庫    ← ★ 可以在同一交易裡做冪等
  ② 呼叫外部 API      ← ★★ 這裡無法納入你的交易
  ③ 發下游訊息        ← ★ 用 Outbox 納入同一交易

★★ ② 的處理方式:讓「外部呼叫」自己冪等
   → ★ 傳 idempotency key 給對方(Stripe、PayPal 等都支援)
   → 對方不支援 → ★ 自己記錄「已呼叫過」,但這中間有一個無法消除的窗口:
      「呼叫成功了但你還沒記錄」⇒ 重複呼叫
   → ★★ 這個窗口無法用技術消除,只能用「對方冪等」或「可偵測的重複」來處理

★ 這就是「恰好一次不存在」的具體位置——它不在 broker,在你和外部世界的邊界上。

★ 冪等鍵該用什麼

用什麼適合
★ message_id預設選擇。但★ 重放/回填時 id 可能不同
★ 業務鍵(order_id + event_type)★★ 更穩健:同一件事無論被發幾次、id 是什麼,都只生效一次
★ 決定性的衍生 id(父 id + position)分割器 產生的子訊息必須用這個
uuid.New()★★ 絕對不要——每次都不同,去重完全失效

★★ 業務鍵通常勝過 message_id:它讓「同一個訂單的付款事件」無論來自
即時通道、DLQ 重放、還是人工回填,都只生效一次。

★ 兄弟辨析

差別
交易式用戶端★ 同一件事的另一面(ack 時機);★★ 兩者一起才完整
保證投遞★ 它的代價就是重複 ⇒ 必須配這一頁
命令訊息★ 命令有副作用,最需要冪等
訊息序列版本號式的冪等,順便解亂序

🧪 我實際套用的紀錄

  • (待填)

⚠️ 注意 / 什麼時候不適用

  • 需要儲存已處理 ID,並決定保留多久 / 如何清理。

  • 分散式下的去重狀態本身要可靠。

  • 與交易式用戶端搭配,共同處理重送與失敗。

  • ★★ 這是最不可省略的模式。所有實務 broker 都是至少一次,重複一定會發生。

  • ★★ 去重紀錄與業務變更必須在同一個資料庫、同一個交易裡。 去重放 Redis 會造成「Redis 記了但交易回滾」⇒ 訊息永遠不會被處理且無錯誤。

  • ★★ 不要「先 SELECT 再 INSERT」。併發下兩者都會說沒處理過。直接 INSERT 靠唯一索引擋。

  • ★★ 冪等鍵優先用業務鍵而非 message_id。業務鍵讓即時/重放/回填都只生效一次。

  • ★★ 絕對不要用 uuid.New() 當冪等鍵(分割器 的子訊息尤其要用決定性 id)。

  • ★★ 處理時間超過 ack 期限(SQS 預設 30 秒)是「沒重送卻重複」的頭號原因。

  • ★ 去重表保留期 = broker retention + DLQ retention + 人工重放窗口 + 緩衝。 設太短會讓舊訊息重放時副作用重複。

  • ★★ 外部 API 呼叫無法納入你的交易。 要傳 idempotency key 給對方;對方不支援時有一個無法消除的窗口。

  • INCREMENT 不是冪等的,SET/UPSERT 才是。 這是最基本也最常被忽略的區分。

🔗 相關工具