/note/tech

ポーリング処理廃止によるイベント駆動アーキテクチャへの移行 ― DBコスト30%削減の実現

要約:

■ 1. 背景と課題

  • DBコストが毎月徐々に上昇する問題に直面
  • 予約管理システムで薬剤師が最新の予約情報をリアルタイムで確認できることが重要な要件
  • フロントエンドから30秒間隔でAPIをポーリングする実装を採用
  • システムの成長とともに問題が顕在化
  • ポーリング間隔は30秒で1回あたりのAPIコール数は9回
  • 予約検索APIの秒間リクエスト数は約2,000リクエスト/秒
  • 予約一覧・予約詳細・ステータス情報など画面表示に必要な情報を複数のエンドポイントから取得していたため1回のポーリングで9回のAPIを呼び出し
  • 多数の薬局クライアントが同時にポーリングを実行するためリクエスト数が膨大化
  • 予約データは日々構造が複雑になりデータベースのテーブルにインデックスを張っても大きな負荷がかかる状況
  • ポーリング方式の根本的な問題はデータに変更があってもなくても定期的にリクエストが発生する点
  • 予約データに変更が発生する頻度はポーリング頻度と比較してかなり低い
  • 大半のリクエストは「変更なし」という結果を返すだけで実質的な価値を生み出していない
  • 無駄なリクエストが継続的にデータベースへ負荷をかけインフラコストを押し上げる要因
  • プロジェクトで導入店舗数が倍増し月次のインフラコストレビューでDBコストの上昇トレンドが明確化したことが転機

■ 2. 解決策の設計思想

  • 変更があったときだけ通知を受け取る方式への転換を決断
  • 従来のプル型(ポーリング)からプッシュ型(イベント駆動)への転換により不要なリクエストを根本的に排除することを目指す
  • 処理フロー:
    • 予約データに変更が発生(イベント発火)
    • イベント伝播用マイクロサービスがサブスクライブ
    • マイクロサービスがFirestoreの薬局別ドキュメントを更新
    • フロントエンドがFirestoreの変更を検知
    • 必要な予約データをAPIから取得

■ 3. アーキテクチャコンポーネント

  • 予約サービス(イベント発行元):
    • 予約の作成・更新・キャンセルなどの変更を検知しイベントを発行
    • 既存の予約システムに最小限の変更を加える形で実装
  • Cloud Pub/Sub:
    • Google Cloudが提供するフルマネージドなメッセージングサービス
    • イベントの非同期配信を担いシステム間の疎結合を実現
  • イベント伝播サービス:
    • GKE上で稼働する軽量なマイクロサービス
    • Pub/Subからイベントを購読しFirestoreの更新を行う
    • 独立させることで関心の分離と保守性の向上を図る
  • Firestore:
    • 薬局ごとのイベント状態を管理するリアルタイムデータベース
    • 各薬局に対応するドキュメントを用意しイベント発生時にタイムスタンプを更新
  • フロントエンドクライアント:
    • Firestoreのリアルタイムリスナー機能を使用して自身に関係するドキュメントの変更を監視
    • 変更を検知したら必要なデータをAPIから取得

■ 4. 技術選定の根拠

  • Firestoreを選んだ理由:
    • リアルタイム同期機能が標準装備でSDKを使うだけで面倒な接続管理なしにリアルタイム同期が実現可能
    • Google Cloudのマネージドサービスとして自動的にスケールしクライアント数の増加に対してインフラ側での対応が不要
    • 薬局ごとのドキュメント分離により各薬局が自身に関係するドキュメントのみを購読するため不要なデータを受信しない
    • 既にGKEやPub/Subを使用していたため同じエコシステム内で完結できる
  • Pub/Subを介在させる理由:
    • システム間の疎結合化により予約システムはFirestoreの存在を知る必要がなく「イベントを発行する」という責務のみを持つ
    • 信頼性の向上でPub/Subはメッセージの永続化と再送機能を持つため一時的な障害が発生してもイベントが失われない
    • Pub/Subのメトリクス(未処理メッセージ数・処理時間など)を監視することでシステムの健全性を把握しやすい
    • 将来的には分析基盤への連携・通知サービスへの連携などイベントの配信先を追加する可能性があり拡張が容易
  • Cloud Tasksを選んだ理由:
    • 動的なタスクスケジュールに最適でフルマネージド
    • 指定した時刻に1回だけHTTPリクエストを送信するというシンプルな機能を提供
    • キューの管理やスケーリングを意識する必要がない

■ 5. スロットリング機構の実装

  • 課題:
    • 予約の登録や更新・キャンセルなどが短時間の間に行われた場合に多数のイベントが発生
    • 同じ薬局に対して数秒間で何十回もFirestoreが更新される
    • フロントエンドが変更を検知するたびにAPIを呼び出し結局大量のリクエストが発生
    • スパイク時の負荷が改善されない
  • 解決策:
    • Cloud Tasksを使ったスロットリング機構を導入
    • 一定時間内に発生した複数のイベントを1回の通知にまとめる
    • Cloud Tasksで遅延実行を行い指定時刻にHTTPリクエストを送信
  • スロットリング間隔の調整:
    • 10秒という値を採用
    • 通常の予約操作ではユーザーが10秒以内に複数回更新することは稀
    • 複数イベント発生時のイベント集約効果が十分に得られる
    • ユーザーが体感する遅延として許容範囲内
    • モニタリングの結果を見ながら調整を継続

■ 6. テスト戦略

  • イベント発生ケースの網羅:
    • イベントが正しく発火されることがシステム全体の動作に直結
    • イベントの発火漏れがあればフロントエンドに変更が伝わらずユーザーは古い情報を見続ける
    • イベントが発生するすべてのケースを洗い出し網羅的にテストを実施
  • ケースの洗い出し:
    • 予約作成(処方箋事前送信予約・オンライン服薬指導予約・店頭受付)
    • 予約更新(日時変更・ステータス変更・患者情報変更・メモ追記)
    • 予約キャンセル(ユーザー操作・自動キャンセル・管理者操作)
  • 動作確認の観点:
    • イベントが正しく発火されるか(Pub/Subにメッセージがパブリッシュされることを確認)
    • イベント内容が正しいか(薬局ID・予約ID・イベントタイプが正確に設定されていることを確認)
    • Firestoreが更新されるか(該当薬局のドキュメントが更新されることを確認)
    • フロントエンドが検知するか(画面上で変更が反映されることを確認)
  • リグレッションテストとの擦り合わせ:
    • 既存のリグレッションテストで今回洗い出したイベント発生ケースがカバーされているか確認
    • カバーされていないケースがあれば新たにテストケースを追加
    • イベント発火の確認をリグレッションテストの検証項目に追加
  • 本番リリース後のイベント発火漏れはゼロを達成

■ 7. 導入結果

  • 定量的効果:
    • 予約検索APIの秒間リクエスト数は約2,000/秒から約1,000/秒へ50%削減
    • DBコストは30%削減
    • ネットワークトラフィックは大幅削減
  • 定性的効果:
    • リアルタイム性の向上で従来は最大30秒の遅延があったが予約の変更がほぼリアルタイムで画面に反映
    • システム安定性の向上でポーリングによる定期的な負荷スパイクがなくなりデータベースへの負荷が平準化
    • 開発者体験の改善で各コンポーネントの責務が明確でデバッグや機能追加がしやすくなった

■ 8. 運用上の考慮事項

  • 成功要因:
    • 段階的なリリースでまずは一部の薬局から段階的に展開し想定外の問題があった場合の影響範囲を限定
    • 監視体制の整備でPub/Subの未処理メッセージ数・Cloud Tasksの実行遅延・Firestoreの読み取り/書き込み数・フロントエンドからのAPI呼び出し数を監視
    • チーム内での知識共有で新しいアーキテクチャについて設計の意図や運用方法をドキュメント化し属人化を回避
  • 留意すべき事項:
    • イベント欠損への対策としてPub/Subの再送機能を活用(デッドレターキューの設定)
    • 順序保証の考慮で分散システムにおいてはイベントの到着順序が発生順序と一致するとは限らない
    • Firestoreコストの試算で導入前に試算を行いトータルでコストメリットがあることを確認

■ 9. 今後の展望

  • スロットリング間隔の動的調整で時間帯やイベント発生頻度に応じて動的に調整することでさらなる最適化が可能
  • 他システムへの水平展開で今回得られた知見を他のポーリング処理を行っているシステムにも展開予定