検索インデックス更新を非同期化し、ピーク時の耐性向上

  • kafka
    kafka
  • react
    react
  • expressjs
    expressjs
2023/11/21に公開

まとめ

  • 課題:在庫・価格更新を同期で ElasticSearch に反映していたため、書き込み処理が負荷集中時に遅延・タイムアウト。セール時などのスパイクで可用性が不安定化していた。
  • 対応:Outbox → Pub/Sub → Indexer の非同期パイプラインへ刷新。また部分アップサートで更新処理の軽量化。
  • 成果:書き込み体験の安定を維持しつつ、大きな遅延なくElasticSearch 反映。ピーク時も在庫の鮮度と検索信頼性を両立。
  • 波及効果:非同期更新が社内標準化され、他のAPIにも展開。負荷を平準化しながら拡張できる更新基盤として、運用コスト低減を実現。

背景・課題

サービス構成と前提

宿泊予約サービスのように、「日付 × 人数 × 価格 × 設備 × 位置情報」などの複合条件でリスティングを検索する構成。
在庫や料金は日々変動し、週末やセール時には「更新イベント(在庫・価格)」と「検索リクエスト」が同時に急増する。

当時の仕組みと問題点

従来は、在庫や料金の更新時に同期的に ElasticSearch へインデックス更新していた。
この構成では、次のような問題が顕在化していた。

  • DBロックと ElasticSearch 更新 I/O が重なり、書き込みレスポンスが悪化。
  • 一括更新イベントが集中すると、タイムアウトや再試行が連鎖。

解決すべき技術的課題

  • 部分更新の最適化:料金や在庫など、変更のあった属性だけを差分アップサートできる構造にする。
  • 負荷平準化:セール開始やカレンダー一括更新など、大量の更新が発生しても安定して処理できる設計にする。

ビジネス的な狙い

  • ユーザ体験の維持:在庫や料金更新の遅延・失敗を防ぎ、操作の快適性を保つ。
  • 検索体験の信頼性:検索結果が最新状態を反映し、在庫落ちや誤表示を防ぐ。

対応方針

基本方針(ピーク平準化の中核)

  • 同期更新を廃止し、非同期化へ移行
    アプリから直接 ElasticSearch を更新していた構成を見直し、Outbox → Pub/Sub → Indexer のパイプラインを導入。
    書き込み処理を即時応答とし、ElasticSearch 更新を非同期に分離することで、ピーク時でも安定したスループットを維持する。

  • 部分アップサートによる軽量更新化
    料金・在庫・設備など、変更のあった属性のみを差分アップサート。全量再索引を避け、ElasticSearch 更新負荷と反映遅延を最小化する。

  • 冪等・後勝ち・再処理による整合性維持
    各イベントに event_idversion/updated_at を付与し、重複適用や順序崩れを防止。
    リトライ・DLQ・バックフィル再処理を組み込み、一時的な障害があっても最終的整合を保証する。

運用と監視方針

  • フェーズドリリース
    更新種別(例:カレンダー一括更新)ごとに段階導入し、機能フラグで制御しながら負荷特性を観測。

  • 観測可能性の強化
    イベント発生から ElasticSearch 反映までの鮮度、キュー滞留、再処理の収束状況を常時モニタリング。

  • スケーラビリティ確保
    トラフィック急増時に備え、Pub/Sub と Indexer の水平スケール、および ElasticSearch のシャード構成拡張を前提とした設計とする。

システム構成(Before)

システム構成(After)

調査・測定フェーズ

目的(What to prove)

  • 書き込み系のレスポンスがピーク時にも安定して維持されること。
  • 検索インデックスへの反映が、一定の鮮度を保って継続的に完了すること。
  • 非同期処理によって発生しうる順序崩れ・重複・欠落が、再処理を含めて最終的に整合すること。

メトリクス設計(定義と計測方法)

  • 書き込み遅延:アプリ内計測+APMトレースにより、リクエストの遅延分布(p50/p95/p99)を取得。
  • 反映鮮度(イベント→ElasticSearch反映)
    • 定義: index_lag = t(indexed_at) - t(event_occurred_at)
    • 取得: Outbox に occurred_at、Indexer 完了時に indexed_at を記録し、エンティティ ID 単位で突き合わせ。
  • 品質(正確性)
    • 更新イベントが重複・順序崩れ・欠落なく処理されていること。
    • 障害発生時も再処理によって最終的に整合が取れること。
    • 処理経路全体でイベントの状態(受信・処理・反映)が追跡可能であること。
  • 運用健全性:キュー滞留長、消費レート、DLQ 件数、再処理成功率、リトライ回数分布を可視化。

ベースライン測定(Before)

  • 取得項目:
    • 同期反映時の書き込み遅延とタイムアウト率
    • 同期反映時のインデックス反映鮮度(実質的に即時)とスパイク発生時の破綻挙動

負荷モデル(現実的な利用状況を模倣)

  • 書き込みイベント:
    • 平時は中程度の更新頻度、ピーク時はセールや週末開始に合わせて数倍に増加。
    • 内訳:料金カレンダー更新が主(約 6 割)、在庫確保 / 開放(約 3 割)、掲載情報変更(約 1 割)。
  • 検索トラフィック:
    • 書き込みと同時に増加(週末・連休シナリオ)。

移行作業

段階的リリース

  • 内部的には DB 更新時に Outbox への追記を追加し、そのイベントを元に新しい非同期経路(Indexer)が ElasticSearch を更新。
  • 一定期間は旧経路(同期更新)も並行稼働させ、両者の反映結果を比較・監視。

運用監視体制の切り替え

  • Pub/Sub・Indexer・DLQ を監視ツールで可視化し、処理遅延(lag)・滞留件数・失敗率をダッシュボード化。
  • ElasticSearch 側の反映遅延も合わせて監視し、しきい値超過で自動通知(アラートポリシー)。

検証結果のフィードバック

  • 差分を特定し、「どの属性で誤差が多いか(価格か在庫か)」を特定
  • 更新ロジックのチューニング
  • インデックス反映の正確性が安定したことを確認して本運用へ移行

リリース完了条件

  • 全更新種別で同期経路を停止し、非同期パイプラインへ完全移行

結果

定量的成果

パフォーマンス(書き込みの安定化)

  • 同期更新時に発生していたスパイク(高負荷時のレイテンシ上昇)が解消。
  • セールや週末ピークでも、書き込みAPIのレスポンス時間は一定範囲内で安定。
  • タイムアウト・再試行率は大幅に減少し、利用者の操作体験を維持。

可用性・スループット(ピーク平準化)

  • ElasticSearch 更新処理が非同期に分離されたことで、アプリ側のCPU・I/O負荷を吸収可能に。
  • キュー処理のスループットを動的にスケールアウトできるようになり、
    一時的なトラフィック集中にも処理遅延なく対応。
  • DLQ/再処理ジョブが安定稼働し、短時間での自己回復が実現。

正確性(整合性の維持)

  • 冪等処理と version / updated_at による後勝ち制御が機能し、
    順序崩れや重複適用による誤更新はゼロに収束。
  • 旧(同期)経路との突き合わせ比較でも差分率は極小で安定。

定性的成果

開発・運用体験の改善

  • 運用の安心感の向上:
    ElasticSearch 更新をアプリ本体から切り離したことで、ピーク時のアラート頻度が大幅に減少。
    「DBが詰まっているかもしれない」といった不安が解消され、チームは障害対応よりも改善施策に時間を使えるようになった。

  • 段階的リリースによる心理的安全性
    同期・非同期のデュアル期間を設け、反映差分を可視化しながら切り替えを進めたことで、現場全体に「リスクを把握してから移行する」という共通認識が定着。

組織・技術基盤への波及

  • 非同期処理の設計原則が社内標準化
    本件を契機に、他の更新系API(通知・メール送信・集計処理など)にもOutbox → Pub/Sub → Worker 構成が展開。
    「更新を同期で抱え込まない」というアーキテクチャ思想がチーム横断で共有・再利用されるようになった。

ユーザ体験と運営効率の向上

  • 操作レスポンスの安定
    料金・在庫更新が即時反映される体感が維持され、利用者が管理画面から安心して頻繁に価格調整・在庫管理を行えるようになった。
  • サポート負荷の軽減
    更新遅延や反映失敗が減少し、サポートチームへの問い合わせ件数が減少。結果として、運営コスト削減と顧客満足度の向上の両立を実現した。