🎯 什麼情境該想到我
當你「訊息可能因『至少一次投遞』或重送而重複到達,你想確保同一則訊息重複收到時只產生一次效果」的時候。
⚙️ 怎麼用(步驟 / 公式)
意圖:讓接收端在收到重複訊息時,處理結果與只收到一次相同(冪等),以因應重複投遞。
做法 / 結構要點:
- 為訊息帶上唯一識別(如訊息 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 重放、還是人工回填,都只生效一次。
★ 兄弟辨析
🧪 我實際套用的紀錄
- (待填)
⚠️ 注意 / 什麼時候不適用
-
需要儲存已處理 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才是。 這是最基本也最常被忽略的區分。
🔗 相關工具
- 交易式用戶端 Transactional Client(同家族,回滾重送的重複由此處理)
- 持久訂閱者 Durable Subscriber(同家族,補收也可能造成重複)
- 企業整合模式
- 交易式用戶端 —— ★ 同一件事的另一面,兩者一起才完整
- 保證投遞 —— 它的代價就是重複
- 命令訊息 —— 最需要冪等的訊息類型
- 訊息序列 —— 版本號式的冪等
- 分割器 —— 子訊息的決定性 id
- 工具-MQ消費端防禦三原則 —— 冪等的完整實作
- 工具-交易與隔離等級 —— 唯一索引與併發控制