結論先講

分散式系統只有三種訊息傳遞語意:At-Most-Once(可能丟)、At-Least-Once(可能重複)、Exactly-Once(理論上做不到)。 大部分系統選 At-Least-Once + Idempotent Consumer——接受重複,但讓重複處理的結果和只處理一次一樣。


坑 1:Exactly-Once Delivery 不存在

為什麼做不到

Producer → Broker → Consumer

步驟 1: Producer 送訊息給 Broker ✅
步驟 2: Broker 送訊息給 Consumer ✅
步驟 3: Consumer 處理完成,回 ACK 給 Broker
         ↑ 如果 Consumer 處理完了但 ACK 在網路中丟了呢?
         → Broker 以為沒處理 → 重送 → Consumer 處理了兩次

在分散式環境下,「送達」和「處理」和「確認」是三個不同的步驟,任何一步都可能失敗。你不可能同時保證「一定送到」和「只送一次」。

解法:Idempotent Consumer

不試圖保證「只處理一次」,而是保證「處理 N 次的結果和 1 次一樣」。

async function handleOrderCreated(event) {
  // 用 event ID 做冪等檢查
  const exists = await db.query(
    'SELECT 1 FROM processed_events WHERE event_id = ?', [event.id]
  );
  if (exists) return; // 已處理過,跳過
 
  await db.transaction(async (t) => {
    await Product.decrement('stock', { ... }, { transaction: t });
    await db.query(
      'INSERT INTO processed_events (event_id) VALUES (?)', [event.id],
      { transaction: t }
    );
  });
}

關鍵:用一個 processed_events 表記錄已處理的 event ID。處理和記錄在同一個 transaction 裡——要嘛都成功,要嘛都失敗。


坑 2:訊息順序不保證

場景

Event 1: order.created (10:00:01)
Event 2: order.cancelled (10:00:02)

你期望的順序:先 created 再 cancelled。

但 Kafka 的 partition 之間沒有順序保證。如果兩個 event 進了不同的 partition:

Partition 0: [order.cancelled]  ← 先被消費
Partition 1: [order.created]    ← 後被消費

Consumer 先收到 cancelled、再收到 created → 最終狀態是 created(應該是 cancelled)。

解法

1. 同一個 entity 的 event 送到同一個 partition

Kafka:用 orderId 當 partition key,保證同一筆訂單的所有 event 進同一個 partition。同一個 partition 內的順序是有保證的。

producer.send({
  topic: 'orders',
  messages: [{ key: orderId, value: JSON.stringify(event) }]
});

2. Event 帶版本號

{ "type": "order.status_changed", "version": 3, "data": { "status": "cancelled" } }

Consumer 只處理比目前版本大的 event,忽略舊版本。


坑 3:Consumer 掛了,怎麼恢復

Kafka 的 Offset 機制

Kafka 的 Consumer 用 offset 記錄「讀到哪裡了」。Consumer 掛了重啟,從上次 commit 的 offset 繼續讀。

但如果 Consumer 處理完了、offset 還沒 commit 就掛了呢?

→ 重啟後從上次 commit 的 offset 開始 → 重複處理(回到坑 1 的 idempotent 問題)。

RabbitMQ 的 ACK 機制

RabbitMQ 要求 Consumer 明確 ACK 才算消費完成。如果 Consumer 掛了沒 ACK,訊息會重新回到 queue 被其他 Consumer 拿走。

同樣的問題:處理完但還沒 ACK 就掛 → 重複處理。

結論

不管用 Kafka 還是 RabbitMQ,Consumer 都必須是 idempotent 的


坑 4:Dead Letter Queue 必須有

什麼是 DLQ

Consumer 處理一個 event 失敗了(parse error、業務邏輯錯誤、下游服務掛了)。你可以:

  • 無限重試:可能永遠成功不了,Queue 卡住
  • 直接丟掉:資料遺失
  • 送到 Dead Letter Queue:人工處理

DLQ 是「處理失敗的 event 的暫存區」。運維人員可以檢查 DLQ 裡的 event,修好問題後重新處理。

一定要設的原因

沒有 DLQ 的話,一個格式錯誤的 event 會讓 Consumer 進入「失敗 → 重試 → 失敗 → 重試」的無限迴圈,後面排隊的正常 event 全部被擋住。


坑 5:Event Schema 變更

場景

v1 的 event:

{ "type": "order.created", "data": { "amount": 1500 } }

v2 改了結構:

{ "type": "order.created", "data": { "totalAmount": 1500, "currency": "TWD" } }

如果 Producer 升到 v2 但 Consumer 還是 v1 → Consumer 找不到 amount 欄位 → 報錯。

解法:向後相容

1. 只增不刪

新增欄位沒問題,舊 Consumer 會忽略不認識的欄位。刪除或重命名欄位會破壞舊 Consumer。

2. 版本號

Event 帶 version 欄位。Consumer 根據 version 決定怎麼 parse。

3. Schema Registry

Kafka 有 Schema Registry,可以在 produce 時強制驗證 schema 相容性。不相容的 event 會被擋下來。


下一篇

框架選型方法論:不是選最快的,是選最適合的 — 團隊能力 × 硬體預算 × 生態需求 = 框架選擇。把壓測數據轉化成真正的決策方法論,不是比排名而是建決策框架。


本系列文章

完整 68 篇目錄見 系列首頁

← 上一篇:Event-Driven 架構入門:為什麼微服務離不開它 → 下一篇:框架選型方法論:團隊能力 × 硬體預算 × 生態需求