【ITニュース解説】The webhook dedupe everyone copies has a hole in it
2026年09月19日に「Dev.to」が公開したITニュース「The webhook dedupe everyone copies has a hole in it」について初心者にもわかりやすく解説しています。
ITニュース概要
ウェブフックの重複排除の一般的なやり方には問題点がある。処理中にシステムがクラッシュすると、「処理済み」と記録されても実際の作業が行われず、イベントが消えることがあるのだ。原因は、記録と実際の作業が別々にコミットされること。解決策は、これらを同時に行うか、処理の状態を細かく管理すること。根本的には、何度実行されても問題ない処理にすることが重要である。
ITニュース解説
多くのウェブサービスやアプリケーションは、イベントが発生した際に他のシステムにその情報をリアルタイムで通知する仕組みを使っている。これが「Webhook」と呼ばれる技術である。例えば、ユーザーがサービスで何かを購入した際に、支払いシステムから在庫管理システムへ「注文が入った」という情報がWebhookで送られる、といった具合である。しかし、このWebhookには一つ大きな課題がある。それは、同じイベントが複数回送られてくる可能性がある、ということだ。
なぜ同じイベントが複数回送られてくるのだろうか。システム間の通信は常に完璧ではない。Webhookの送信元は、イベントを確実に届けようとする。そのため、イベントを送信した後、受信側からの「受け取ったよ」という応答が届かなかったり、応答が遅すぎたりすると、「送信に失敗した」と判断し、同じイベントをもう一度送ることがある。これを「最低1回配送保証」という。システムとしてはイベントを「失う」よりは「重複して送る」方を選ぶため、受信側は同じイベントが複数回届く可能性を常に考慮して設計する必要がある。この重複したイベントを一つにまとめ、一度だけ処理するようにする技術を「デデュープ(重複排除)」と呼ぶ。
一般的なデデュープ手法として、データベースのユニーク制約を利用する方法がよく知られている。具体的には、イベントを識別するためのユニークなキー(イベントキー)をprocessed_eventsというテーブルに記録する。データベースのSQL文で言えば、「INSERT INTO processed_events (event_key) VALUES ($1) ON CONFLICT (event_key) DO NOTHING RETURNING event_key;」のような形になる。このSQL文は、もしevent_keyが初めてデータベースに挿入される場合はそのevent_keyを返し、すでに存在する場合は何も返さない、という動作をする。開発者は、このSQL文がキーを返してきたら「自分がこのイベントの最初の処理者なので、実際の作業を開始する」と判断し、何も返してこなければ「誰かがすでに処理を完了しているか、処理中なので、何もしない」と判断して、送信元に「200 OK」を返すのである。この方法は一見、シンプルで確実に見える。データベースがキーの重複を原子的に(途中で中断されない一つの処理として)扱ってくれるため、同時に複数の処理が走っても正しく重複を排除できる。
しかし、この一般的なデデュープ手法には実は「落とし穴」が存在する。それは、イベントキーをデータベースに挿入し、「よし、処理を開始しよう」と判断した直後、実際の作業(例えば注文の履行など)が完了する前に、システムがクラッシュしたり、プロセスが強制終了したりするケースである。この場合、データベースにはイベントキーが「処理済み」として残ってしまうが、実際の作業は一切行われていない。その後、送信元から同じイベントが再送されてきても、データベースにはすでにキーが存在するため、システムは「このイベントはすでに処理済みだ」と誤判断し、何もせずに「200 OK」を返してしまう。結果として、そのイベントは永久に処理されることなく「消失」してしまうのである。この問題は、エラーメッセージも発生しないため、開発者が気づきにくく、顧客からの問い合わせで初めて発覚するといった事態に陥りやすい。
この落とし穴を塞ぐための対策の一つは、「単一のトランザクション」を使うことである。もし、イベントキーの登録と、そのイベントに対する実際の作業の両方が、同じデータベース内での操作で完結するのであれば、これら一連の操作を一つの「トランザクション」としてまとめることができる。トランザクションとは、複数のデータベース操作を論理的な一単位として扱い、その中のすべての操作が成功するか、あるいは途中で失敗した場合はすべての操作が実行される前の状態に戻される(ロールバックされる)仕組みである。これにより、キーの登録と実際の作業のどちらか一方だけが実行されて中途半端な状態になることを防ぎ、システムが途中でクラッシュしても、データベースは常に整合性の取れた状態を保つ。イベントキーは登録されず、次のリトライで再び処理される機会が生まれるため、イベントの消失を防げる。ただし、この方法は、実際の作業がデータベース外のサービス(外部API呼び出しやメール送信など)を含む場合には適用できない。また、トランザクションを開いている間はデータベースのリソースを占有するため、処理時間が長くなるような場合にはシステム全体のパフォーマンスに影響を与える可能性もある。
トランザクションでカバーできない、外部サービスとの連携を含むケースでは、より高度な「状態管理」を導入する必要がある。これはprocessed_eventsテーブルに、イベントの処理状況を示すstatusカラム(例えば「in_progress:処理中」や「done:完了」)と、処理を開始した時刻を示すclaimed_atカラムを追加する方法である。イベント処理を開始する際には、単にキーを挿入するだけでなく、「INSERT INTO processed_events (...) VALUES (...) ON CONFLICT (event_key) DO UPDATE SET status = 'in_progress', claimed_at = now() WHERE processed_events.status = 'in_progress' AND processed_events.claimed_at < now() - interval '5 minutes' RETURNING event_key;」のようなSQL文を使う。このSQL文は、イベントキーがまだ存在しない場合は新規にin_progress状態で挿入し、すでに存在する場合はin_progress状態かつ一定時間以上経過している(つまり、以前の処理者がクラッシュした可能性が高い)場合にのみ、ステータスをin_progressに更新して処理の所有権を「横取り」し、そのキーを返す。
この複雑な更新操作の結果、三つのパターンが発生する。一つ目は、自身がイベントの所有権を得て処理を開始するケースである。この場合、実際の作業を行い、完了後にデータベースのstatusをdoneに更新する。このdoneへの更新が、今後のリトライに対して「このイベントはもう処理済みだ」と伝える唯一の手段となる。二つ目は、イベントがすでにdone状態だったケースである。この場合は他の処理者がすでに作業を完了しているので、自身は何もせずに「200 OK」を返せばよい。三つ目は、イベントがin_progress状態であり、かつclaimed_atも新しい(つまり、他のワーカーが現在そのイベントを処理中である)ケースである。この場合は、自身は処理を行わず、送信元に「今は処理できないから、後でリトライしてほしい」という意図を示すHTTPステータスコード(例えば「409 Conflict」や「503 Service Unavailable」)を返す。ここで安易に「200 OK」を返してしまうと、もし処理中のワーカーがクラッシュした場合に、イベントが消失してしまうリスクがある。claimed_atからの経過時間(リース時間)は、実際の処理にかかる時間よりも長く、かつクラッシュしたワーカーがイベントをブロックしすぎないように慎重に設定する必要がある。
これらのデデュープ処理は重要だが、究極的には「冪等性(べきとうせい)」という考え方をシステムに取り入れることが最も堅牢な防御策となる。冪等性とは、同じ操作を何回実行しても、一度実行したときと同じ結果が得られる性質のことである。デデュープテーブルは、設定ミスや予期せぬ障害で機能しなくなる可能性がある。その場合でも、二重課金やデータの不整合といった問題が発生しないように、イベントハンドラー自体を設計しておくべきである。例えば、データベースのテーブルにビジネス上のユニークなキー(注文IDなど)に対するUNIQUE制約を設定し、重複するデータが挿入されないようにする。また、状態を更新するような処理では、「UPDATE invoices SET status = 'paid' WHERE id = $1 AND status = 'pending';」のように、更新前の現在の状態を確認してから更新する条件付きSQL文を使用する。これにより、すでに「paid」状態の請求書に対して再度「paid」に更新する指示が来ても、何も影響を与えない。このような設計がなされていれば、デデュープ層は「無駄な作業を避けるための最適化」という位置づけになり、たとえデデュープが一時的に機能しなくても、システムは正しく動作し続けることができる。
デデュープ処理を機能させる上で、イベントを一意に識別するための「キー」を適切に選定することは極めて重要である。同じイベントの再送であっても、キーが変わってしまってはデデュープが成立しないからである。そのため、StripeのidフィールドやGitHubのX-GitHub-Deliveryヘッダのように、各サービスが提供する、再送時にも変わらないイベントIDを使用すべきである。システムがリクエストごとに生成するUUIDや、イベントを受信した時刻のタイムスタンプ、リトライ回数などは、再送のたびに値が変わってしまうため、絶対にキーとして使用してはならない。これらを使ってしまうと、同じイベントの再送が別々のイベントとして扱われ、デデュープテーブルが無意味な重複ログになってしまう。また、ペイロード内のオブジェクトID(例えば注文ID)をキーにすることも注意が必要である。一つの注文に関連して、注文作成、支払い成功、配送完了など複数の異なるイベントが発生する場合、すべてに同じ注文IDが使われていても、それらは別々のイベントである。オブジェクトIDをキーにしてしまうと、最初のイベントでキーが登録された後、関連する他のイベントがすべて「重複」と見なされ、処理されずに消えてしまう可能性がある。キーは「そのイベント自体」を一意に識別するものである必要がある。
最後に、デデュープ処理が正しく機能しているかをテストする方法も理解しておく必要がある。テストは主に二種類ある。一つ目は、同じイベントを連続して二回送信し、システム全体でそのイベントに対する副作用(例えばデータベースへの書き込みや外部サービスへの通知など)が一度だけ発生することを確認するテストである。これは、イベントキーの選定ミスがないかを確認するために有効である。二つ目は、デデュープ処理の「落とし穴」を意図的に再現するテストである。具体的には、イベントキーの登録と、実際のイベント処理の間に、意図的にプログラムを強制終了させるコード(例えばsys.exit(1))を挿入する。この状態でイベントを送信し、プロセスがクラッシュしたことを確認する。その後、システムを再起動し、同じイベントをもう一度送信する。このとき、最終的にイベントが期待通りに処理されていることを確認するのである。もし二回目の送信でシステムが何もせず「200 OK」を返してしまい、実際の処理が行われていなければ、先に説明した「落とし穴」が残っていることになる。これらのテストを通じて、デデュープ処理の堅牢性を確認することが可能である。