冪等性與 Exactly-Once 語義
說明 RabbitMQ 的 At-Least-Once 投遞語義,以及 Consumer 端如何透過冪等設計避免重複處理訊息
At-Least-Once 是 RabbitMQ 的基本語義
RabbitMQ broker 不對訊息持久性提供無條件保證。要讓訊息安全抵達 consumer,需要 durable queue、persistent message、publisher confirms 三者同時成立,而 DLX 轉送過程更是沒有 publisher 的盲區。結論是 RabbitMQ 採用的是 at-least-once 投遞——寧可多送,也不要丟。
這個選擇把責任推給了 consumer:既然 broker 可能把同一則訊息送超過一次,consumer 處理一次或多次就必須產生相同結果。這就是冪等性。
三種投遞語義
| 語義 | 意義 | 特徵 |
|---|---|---|
| At-most-once | 最多一次,可能丟 | 效能好,但不可靠 |
| At-least-once | 至少一次,可能重複 | 可靠,但 consumer 要處理重複 |
| Exactly-once | 剛好一次 | 分散式系統中無法嚴格達成 |
RabbitMQ、Kafka、SQS、NATS JetStream、RocketMQ 都選 at-least-once。真正的 exactly-once 被 FLP 不可能性與雙將軍問題證明無法在一般分散式環境下達成。工業界的共識是:broker 保證至少送一次,去重交給 consumer。
與其問「怎麼讓 RabbitMQ 不重複」,要問的是「我的 consumer 能不能安全吃下重複」。
重複從哪裡來
即使 broker、producer、consumer 都正常運作,重複仍會在幾個場景自然出現:
- Consumer 處理完但 ACK 前 crash:broker 沒收到 ACK,重新派送。
- ACK 在網路上遺失:broker 認為 consumer 掉線,requeue 派給別人。
- Retry 機制重送:DLX-based delayed retry、publisher confirm 逾時重發都會刻意或非刻意地重複。
- Cluster failover:Quorum queue 切換 leader 時,in-flight 訊息可能被新 leader 重投。
問題從來不是「會不會發生」,而是「發生時 consumer 會不會出錯」。
什麼是冪等
冪等(idempotent):同一筆請求送 1 次或送 100 次,系統最終狀態一樣。
| 操作 | 冪等? | 原因 |
|---|---|---|
SET balance = 100 | ○ | 再做一次結果一樣 |
balance = balance + 10 | × | 多做一次多加一次 |
DELETE WHERE id=5 | ○ | 已刪掉再刪也一樣 |
INSERT | × | 會多一筆 |
UPSERT / INSERT IGNORE | ○ | 有就跳過 |
| 發送 email | × | 收件人會收到兩封 |
| 呼叫第三方 API | 視 API 而定 | 看是否支援 idempotency key |
業務裡天生冪等的操作其實不多,多半要主動包裝。下面是四種實作方法。
方法一:唯一鍵 / 資料庫約束
讓資料庫擋重複。Message ID 當 primary key 或 unique index,重複插入失敗就當作已處理。
async def handler(message):
try:
await db.execute(
"INSERT INTO processed_orders (msg_id, data) VALUES ($1, $2)",
message.message_id, message.body,
)
except UniqueViolationError:
pass # 重複訊息,直接 ACK 丟掉
await message.ack()
簡單可靠,適合天生有業務 ID 的場景(例如訂單)。對「加錢」這類沒有明顯唯一性的 side effect 不適用。
方法二:去重表(Dedup Table)
把去重從業務表剝離出來,單獨一張 dedup table:
CREATE TABLE message_dedup (
message_id VARCHAR(64) PRIMARY KEY,
processed_at TIMESTAMP DEFAULT NOW()
);
async def handler(message):
async with db.transaction():
try:
await db.execute(
"INSERT INTO message_dedup (message_id) VALUES ($1)",
message.message_id,
)
except UniqueViolationError:
await message.ack()
return
await do_business_logic(message.body)
await message.ack()
關鍵:dedup insert 必須和業務邏輯在同一個 transaction。如果先插 dedup 再執行業務,中間 crash 會導致 dedup 記號存在但業務沒做,下次重送會被誤判為已處理,變成假冪等。
Dedup table 會無限成長,需要清理策略:TTL 欄位加定期清理、按日期 partition 整批 drop、或直接用 Redis SET key EX 86400。時間窗口抓「大於訊息可能重複的最大時間窗」即可,retry 最長一小時就設 24 小時。
方法三:狀態機(業務層冪等)
有明確生命週期的業務物件可以把冪等內建到狀態機。訂單從 PENDING 到 PAID 到 SHIPPED,只能從特定前置狀態轉入:
async def process_payment(message):
order = await get_order(message["order_id"])
if order.status == "PAID":
return # 已付過
if order.status != "PENDING":
raise InvalidStateError() # 非法狀態,進 DLQ
await mark_as_paid(order.id)
不需要 dedup table、不依賴 message ID,但要求業務有清楚的狀態轉換圖。不適合純事件、沒有中心狀態物件的場景(log 聚合、metrics)。
方法四:版本號 / CAS
樂觀鎖:
UPDATE orders SET status = 'PAID', version = version + 1
WHERE id = $1 AND version = $2
Version 對不上代表已被改過,這次是重複。配合 rowcount 判斷,無需 primary key 衝突也能達成冪等。
Message ID 從哪裡來
| 來源 | 優點 | 缺點 |
|---|---|---|
| Producer UUID | 通用、不需業務知識 | 重發會生新 ID,無法去重 |
| 業務 ID | 重發自動冪等、可追查 | 需要保證業務 ID 唯一 |
| 內容 hash | 內容相同自動去重 | 欄位微小變化就失效 |
能用業務 ID 就用業務 ID,並設到 AMQP 的 message_id property,讓 trace、log、監控讀到同一個識別。
await exchange.publish(
aio_pika.Message(
body=json.dumps({"order_id": "ORD-123", ...}).encode(),
message_id="ORD-123",
),
routing_key="order.created",
)
Exactly-Once 是神話嗎
嚴格的答案是:不能,任何分散式訊息系統都不能。FLP 不可能性與「網路可能任意丟包」從根本上禁止真正的 exactly-once——只要「訊息送出但回應遺失」存在,發送方就無法分辨「對方沒收到」和「對方收到但 ACK 丟了」。
Kafka 宣稱的 exactly-once 是封閉迴路內的 exactly-once:producer idempotence + transactional write 在「Kafka 讀→處理→Kafka 寫」這個迴路裡有效。一旦 consumer 寫到外部系統(資料庫、第三方 API、email),保證立刻失效——外部系統不參與 Kafka transaction。
所以 Kafka 也要求 consumer 在外部寫入時自己做冪等。RabbitMQ 沒有 producer 去重機制,但終點效果一樣:at-least-once + consumer idempotency ≈ 業務語義的 exactly-once。這是整條可靠性 chain 的最後一環,前面的 durable queue、persistent message、publisher confirms、DLX 監控讓「至少一次」成立,冪等 consumer 則把它收斂成「業務上只發生一次」。