可靠性2026年8月3日閱讀時間 9 分鐘

同一個 Webhook 收到兩次:這是約定好的行為,不是 bug

多數主要服務商採至少一次(at-least-once)傳遞:重複一定會發生,不是例外。四種冪等做法,依保護程度由低到高排序。

從發送端看,這兩種情況一模一樣服務商POST處理程式執行了訂單已履約回應在傳輸途中遺失服務商POST先當機了什麼都沒發生同樣沒有回應重試 → 一個可以靠設計處理的重複不重試 → 無聲無息、無從察覺的營收損失。每家服務商都選擇重試。
發送端的兩難,以及為什麼多數服務商都用同一種方式解決。

有人只被扣款一次,卻收到兩封信。或是同一筆訂單出貨兩次,或是兩個人都在 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,客戶已經取消的東西,卻還能繼續使用。沒有任何事件被處理兩次,結果仍然是錯的。

依序送出active 14:00:01cancelled 14:00:03重試後才抵達cancelledactive(晚 5 分鐘)沒有任何事件被處理兩次。那一列寫的卻還是 active。解法是單調遞增的防護條件,不是去重表:WHERE updated_at < $new
這裡用去重是用錯工具。沒有任何東西被送了兩次,只是順序錯了。

冪等性在這裡幫不上忙。你需要的是在 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 上建唯一索引。下次動到這段程式碼時,把標記移進交易裡。如果你處理的東西有生命週期(訂閱、訂單、部署),在順序錯亂寫入一個你一個月都沒發現的過期狀態之前,先加上版本判斷。

所有文章2026年8月3日 · 9 分鐘

別再漏掉 Webhook。

換一個網址,就有重試、事件紀錄和一鍵重播。或者先親眼看看:註冊後跑一次 30 秒的當機演練,看著一個事件在伺服器當機時保存下來。不需要伺服器。