有人只被扣款一次,卻收到兩封信。或是同一筆訂單出貨兩次,或是兩個人都在 10:00 來赴同一個預約。你翻開 log,發現同一個 event.id 在 14:02:07 處理過一次,14:02:41 又處理了一次,第一個念頭就是服務商壞了。
服務商沒壞,它做的正是它承諾的事。Stripe、Shopify、Twilio,以及多數有分量的 Webhook 發送端,保證的都是至少一次(at-least-once)傳遞。至少一次是一個精確的術語:重複本來就是約定的一部分。把傳遞當成恰好一次來設計,才是真正的 bug,而且它只會在正式環境浮現,通常是在你最忙的那個小時。
重點摘要
- 至少一次的意思是:重複遲早一定會發生,而不是很少發生。
- 恰好一次的傳遞,花再多錢也買不到。恰好一次的處理做得到,但要在你這邊自己實作。
- 在服務商的事件 ID 上建唯一索引,一行 SQL 就解決 90% 的問題。
- 無法放進交易的副作用(寄信、第三方 API),需要先認領、再提交(claim-then-commit)的模式。
- 順序錯亂是沒人事先規劃的姊妹問題,需要的是版本判斷,不是去重。
為什麼沒有人賣恰好一次
想像一下發送端的處境。它把事件 POST 給你,回應還沒回來,TCP 連線就斷了。你的伺服器可能已經完整處理完,只是確認收到的回應弄丟了;也可能在碰到事件之前就當機了。從外面看,這兩種狀態一模一樣。
發送端只有兩個選擇。重試:如果你已經處理過,現在就多了一份重複。不重試:如果你根本沒處理到,這個事件就永遠遺失了。
兩難就是這樣,沒有第三條路。多數服務商選擇重試,因為重複的付款通知只是麻煩,可以靠工程手段處理;遺漏的付款通知,卻是你察覺不到的無聲營收損失。它們的選擇是對的。只是這代表去重必須在某個地方發生,而唯一有足夠脈絡的地方,是你的資料庫。
實際造成重複的來源,大致依我們看到的頻率排列:
- 你的回應太慢,發送端逾時了,但處理程式其實還是跑完了。
- 你的應用程式回了 200,前面的負載平衡器或代理卻回了 502。
- 處理程式成功了,卻在清理階段拋出例外,框架把它變成了 500。
- 部署在寫入之後、回應之前,把 process 砍掉了。
- 你在某個儀表板按了「resend」(重新傳送),然後忘了。
注意,這五種裡有四種,都是工作已經做完,只是確認收到的回應弄丟了。這是常態,不是罕見的特例。
模式 1:在事件 ID 上建唯一索引
如果只做一件事,就做這個。
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
provider TEXT NOT NULL,
processed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
const { rowCount } = await db.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT (event_id) DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return new Response(null, { status: 200 }); // already handled
await doTheWork(event);
有兩個細節常被做錯。
遇到重複時回 200,不要回 409。從發送端的角度看,你正確忽略的重複是一次成功。回傳錯誤,發送端就會重試這個你剛說過已經收到的東西,你等於自己造了一個迴圈。
ID 必須是服務商給的,不是你自己產生的:Stripe 的 event.id、GitHub 的 X-GitHub-Delivery、Shopify 的 X-Shopify-Webhook-Id。拿 payload 的雜湊值來代替很誘人,但這是陷阱:發送端沒在請求主體放 ID 時,兩個確實不同的事件,請求主體可能逐位元組完全相同。同一位客戶、同樣金額、相隔一秒的兩筆訂單,是兩筆真實的訂單。用請求主體的雜湊去重,你就拒絕履行了一筆客人已經付款的訂單。
如果服務商完全不給 ID,你就得從真正唯一的業務欄位,自己組出一個合成鍵。這個處境更糟,值得在程式碼裡留個註解,說明為什麼這樣選。
模式 2:把標記和工作放在同一個交易裡
模式 1 有個漏洞。如果 INSERT 已經提交,接著 doTheWork 拋出例外,你就把一個沒處理的事件標成了已處理。之後的重試會被忽略,事件就這樣無聲無息地遺失,這比你原本想防止的重複還要糟。
如果工作本身是資料庫操作,就用交易補上這個漏洞:
await db.transaction(async (tx) => {
const { rowCount } = await tx.query(
`INSERT INTO processed_events (event_id, provider)
VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[event.id, "stripe"],
);
if (rowCount === 0) return; // duplicate, commit an empty tx
await tx.query(`UPDATE orders SET status = 'paid' WHERE id = $1`, [orderId]);
});
兩者要嘛都寫入,要嘛都不寫入。這是正確的寫法,預設就該用它。
模式 3:無法回滾的副作用,用先認領、再提交
交易管不到 Resend、Salesforce 或物流 API。信寄出去就是寄出去了。這時老實的做法,是兩階段的標記:
CREATE TABLE processed_events (
event_id TEXT PRIMARY KEY,
status TEXT NOT NULL, -- 'processing' | 'done'
claimed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ
);
做任何事之前,先認領這個事件。接著做事,再把它標成完成。處理途中同時抵達的重複事件,會看到 processing 而退開,不會跟你搶著處理。
需要你做決定的部分是:process 在認領和提交之間當掉,卡在 processing 的資料列該怎麼辦?你無法知道信到底寄出去沒有。兩種選擇都說得通,但你應該刻意做出決定:
// Rows stuck in 'processing' >15min are re-claimed. Biases toward
// a possible duplicate email over a definitely-missing one. Flip this for
// side effects where duplication costs money (charges, shipments).
const STALE_CLAIM_MS = 15 * 60 * 1000;
收據和通知,重新認領是對的,因為多寄一封信的代價很小。凡是牽涉金錢或貨物的,就讓它卡著,並發出警示交給人處理。在常數旁邊用註解寫下你選了哪一種、為什麼,能幫下一個人省下非常困惑的一個小時。
模式 4:用版本判斷處理順序錯亂
這裡去重幫不上忙,而真正會讓資料出錯的,就是這種失敗。
有重試,就代表事件 #2 可能超車事件 #1,而拉開這個落差的,正是退避排程:等了五分鐘的傳遞,會比這五分鐘內送出的所有東西都晚到。一個訂閱在 14:00:01 更新為 active,在 14:00:03 更新為 cancelled。第一次傳遞失敗,五分鐘後重試。你的資料庫現在顯示 active,客戶已經取消的東西,卻還能繼續使用。沒有任何事件被處理兩次,結果仍然是錯的。
冪等性在這裡幫不上忙。你需要的是在 WHERE 子句裡加上單調遞增的判斷:
UPDATE subscriptions
SET status = $1, updated_at = $2
WHERE id = $3
AND updated_at < $2; -- refuse to apply stale state
服務商給你什麼單調遞增的欄位,就用什麼:version 欄位,或物件本身的 updated_at。不要用你自己的 NOW(),它反映的是你收到的時間,而出錯的恰恰就是這個順序。
有些服務商既不給唯一 ID,也不保證順序。HubSpot 的文件寫明它的 eventId 不保證唯一,而且批次送出的事件沒有任何順序承諾;該改用什麼去重,是另一個問題。
如果狀態機有真正的不變條件,就再進一步:直接拒絕不合法的狀態轉換,而不是依賴時間戳記。從 expired 轉成 active 應該拋出錯誤,而不是默默套用。
關於測試
送一個 Webhook、檢查結果,是找不到這些 bug 的。值得寫的測試是:
- 連續送同一個事件兩次。斷言只產生一次副作用。
- 同時並行送同一個事件兩次。能抓到漏掉唯一索引的就是這個測試,循序的測試永遠不會失敗。
- 顛倒順序送兩個事件。斷言較新的狀態勝出。
- 在寫入標記和執行副作用之間砍掉 process。斷言結果符合你在模式 3 做的決定。
真正有價值的是並行測試。多數冪等程式碼都假設傳遞是循序的,第一次遇到兩次重試在同一毫秒落到兩個執行個體上,就垮了。測試會過的 Webhook 程式碼和真正能用的 Webhook 程式碼之間有多大落差,我們在你的 Webhook 簽章測試什麼都證明不了寫得更多。
AnyHook 的角色
AnyHook 不會讓你這邊不再需要冪等性,任何聲稱可以的服務都是在誇大。重複源自發送端,也可能發生在我們和你之間的網路上,這一層沒有人能消除。
它改變的,是你看見問題的能力:
- 每個事件都連同完整請求、嘗試次數和最新一次回應一起記錄。「這是被送了兩次,還是我的處理程式在一次傳遞上跑了兩次」這個問題,在儀表板上就能回答,不用在凌晨 2 點比對三個來源的 log。
- 每次傳遞都明確標示第幾次嘗試,所以重試和新事件分得出來。
- 同一個事件的每次重試,
X-AnyHook-Delivery標頭都帶著同一個事件 ID。即使上游服務商吝於提供去重鍵,對發生在我們和你之間的重複,你也有一個可用的去重鍵。 - 重播是刻意的操作,而且有標記。重播的事件會標上
is_replay,所以一週後回頭看,回填不會像是一波來路不明的重複尖峰。 - 事件紀錄可偵測竄改。當問題是「到底收到了什麼,這筆資料有沒有被改過」,可以修改的資料庫只是一種說法,不是證據:可偵測竄改的 Webhook 事件紀錄。
結論
重複不是服務商的缺陷,而是永不遺失事件的代價,這是正確的取捨。今天就在服務商的事件 ID 上建唯一索引。下次動到這段程式碼時,把標記移進交易裡。如果你處理的東西有生命週期(訂閱、訂單、部署),在順序錯亂寫入一個你一個月都沒發現的過期狀態之前,先加上版本判斷。