「あの、特定の時刻にだけ二重登録が発生するんです...」Webhookにおける再同期設計の落とし穴
はじめに
株式会社Sun Asterisk でバックエンドエンジニアをしている三浦です。
今年で3年連続のアドベントカレンダー参加です!
🖊️昨年のアドベントカレンダーの記事はこちら🖊️
最近はどうしてもAI関連の記事が多くなりがちだったので、久しぶりに技術的なトラブルシューティングの記事を書こうと思います。
本記事ではWebhookの特性を基本的なところからおさらいしつつ、信頼性を高めるための設計とその注意点を、今回発生した事例を交えてご紹介します。
今後の設計に活かしていただいたり現在の設計を見直すきっかけになれば幸いです!
概要
まずは事象の概要です。
- Shopifyアプリと基幹システム間のデータ連携サービス(Subscriberアプリ)を開発・運用している
- 基幹システムへの連携に対し、特定の時刻にだけ二重登録が発生していた
- 発生頻度は稀(少なくとも過去数年間は発生していなかった)
私が運用を担当し始めたのは開発が終わり運用フェーズに入ってからだったため、過去の実装や設計意図などは全て把握できておらず手探りでの調査となりました。
システム構成
主な構成は以下の通りです。
ShopifyからのWebhookイベントを受信し、SQSを介して基幹システムに登録処理を行う構成です。
基本的に(ほぼ)リアルタイムでのデータ連携を行っており、Shopifyで注文が発生するとWebhookがSQSに送信され、Subscriberアプリがメッセージを受信し基幹システムへの登録処理を行います。
これに加え、定期的にShopifyからデータを取得して整合性を保つバッチ処理も存在します。
Webhookの特性と注意点
ここでは最初にWebhookを含むイベントデータの連携方式の特性と、それを踏まえたシステム設計の注意点について説明します(ご理解いただいている方は読み飛ばしてください)。
Webhookとポーリング方式
まず、イベントデータの連携方式としてWebhook方式とポーリング方式がよく対比で説明されます。
Webhookはイベント駆動型の通信方式であり、特定のイベントが発生した際に指定されたURLにHTTPリクエストを送信するプッシュ型の仕組みです。
例えばShopifyであれば、注文の作成や更新、顧客情報の変更などのイベントが発生した際すぐにWebhookが送信されます。
Webhook方式
これに対し、ポーリング方式は受信側が定期的に送信側に問い合わせを行いイベントの有無を確認するプル型の仕組みです。
そのためポーリング間隔によっては遅延や送信側への負荷増加に繋がる可能性があります。
ポーリング方式
どちらの方式にも一長一短がありますが、Webhookはポーリング間隔に依存しないためリアルタイム性に優れ、受信側はただ待ち受けるだけでよいためシステム間の結合度を低く保てる点がメリットになります。
本プロジェクトではWebhookを採用しているため、以降はWebhookの特性に焦点を当てて説明します。
Webhookの特性
Webhookを利用したシステム設計においては、以下のような特性を理解しておく必要があります。
- 重複イベントの発生: 一般的には 「少なくとも一度は配信する(at-least-once delivery)」 モデルが採用されており、同じイベントが複数回送信されることがある。他には「一度だけ配信する(exactly-once delivery)」や「最大一度だけ配信する(at-most-once delivery)」モデルも存在するため、利用するサービスの仕様は確認が必要
- 順序保証の欠如: 複数のイベントが同時に発生した場合、受信側での処理順序が保証されないことがある
加えて、今回使用しているShopifyのWebhookに関しては全ての配信が保証されているわけではありませんでした。
公式ドキュメントのベストプラクティスにおいては以下のように記載されています。
Your app shouldn't rely solely on receiving data from Shopify webhooks. Because webhook delivery isn't always guaranteed, you should implement reconciliation jobs to periodically fetch data from Shopify.
Webhookを利用したシステム設計の注意点
以上の特性から、Webhookを利用したシステム設計においては以下の点に注意する必要があります。
- 冪等性の確保: 同じイベントが複数回処理されても結果が変わらないように設計する
- 順序依存の排除: イベントの処理順序に依存しない設計とする。順序が重要な場合はFIFOキューの利用を検討する
そして、ShopifyのWebhookのように1回以上の配信が保証されていない場合は、以下の点も考慮する必要があります。
- イベントの取りこぼしへの対応: 定期的なデータ取得や再同期の仕組みを設ける
先ほどのシステム構成においてバッチ処理で定期的な同期を行っていたのは、このイベントの取りこぼしや配信の遅延への対応を目的としています。
原因特定の過程
それではWebhookの特性と設計上の注意点を理解した上で原因特定の過程について順に追っていきます。
Subscriberアプリのログ分析とロック処理の確認
まずは該当注文のログを確認しました。
実際に二重での登録処理が走っていることが確認できれば、リアルタイム処理における問題である可能性が高いと分かるからです。
しかし、そのようなログは見当たりませんでした。
一応ログの見落としも考慮して、発生している現象におけるロック処理についても正しく機能しているか確認しました。
これについては注文発生時のメッセージを模したスクリプトを用意し、ロック処理が正しく機能しているかをチェックしました。
結果、ロック処理自体は正しく機能しており、リアルタイム処理における問題ではないことがわかりました。
基幹システムのログ分析
基幹システム側のログも確認しました。
こちらでは、実際にAPI経由で二重での登録処理が実行されていることが確認できました。
つまり、AWSの基盤上から基幹システムに対する登録処理が二重で実行されていることは間違いありません。
しかし、ログ上ではSubscriberアプリからの登録処理は一度しか実行されていないため、原因が何か分からない状態でした。
(ここまでバッチ処理での注文登録は考慮しておらず完全に見落としていました)
発生事象の共通点の分析
ここで、改めて注文情報を整理しました。
二重登録が発生したデータは2024年から2025年にかけて合計4件発生しており、2024年9月に1件、12月に2件、そして2025年1月に1件発生していました。
これらの注文情報の詳細を整理した結果、なんとちょうど特定の時刻(12:01)に二重登録が発生していることが判明しました。
ここでようやくバッチ処理が怪しいと気づき、各バッチ処理の内容と実行スケジュールを確認しました。
すると、まさにその時刻に注文同期用のバッチ処理が実行されていることがわかり、これが原因であると確信しました。
(12:00実行。処理の完了が大体12:01頃となる)
バッチ処理の調査
バッチ処理の内容を詳しく調査したところ、Shopifyから注文データを取得し、基幹システムに登録する処理が含まれていました。
しかし、このバッチ処理にはリアルタイム処理と同様のロック処理が実装されておらず、バッチ処理がリアルタイム処理と競合する可能性があることが判明しました。
解決方法
判明した登録経路はリアルタイム処理とバッチ処理の2箇所だけであり、将来的にも増える予定はなかったため、今回の対応としてはバッチ処理側にロック処理を追加することで問題を解決しました。
ただし、本来理想の対応は登録処理を一箇所に集約することだと思います。
複数の経路から同じリソースへ登録する場合、実装漏れを回避するためにも可能であれば登録処理を一箇所に集約することを検討すべきだと考えます。
今回の事象からの学びと設計時の注意点
今回の二重登録問題について振り返ると、リアルタイム処理側には堅牢なロック処理が実装されていたため、データの整合性を重視して設計されていたことが分かりました。
一方で、その堅牢さゆえに、補助的な役割であるバッチ処理との競合というレアケースが盲点となっていました。
バッチの実行スケジュールも1日数回程度であったため、長期間問題が顕在化しなかったことも影響していると思います。
以下、今回の事象における設計上の課題点をまとめます。
- 複数経路の競合: リアルタイム処理単体では完璧に制御されていても、再同期用のバッチ処理という別経路が加わることで、予期せぬ競合が発生する状態になっていた
- 排他制御の適用範囲: メインの処理経路には厳密なロック処理が実装されていたが、バッチ処理側にも同等の排他制御を適用する必要があった
設計時に確認すべきポイント
今後、同じような問題を防ぐためには以下の項目を設計時に確認することが重要だと思いました。
- 登録経路の把握: 同じリソースへのデータ登録がどの経路から実行される可能性があるのか
- 各経路への排他制御の同等性: すべての登録経路に、同じレベルの冪等性保証とロック機構が実装されているか
理想的な設計パターン
繰り返しになりますが、本来であれば登録処理を一箇所に集約することが最善だと思います。
今回のWebhook処理における冪等性は、「個々の処理が重複しても安全」 であることに加え 「異なる経路からの処理が同時に実行されても安全」 である必要があります。
配信が保証されていないWebhookを使用する場合、特に再同期処理(バッチ処理)による同期の仕組みが必要ですが、その際は必ず複数経路の競合を考慮に入れた設計を心がけてください。
おわりに
今回は実際に発生した事例をもとに、Webhookの再同期設計における落とし穴とそこから得られた教訓について紹介しました。
システム全体の整合性を保つためには経路ごとの排他制御だけでなく、経路間の競合も考慮する必要があることを学びました。
この記事が現状の設計を見直すきっかけになったり、これからWebhook連携を設計する方の参考になれば幸いです。
明日13日目のアドベントカレンダーは、デザイナーである田代さんの記事です!
ぜひお楽しみに!
Discussion