課金は 1 回なのに、メールは 2 通送られていました。あるいは同じ注文が 2 回発送されていたり、同じ 10:00 の枠に 2 人がやって来たりします。ログを掘ると、同じ event.id が 14:02:07 に処理され、14:02:41 にもう一度処理されています。真っ先に疑うのはプロバイダーの不具合です。
プロバイダーは壊れていません。約束したとおりに動いています。Stripe、Shopify、Twilio をはじめ、主要な Webhook 送信元のほとんどは少なくとも 1 回の配信(at-least-once)を保証しています。at-least-once は厳密な意味を持つ用語で、重複も仕様のうちだということです。本当のバグは、配信が 1 回限り(exactly-once)であるかのように作ることです。このバグは本番環境でしか表に出ず、たいていはいちばん忙しい時間帯に現れます。
TL;DR
- at-least-once とは、重複がまれに起きるのではなく、いずれ必ず起きるという意味です
- 1 回限りの配信(exactly-once)は、いくら払っても手に入りません。手に入るのは 1 回限りの処理のほうで、それは受信側で作るものです
- プロバイダーのイベント ID にユニークインデックスを張れば、SQL 1 行で 90% は片づきます
- トランザクションに入らない副作用(メール、外部 API)には、確保してから確定する(claim-then-commit)パターンが必要です
- 順不同での到着は重複と対になる問題なのに、誰も備えていません。必要なのは重複排除ではなく、バージョン管理です
1 回限りの配信を誰も売らない理由
送信元の立場で考えてみてください。イベントを POST したところ、レスポンスが返る前に TCP 接続が切れました。サーバーは処理を終えていて、応答だけが失われたのかもしれません。処理に手をつける前にサーバーがダウンしたのかもしれません。外から見ると、この 2 つの状態は区別がつきません。
送信元の選択肢はちょうど 2 つです。リトライすれば、すでに処理済みだった場合は重複になります。リトライしなければ、処理されていなかった場合はイベントが永遠に失われます。
ジレンマはこれだけで、3 つ目の扉はありません。ほとんどのプロバイダーはリトライを選びます。重複した支払い通知は工夫で対処できる面倒ごとにすぎませんが、届かなかった支払い通知のほうは、検知できないまま売上が消えることを意味するからです。正しい判断です。ただしそれは、重複排除をどこかが担わなければならないということです。そのための十分な文脈を持っている場所は、手元のデータベースしかありません。
実際の重複の原因を、よく見かける順におおまかに並べます。
- レスポンスが遅れて送信元はタイムアウトしたが、ハンドラーは最後まで完了していたケース
- アプリが 200 を返した後に、手前のロードバランサーやプロキシが 502 を返したケース
- ハンドラーは成功したが、後片付けの途中で例外を投げ、フレームワークがそれを 500 に変えたケース
- 書き込みの後、レスポンスを返す前に、デプロイがプロセスを終了させたケース
- ダッシュボードで再送ボタンを押したことを忘れていたケース
この 5 つのうち 4 つは、処理は終わっていて応答だけが失われたケースです。珍しい例外ではなく、ごく普通のケースです。
パターン 1:イベント ID にユニークインデックス
1 つだけやるなら、これです。
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);
よく間違えられる点が 2 つあります。
重複には 409 ではなく 200 を返してください。正しく無視した重複は、送信元から見ればれっきとした成功です。エラーを返すと、送信元は「もう受け取った」と伝えたばかりのものをリトライします。ループの完成です。
ID は自分で振ったものではなく、プロバイダーのものを使います。Stripe なら event.id、GitHub なら X-GitHub-Delivery、Shopify なら X-Shopify-Webhook-Id です。ペイロードのボディをハッシュして代わりにするのは魅力的ですが、罠です。送信元がボディに ID を入れていない場合、本当に別々の 2 つのイベントがバイト単位で同じボディを持つことがあります。同じ顧客が同じ金額で 1 秒違いに出した 2 件の注文は、本物の 2 件の注文です。ボディのハッシュで重複排除すれば、代金が支払われた注文の処理を拒んだことになります。
プロバイダーが 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 までは及びません。メールは一度送ったら取り消せません。ここで取るべき誠実な手は、2 段階の記録です。
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 を見て引き下がるので、先を争うことはありません。
判断が必要なのは次の点です。確保と確定の間でプロセスが死に、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;
領収書や通知なら、再確保が正解です。2 通目のメールは安く済むからです。お金や物を動かす処理なら、行はそのまま残して人に知らせます。どちらを選んだのか、そしてなぜなのかを定数の横のコメントに書いておけば、次の担当者はひどく混乱する 1 時間を過ごさずに済みます。
パターン 4:順不同の配信にはバージョンガード
ここでは重複排除は役に立ちません。そして、実際にデータを壊すのはこの失敗です。
リトライがあるので、イベント #2 がイベント #1 を追い越すことがあります。その隙間を作るのが、まさにバックオフのスケジュールです。5 分待った配信は、その 5 分間に送られたものすべてより後に届きます。あるサブスクリプションが 14:00:01 に active に、14:00:03 に cancelled に更新されたとします。最初の配信が失敗し、5 分後にリトライされます。するとデータベースは active になり、顧客は解約したはずのものを使い続けられます。2 回処理されたイベントはありません。それでも結果は間違っています。
冪等性はここでは役に立ちません。必要なのは、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 を 1 つ送って結果を確かめるだけでは、これらのバグは見つかりません。書く価値のあるテストは次のとおりです。
- 同じイベントを 2 回続けて送ります。副作用が 1 回であることをアサートします
- 同じイベントを 2 回、今度は同時に送ります。ユニークインデックスの欠落を見つけるのはこのテストで、逐次実行のテストは決して失敗しません
- 2 つのイベントを、順序を入れ替えて送ります。新しいほうの状態が勝つことをアサートします
- 処理済みの記録の書き込みと副作用の間で、プロセスを強制終了します。パターン 3 で決めたとおりになることをアサートします
価値があるのは同時実行のテストです。冪等性のコードのほとんどは、配信が順番に来る前提で書かれています。そして、2 つのリトライが同じミリ秒に 2 つのインスタンスに届いた最初の瞬間に崩れます。通る Webhook のテストと、実際に動く Webhook のコードの間の溝については、「Webhook の署名検証テストは何も証明していない」で詳しく書きました。
AnyHook の位置づけ
AnyHook を使っても、受信側の冪等性が不要になるわけではありません。不要になると謳うサービスは誇大広告です。重複は送信元で生まれ、AnyHook と受信側の間のネットワークでも起こりえます。これは誰にも取り除けない層です。
変わるのは、問題を見えるようにできることです。
- すべてのイベントが、リクエスト全体、試行回数、最新のレスポンスとともに記録されます。「2 回送られたのか、1 回の配信でハンドラーが 2 回動いたのか」に、午前2時に 3 つのログを突き合わせなくても、ダッシュボードで答えられます
- どの配信にも試行回数が明示されるので、リトライと新しいイベントを区別できます
X-AnyHook-Deliveryヘッダーは、同じイベントのリトライではいつも同じイベント ID を運びます。上流のプロバイダーが ID を出し渋る場合でも、AnyHook と受信側の間で生まれる重複には、これを重複排除のキーとして使えます- 再送は意図して行うもので、印がつきます。再送したイベントには
is_replayフラグが立つので、補完のための再送が 1 週間後に謎の重複の急増に見えることはありません - ログは改ざんを検知できます。「正確に何が届いたのか、この行は編集されていないか」が問われるとき、書き換え可能なデータベースは証拠ではなく主張にすぎません:「改ざんを検知できる Webhook ログ」
まとめ
重複はプロバイダーの欠陥ではありません。イベントを決して失わないための代償で、その取引は正しいものです。今日のうちに、プロバイダーのイベント ID にユニークインデックスを張ってください。次にそのコードに触れるときは、処理済みの記録をトランザクションに移します。そしてライフサイクルを持つもの(サブスクリプション、注文、デプロイ)を扱うなら、1 か月気づかないような古い状態を順不同の配信が書き込む前に、バージョンガードを追加してください。