🔎

Real World ISUCON ~海外ツアー検索のパフォーマンスチューニングの事例~

2024/09/05に公開

こんにちは、バックエンドエンジニアの飯沼です。

私たちが開発している海外旅行アプリ『NEWT(ニュート)』では、フライト・ホテルと送迎などのオプションをセットにした海外ツアーを簡単に予約することができます。

当然、海外ツアーを検索する機能もありますが、ツアー数の増加や機能改修を重ねるごとに検索結果の表示が遅くなるという問題が顕在化していました。この状況を改善するため、2024年7月にパフォーマンス改善版をリリースしました。

この記事では、その際に実施した海外ツアー検索のパフォーマンスチューニングについて解説します。

⚡ 改善の結果

動画で見ていただくのが分かりやすいので、先に改善の結果から紹介します。

Before After

この動画ではわかりませんが、レスポンスが速くなった結果、2ページ目以降もローディングアニメーションなしで表示できるようになりました。

リリース前後のレスポンス時間の変化 (デバイスごと)

リリース後、わかりやすくレスポンス時間が短くなっています。ピーク時間帯でのレスポンス時間の95パーセンタイルが4.9秒 → 1.3秒まで短縮できました。

システム構成としては、もともとMySQLのみで実現していた検索機能をElasticsearchに置き換える、という変更をしました。その結果、コスト面ではElasticsearchを追加した分月額の費用は増えましたが、セールなどアクセスが集中する際に実施している負荷対策のために必要なコストを、3分の1以下に抑えることができるようになりました。今後の定常的なリクエスト増加に対して、これまでより緩やかなインフラ費用の増加が期待できます。

🤔 そもそも何が課題だったか?

ツアーを探しているカスタマーにストレスを感じさせてしまうこと以外にも、ツアー検索へのリクエスト増加がデータベースへの負荷になりサービス全体の障害に繋がりやすい、という課題がありました。

NEWTはツアーの予約だけでなく、予約後においても旅程表やフライトのeチケットを見る際などに利用されます。「旅行中に必要な情報にアクセスできない」という最悪の事態は絶対に防がなければならないのです。

また、絞り込み件数の表示やフリーワード検索にも対応したいなどの拡張要望があるなか、RDBのみを利用した当時の構造では実現が難しいという課題もありました。

まとめると、以下3つが今回の取り組みで解決したかったことです。

  • カスタマーが感じるストレス(ローディングが長い)
  • データベースへの負荷(システム全体を巻き込んだ障害)
  • 機能拡張の難しさ(絞り込み件数表示やフリーワード検索、ソート順の最適化)

Step 1: まずはボトルネックを特定

前提として、ツアー検索は2段階のフィルタで実装されていました。

※ 日付指定の有無など、さまざまなケースがありますが、ここでは旅行日が指定されたケースを前提に説明します。

  • 1次フィルタ: SQLによる検索条件と簡易的な在庫チェック
    • ツアーを格納するテーブルを中心に、関連するテーブルをjoinして条件にあうレコードを取得
    • フライト:指定した人数分の空席があることを確認
    • ホテル:ツアーで組み合わせるフライトの到着日や時間帯によりチェックイン日が決まるため、単純なjoinでは在庫情報を参照することが難しいので、指定した人数を収容できる部屋が存在することだけ確認
  • 2次フィルタ: 厳密な在庫チェック
    • 1次フィルタではできなかったホテルの空室を確認、在庫切れで予約不可能なツアーは除外
    • 指定した件数が取得できるまで、1次フィルタによる検索と在庫チェックを繰り返す(例:10件取ろうとして、5件が在庫なし → さらに10件取って在庫がある5件を返す)

Cloud TraceやCloud SQLのQuery Insightsなどを活用して、各種メトリクスを観察した結果、以下のことがわかりました。

  • 2次フィルタの厳密な在庫チェックと、旅行者人数・部屋数に応じた料金計算のためのデータベースアクセスに大部分の時間が掛かっている

    • 1次フィルタが1回のクエリに対して、2次フィルタは取得するツアーの件数分実行されるため(典型的なN+1問題)
    • 同様の構造で、検索結果に表示する料金計算のための料金表へのデータベースアクセスに時間が掛かっている(料金は、出発日x泊数x人数x大人/子どもなどの属性x部屋数の組み合わせで決まるため、キャッシュが難しく検索タイミングで計算している)

      ツアー検索結果での料金表示
  • 1次フィルタのクエリがデータベースのCPUリソース消費の大部分を占めている

    • 10個以上のテーブルをjoinしており、where句にはindexを利用できないような演算がいくつか混在している

上記を踏まえて、以下の戦略でチューニングを進めました。

  • ツアーのデータを非正規化することで1次フィルタの負荷を軽くする
  • 1次フィルタで在庫チェックまで実行することで、2次フィルタをなくす
  • ツアーN件分の料金計算のためのデータベースアクセスの削減

Step 2: ツアーデータの非正規化と在庫チェックのサポート (MySQL → Elasticsearch)

まずは、データベースへの負荷軽減のため、1次フィルタの大量のテーブルjoinとindexを活用できないwhere句での演算をどうするか考えました。

戦略としては、これまでjoinや演算で求めていた値を非正規化/事前計算して保持することで、joinや演算無しでフィルタできるようにする方向で、あとはそれをすでに利用しているMySQLで実現するのか?別ソリューションで実現するのか?という選択肢がありました。

最終的には以下の理由でElasticsearchを採用しました。

  • データ量が多く、更新頻度の高い在庫料金データを効率よく格納・検索できること(在庫料金情報は、ツアー数x出発日x泊数パターン分の情報になるので、例えばツアーが10万件、出発日が1年後まで、泊数パターンが5つの場合、RDBで保持すると合計1.8億レコードになります)
  • 将来やりたい絞り込み件数の表示やフリーワード検索、ソート順の最適化などがより簡単に実現できること

また、1次フィルタのみで在庫チェックできるよう在庫の持ち方にも工夫を加えました。これまでフライトとホテルそれぞれで持っていた在庫数を、それらを組み合わせたツアーとしての在庫数に変換する、という方法です。

具体的には、フライトの空席数とホテルの部屋数/収容人数から利用可能なベッドの数を算出し、ツアーとしての在庫という扱いにしました。その際、Elasticsearchで日付ごとの在庫データを扱う事例が見つからず、独自に工夫したポイントがあるので共有します。

Elasticsearchには、以下のように格納は可能ですが、stock.date: 2024-07-02 AND stock.stock >= 2 (疑似クエリ)のように指定すると、7/2の在庫は0でも tourId: 1 はヒットします。

{
  tourId: 1,
  stock: [
    { date: "2024-07-01", stock: 5 },
    { date: "2024-07-02", stock: 0 },
    ...
  ]
}

なぜかというと、index内部では以下のようにフラットな構造で格納され、日付と在庫の関係性が維持できないからです。

{
  stock.date: ["2024-07-01", "2024-07-02", ...],
  stock.stock: [5, 0, ...]
}

※ 参考: https://www.elastic.co/guide/en/elasticsearch/reference/current/nested.html#nested-arrays-flattening-objects

Nested field typeを使うことで、ネストした日付ごとの在庫を扱うことができますが、index内部ではネストしたオブジェクトごと、つまり日付ごとにドキュメントが格納されるような構造になります。ドキュメント数(ツアー数万件 x 出発日 x 旅行日数パターン分)が膨大になりパフォーマンスへの影響に懸念があったためこの方法は採用を見送りました。

他にも、日付ごとにフィールドを設定する方法ありますが、フィールド数の増加もパフォーマンスに影響を与えるため採用を見送りました。

{
  stock_20240701: 5,
  stock_20240702: 0,
  ...
}

※ デフォルト設定では、フィールド数は1000までに制限されています https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-settings-limit.html

日付x在庫数の2次元データとして保持する方法も考えましたが、クエリが複雑になりそうだったため、最終的には以下のように日付と在庫数を1つのlong型の値に格納しています。

{
  stockByDate: [
    20240701_00005,  // 2024-07-01の在庫数5
    // 在庫が0の7/2は格納しない
  ]
}

例えば、7/2に2名以上の在庫のあるツアーを検索する場合は
stockByDate >= 20240702_00002 AND stockByDate <= 20240702_99999 と指定します。

この仕組みによって、もともと2次フィルタでしかできなかったホテル在庫の考慮が、1次フィルタのみで実現できるようになり、1つ目のN+1問題を解消することができました。厳密には、ベッドを必要としない乳幼児の制約を反映するために、複数ある部屋の収容人数の制約を非正規化してインデックスする工夫もしていますが、長くなるので今回は割愛します。

Step 3: 料金計算のためのデータベースアクセスの削減 (GraphQL / DataLoader)


ツアー検索結果での料金表示

最後に、ツアーごとに実行していた料金計算のためのデータ取得をDataLoaderを使って1回のクエリでまとめて取得できるように書き換えました。具体的には、フライト・ホテルの料金表の他、送迎や保険などのオプション、為替レート、マークアップ率などを取得しています。

結果、データベースへのクエリ数を数百から数十のレベルまで減らすことができ、これによってレスポンス速度が1~2秒程度短縮できました。

画像は改善前後のCloud Traceのサンプルです。

Before After

※ Beforeの方はまだまだ続きがありますが、あまりにも長すぎるので割愛します。

おわりに

以上、海外ツアー検索のパフォーマンス改善の取り組みについて紹介しました。パフォーマンス観点では以前よりずっと良くなったものの、今回触れられなかった課題も含め、まだまだ理想とのギャップは大きい状態です。旅行の検討段階からワクワクするような検索体験を実現できるよう引き続き改善していきたいと考えています。

令和トラベル Tech Blog

Discussion