【ITニュース解説】Our webhook dedupe check was a race, not a guard
2026年10月24日に「Dev.to」が公開したITニュース「Our webhook dedupe check was a race, not a guard」について初心者にもわかりやすく解説しています。
ITニュース概要
Webhookの重複確認処理に不具合があり、複数のシステムが同時に未処理と判断し、同じ処理を二度実行する問題が発生した。原因は、データベースへの登録より先に確認を行う順序にあった。解決策は、先にユニーク制約付きでイベントIDを登録し、重複時は処理を停止することだ。これにより安全に重複処理を防げる。
ITニュース解説
Webサービス間で情報を連携する際に使われる仕組みの一つにWebhookがある。これは、あるサービスで特定のイベント(例えば、商品の購入など)が発生したときに、あらかじめ登録しておいた別のサービスへ、自動的にその情報を通知(HTTPリクエストを送信)する機能だ。Webhookはリアルタイムに情報をやり取りできるため非常に便利だが、インターネットの不安定さやシステムの一時的な遅延などが原因で、同じイベントの通知が複数回送られてくることがある。これを「重複通知」と呼ぶ。
今回のニュース記事では、この重複通知が原因で実際に起きた問題と、その解決策が語られている。問題は、顧客が一度の支払いに対して、領収メールを二重に受け取ったり、ポイントが二重に付与されたりしたというものだった。このような事態は顧客の不満につながり、企業の信頼性を損ねるため、システム側で同じイベントを複数回受け取っても、一度だけ処理する「重複排除」の仕組みが必須となる。
当初の重複排除方法は、非常に一般的でシンプルなものだった。イベントの通知を受け取ると、まずデータベースの「処理済みイベント」テーブルに、そのイベントIDが既に存在するかどうかを検索する。もし見つからなければ、そのイベントに関連する実際の処理(メール送信やポイント付与など)を実行し、最後にイベントIDを「処理済みイベント」テーブルに挿入して記録する、という手順だ。この方法は一見理にかなっており、実際、何ヶ月もの間は問題なく稼働していた。
しかし、ある特定の状況下でこの仕組みが破綻した。問題の原因は、通知元のサービスが、最初の通知に対するこちらのシステムの応答が遅かったため、イベントを再送したことだった。システム側がデータベースへの接続に時間を要し(約3秒)、通知元のタイムアウト設定(2秒)を超えてしまったため、再送が行われたのだ。この再送は最初の通知からわずか40ミリ秒後という短い間隔で発生し、そして、この二つの通知はそれぞれ異なるサーバー(Podと呼ばれる独立した処理単位)で同時に処理されることになった。
ここで発生したのが「レースコンディション」という現象だ。ほとんど同時に処理を開始した二つのサーバーは、どちらも「処理済みイベント」テーブルを検索した。この時点では、まだどちらのサーバーもイベントIDをテーブルに挿入していないため、両方とも「イベントIDは存在しない」という検索結果を得てしまう。その結果、両方のサーバーがそれぞれ、本来一度しか実行してはならない処理(領収メール送信、ポイント付与など)を実行してしまい、顧客に重複した通知や特典が届くことになったのだ。ログ上では通知元からの重複配送に見えたが、根本的な原因はシステム側の「先に存在するかチェックし、存在しなければ処理する」というロジックにあった。このような「チェックしてから処理する(check-then-act)」というパターンは、複数の処理が同時に実行される可能性があるシステムでは、今回のような問題を引き起こしやすい。
この問題に対する解決策は、重複排除のアプローチを根本から見直すことだった。新しい方法は、処理の順序を逆転させ、まずデータベースにイベントIDを挿入しようと試みる、というものだ。ただし、ただ挿入するだけではなく、挿入するイベントIDの列には「UNIQUE制約」という特別な設定を追加する。UNIQUE制約とは、その列に同じ値が二度と挿入されないようにデータベースが保証する機能だ。これにより、同じイベントIDを二度挿入しようとすると、データベース側で必ずエラー(重複エラー)が発生するようになる。
この「イベントIDの挿入」と、それに続く「本来の処理」(領収メール送信やポイント付与など)は、データベースの「トランザクション」という機能を使って一つのまとまりとして扱われる。トランザクションとは、複数のデータベース操作を論理的な一連の作業として扱い、その全てが成功するか、あるいは全てが失敗して元に戻るか(ロールバックされるか)、のどちらかになることを保証する仕組みだ。これにより、もしイベントIDの挿入が成功し、その後の処理で何らかのエラーが発生した場合でも、挿入されたイベントIDは自動的に取り消され、データの一貫性が保たれる。
新しい処理フローは以下のようになる。イベント通知を受け取ると、まずトランザクションを開始し、受け取ったイベントIDをUNIQUE制約付きのテーブルに挿入しようとする。 もしイベントIDの挿入が成功した場合、これはそのイベントを初めて処理することを示す。システムはそのまま、メール送信やポイント付与といった本来の処理を継続する。全ての処理が成功したら、トランザクションを確定(コミット)し、通知元に成功を示す200番台のHTTPステータスコードを返信する。 もしイベントIDの挿入時にUNIQUE制約違反が発生した場合(つまり、既に同じイベントIDが挿入されていた場合)、これはそのイベントが既に処理済みであるか、あるいは別のサーバーで同時に処理中であることを示す。この場合、システムはそれ以上の処理を行わず、トランザクションをロールバックし、通知元に成功を示す200番台のHTTPステータスコードを返信する。これにより、通知元は処理が成功したとみなし、それ以上の再試行を停止する。
この仕組みにより、たとえ複数の通知が同時に到着し、それぞれが異なるサーバーで処理され始めたとしても、最初にイベントIDの挿入に成功したサーバーだけがその後の処理を進める。他のサーバーはUNIQUE制約違反により挿入に失敗し、それ以上の処理を行わずに停止するため、重複処理が確実に防止される。また、システムに一時的な問題が発生してトランザクションが失敗した場合でも、挿入されたイベントIDはロールバックされ、次に通知が来た際にクリーンな状態で再試行できるため、イベントが失われる心配もない。
記事では二つの重要な詳細が補足されている。一つは、重複排除のキーとして「支払いID」ではなく「イベントID」を使用している点だ。これは、一つの支払いに関連して複数の異なるイベントが発生する可能性があるため、イベントごとにユニークなIDで管理する必要があるからだ。もう一つは、重複エラーが発生して処理を停止した場合でも、通知元には200番台のHTTPステータスコード(成功)を返す点だ。もしここで400番台のHTTPステータスコード(エラー)を返してしまうと、通知元は「処理に失敗した」と判断し、イベントが既に処理済みであるにもかかわらず、さらに再試行を続けてしまう可能性があるためだ。200番台を返すことで、通知元は処理が「受け付けられた」と判断し、無駄な再試行を避けることができる。
しかし、この完璧に見える解決策にも一つ課題がある。それは、メール送信のように、データベースのトランザクションに含めることが難しい「副作用」の処理をどう扱うか、という点だ。データベースのコミットと同時に外部サービスへの通信を完全に同期させることは一般的に困難だ。記事では、この問題に対する一般的な解決策として「Outboxテーブル」の利用を提案している。Outboxテーブルとは、トランザクション内で実行された副作用(メール送信など)の情報を一時的に保存する専用のテーブルのことだ。データベースのトランザクションが正常にコミットされた後、別のプロセスがこのOutboxテーブルを監視し、そこに記録された情報を元に実際の副作用(メール送信)を実行する、というパターンだ。これにより、データベースの更新と副作用の実行を、間接的ではあるが確実に連携させることができる。
今回のニュース記事は、単純に見えるロジックが、複数の処理が同時に発生する「レースコンディション」によって、いかに深刻な問題を引き起こしうるかを示している。そして、その解決策として、データベースのUNIQUE制約とトランザクションを組み合わせることで、システムの信頼性を劇的に向上させる方法を具体的に提示している。システムエンジニアを目指す上で、このような「冪等性」(同じ操作を何度行っても同じ結果になる性質)を担保する設計や、トランザクション管理、そして複数プロセス間の排他制御の重要性を理解することは非常に重要だ。多くのサービスと連携する現代のシステム開発においては、単一の処理だけでなく、全体としての一貫性と信頼性をどのように担保するかを常に意識した設計が求められる。