日時フィールドの丸め処理を入れて検索のレスポンス改善をした話

サムネイル

はじめに

この記事はDMMグループ Advent Calendar 2024の3日目の記事です。

はじめまして、データ基盤開発部・検索基盤チームで検索システムの開発・運用をしている小山です。

この記事では検索APIの中で日時フィールドに丸め処理を実装した際の経緯や注意点・結果について記載します。 やったこと自体はそこまで大きなことではないですが、元々想定していたよりも大きく性能向上したため、検索システムを運用している人はもちろん、それ以外のシステムでも考慮する余地がある話になるかと思います。

結論

まず簡潔にやったことと結果を記載します。

やったこと

  • 検索API(後述)で、日時フィールドに5分単位の丸め処理(切り捨て)を実装した
  • 23:59:59 が指定された場合のみ丸め処理の対象から外した

得られた結果

  • 深夜帯のレスポンスタイムが悪化していたタイミングだけでなく、昼の時間帯含め全体的に95%ileで200ms以上の検索レスポンスタイム向上につながった

検索システムの簡易理解

実際にやったことを説明する前にまずDMMの検索システムの概要と今回の実装に関わる一部検索機能の説明をさせていただきます。

DMMの検索システム概要

DMMでは2024年12月現在、検索エンジンにApache Solr(以下、単にSolrと記載)を利用しています。 SolrはApacheソフトウェア財団がプロジェクト管理するオープンソースの全文検索エンジンであり、Luceneと呼ばれる検索エンジンライブラリを使って作成されています。

Solrを使った全文検索機能を社内で様々なサービスが利用できるように検索基盤チームではSolrだけでなく、検索のためのAPI(以下、検索API)や更新のためのAPI(以下、更新API)の開発・運用も行っています。 また、検索APIではRedisに検索リクエストのBodyをキーとしたキャッシュを保存しています。

細かく話すと更新APIの裏にはキューとしてKafkaがあり、そこから先を別のシステムで更新量を調整できるようになっていたり、検索も一部サービスでは前段にproxyが存在していたりなどがありますが、ここでは検索は検索APIを、更新は更新APIを経由しており、検索ではキャッシュを利用していることが把握できれば十分です。

簡易的には以下の図のような構成でシステムを管理しています。

簡易的な検索システム

この中でも今回は検索APIで日時フィールドを丸める処理を実装した話をこの後していきます。

Solrのクエリと検索システムにおけるキャッシュについて

実装の話に移る前に、上記で紹介した検索エンジンのSolrについてここで必要になる最低限の機能としてFilter QueryとSolrを含む検索システム全体のキャッシュについて簡易的に説明させていただきます。

Solrへの検索クエリはURLパラメータを使用して以下のような構成で行われます。

http://<solr_host>/solr/<core>/select?q=<query>&fq=<filter_query>&start=<start>&rows=<rows>&sort=<sort>
  • q : メインの検索クエリ。フィールドを指定して title:Solr のような検索や、単に Solr と検索文字列を入れることもできる。主にスコアリングに利用される。
  • fq : フィルタクエリ。検索結果の絞り込みのために使用され、スコアリングには影響しない。
  • start : 検索結果の開始位置。検索結果をソートしたときに何番目のドキュメントから取得するか。
  • rows : 返却するドキュメントの数。検索で取得したドキュメントからいくつレスポンスとして返すか。
  • sort : 返却するドキュメントの順番を指定。指定された条件に基づいて検索結果を並び替える。

これが全てではないですが、主にこのような構成でURLパラメータに & 繋ぎで条件を指定して検索します。 補足として core というのはRDBでいうところのテーブルのようなもので、coreごとにスキーマや設定があり、どのcoreに検索するかを指定できます。 ここで特に重要なのがフィルタクエリ( fq )と呼ばれる機能です。

Filter Query (フィルタクエリ)

Filter Queryは、メインの検索クエリとは別に、検索結果を絞り込むために使用されるパラメータです。メインの検索クエリがドキュメント検索を行い、スコアリングに基づいて検索結果を返すのに対して、 Filter Queryはスコアリングに影響を与えずに、検索結果をフィルタリングするために使用されます。

例えば、以下のような検索を行ったとします。

http://<solr_host>/solr/<core>/select?q=title:Solr&fq=service:dmm

この検索は、タイトルに Solr を含むドキュメントを検索し、その中でも servicedmm であるドキュメントだけを返します。

Solrにおけるキャッシュ

次にSolrのキャッシュについて説明します。

Solrは検索性能向上のために、いくつかのキャッシュメカニズムを持っています(参考: Apache Solr リファレンスガイド)。

  1. Query Result Cache : クエリ内の q sort fq の3パラメータを取得したハッシュをキーとして、検索結果の順序付けされたドキュメントIDのリストをキャッシュに保持する。
  2. Filter Cache : fq の絞り込み条件と、条件に合うドキュメントの集合をキャッシュに保持する。
  3. Document Cache : Luceneドキュメントオブジェクト(各ドキュメントの保存されたフィールド)をキャッシュに保持する。

この中でもQuery Result Cacheはクエリ自体の一致が必要ですが、Filter Cacheはそれぞれのfqごとにキャッシュ保持されるため、 様々なクエリに対してキャッシュされた結果を利用することができ、キャッシュが効きやすいという特徴があります。

実際、検索基盤チームで管理しているSolrのメトリクスを見てもFilter Cacheのヒット率はかなり高い値を推移しています。

SolrのFilter Cacheヒット率

分かりやすく少し具体例を見てみます。 以下のようなクエリが発行された場合

http://<solr_host>/solr/<core>/select?q=title:Solr&fq=service:dmm

その後に以下のようなクエリが発行されると

http://<solr_host>/solr/<core>/select?q=title:Lucene&fq=service:dmm

Query Result Cacheはキャッシュヒットしませんが、 fq=service:dmm のFilter Query部分は一致しているため、キャッシュから結果を返すことができます。 つまり、いかに効率よくfqをキャッシュに保持するかは検索高速化において重要な考え方となります。

検索システム全体でのキャッシュの考え方

事前に軽く触れたように検索APIがRedisに保存しているキャッシュは検索のリクエストBodyそのものをキーとしています。 つまり、上記Solrのキャッシュと合わせて検索システム全体で以下のようにキャッシュメカニズムを持っていることになります。

  1. 検索リクエストBody全体をチェックして同じリクエストならRedisのキャッシュにヒットする
  2. リクエスト全体が一致していなくても q fq sort が一致していればSolrのQuery Result Cacheにヒットする
  3. Query Result Cacheにヒットしなくても fq だけでも一致していればSolrのFilter Cacheにヒットする

もっと言うと各サービスごとにフロントでもキャッシュ管理しているはずですが、そこは検索基盤チームの管轄ではないので考えないこととします。

実施背景

ここまでの検索システムへの理解を踏まえて、今回の施策を実施した背景について軽く触れさせていただきます。

検索基盤チームでは検索のレスポンスタイムを日々監視しており、SLOアラートの導入をしています。 SLOアラートとしては検索リクエストを受け取るALBレベルでレスポンスタイムの95%ileが500msを超えたらアラートを鳴らすというような運用をしていました(現在は少し違っていますが)。 このアラートが深夜帯の特定時間帯にのみ頻発して鳴るようになったため、該当時間帯に検索リクエストの多いサービスのクエリを調査したところ、日時フィールドに対して現在時刻をそのまま指定していることが分かりました。

当時の検索APIレスポンスタイム

検索された現在時刻をそのまま指定されると検索のたびにFilter Queryの中身が変わるため、RedisのキャッシュはおろかFilter Cacheにもヒットせず、レスポンスタイム悪化の原因になると推測しました。 そこで、検索APIに日時フィールドの丸め処理を実装することになりました。

元々各サービスの担当事業部には検索システムを利用する際に日時フィールドの丸め処理をお願いしていましたが、リリースされる時点で詳細なクエリ確認までは行っておらず、あくまでも任意要件となってしまっていたため、 事業部によっては未実装になっていたのが、今回の問題だと考えられます。 しかし、既に多くの事業部が検索システムを利用しており、実態の把握が難しいこと、クライアント側の制御はこちらではできないので全てを是正するのは無理だと考えて検索API側で実装することにしたのが背景となります。

実装詳細

さて、ここからは実際にどのような実装をしていったかを見ていきますが、その前にそもそも日時フィールドを指定した絞り込みが何のためのクエリなのかを考えていきたいと思います。

日時フィールドを利用した絞り込みをするユースケースはいくつかありますが、以下の2つのケースを主に考えます。

  1. 検索した日時に表示可能な商品のみを表示したい
  2. 特定の日時に販売開始した商品の一覧を取得したい

それぞれのケースを少し細かく見ていきます。

検索した日時に表示可能な商品のみを表示したい

これは分かりやすいと思います。

DMMのトップページで検索をしたときに既に販売が終了している商品や、まだ販売・予約開始されていない表示不可な商品が検索にヒットしては問題となるため、検索した時点で表示可能な商品のみを取得する必要があります。

Solrに格納されている各サービスのデータには表示開始日時を格納するフィールド、そして表示終了日時を格納するフィールドがあり、 以下のような形式で検索APIにリクエストすることでSolrのFilter Queryを用いた範囲絞り込みができます。

"filter": [
  {
    "field": "begin",
    "type": "range",
    "lte": "2024-12-03T13:00:00"
  },
  {
    "op": "and"
  },
  {
    "field": "end",
    "type": "range",
    "gte": "2024-12-03T13:00:00"
  }
]

このクエリは表示開始日時が2024年12月3日の13時より前でかつ、表示終了日時が2024年12月3日の13時より後の商品をフィルタリングするものであり、 この部分を検索APIがSolrのクエリに変換して最終的には以下のようなクエリが発行されます。

http://<solr_host>/solr/<core>/select?fq=begin:[*+TO+2024-12-03T13:00:00]&fq=end:[2024-12-03T13:00:00+TO+*]

ここで2024-12-03T13:00:00の部分に検索を行った現在時刻が秒単位まで指定されることで、どのレイヤーでもキャッシュが効かなくなり、レスポンスタイムの悪化に繋がります。

特定の日時に販売開始した商品の一覧を取得したい

これについては明確にユースケースを検索基盤チーム側で把握しているわけではないですが、例えばトップページなどにその日の新着商品を出しておきたいなどで一覧取得したいなどが考えられると思います。

この場合には、検索した日時ではなく具体な日時を範囲指定して検索されることになります。

"filter": [
  {
    "field": "start",
    "type": "range",
    "gte": "2024-12-03T00:00:00",
    "lte": "2024-12-03T23:59:59"
  }
]

上記クエリは2024年12月3日に販売開始した商品の一覧を取得したいクエリになり、特徴としてstartというフィールドに対して 00:00:00 から 23:59:59 という範囲を指定しています。

ここでは 23:59:59 という明確な1日の終わりを指定していることから、この時間を丸めて 23:55:00 のようにしてしまった場合に 23:56:00 に販売開始した商品などがあるとそれを取得できない問題に繋がります。

なお、 23:59:59 に関わらず 23:59:58 など他の時間でも同様のことは言えるという話があるかと思いますが、実際のクエリを調査したところ 23:59:59 以外には指定されているクエリがほとんどなかったため、 23:59:59 のみを対象としました。

もっと言うと、Solrのdate型の仕様ではミリ秒まで入るはずなので本来はやはり事業部側にlte(less than equal)ではなくlt(less than)で翌日0時を指定してもらう方が正しいという話もあるのですが、検索APIで実際に来たクエリをそこまで変えるものかということもあり、 23:59:59 はそのまま利用しています。

実装要件

上記のようなユースケースを想定して洗い出した実際の要件は以下の通りです

  • 日時フィールドの時刻を5分で丸める(切り捨てる)
  • 23:59:59 という具体的な1日の終わりを指定された場合には丸める対象から除外できるようにする
  • 除外するフィールドはconfigで指定する

除外するフィールドをconfigで指定するようにしたのは、上で少し触れましたが、SolrにはRDBでいうテーブルにあたるcoreという概念があり、coreごとにスキーマ構成が変わるからです。

現在それぞれのcoreで同じような役割でも別のフィールド名になっている部分があるため、 特定のフィールドだけを指定してしまうと漏れが発生すると考えて検索リクエスト先を見て対象のフィールドを変えられるようにconfigに指定するようにしました。

これで100%問題が発生しないというわけではないかもしれませんが、かなりのエッジケースを除き、大部分のユースケースに沿った対応ができたと思っています。 少なくともこの実装をした当初から既に半年経っていますが、これによって問題になったという報告は受けていません。

実装後のレスポンスタイム

日時フィールドの時刻を5分で丸める実装をリリースした前後一週間の検索APIのレスポンスタイムは以下の通りです。

検索APIのレスポンスタイム

まず、この実装をした背景でもある深夜帯のレスポンス悪化については完全に解消されたのが分かると思います。 また、それだけでなく昼の時間帯でも200ms以上のレスポンスタイムの改善に繋がりました。

また、それぞれのレイヤーごとのキャッシュヒット率は以下の通りです。

Redisキャッシュヒット率 Query Result Cacheヒット率 Filter Cacheヒット率

今回の実装では検索APIのRedisキャッシュのヒット率が向上したものの、SolrのQuery Result CacheとFilter Cacheについては大きな改善は見られませんでした。 Solrのレスポンスタイム自体で見てもかなりの改善には繋がっていますが、これはRedis側でこれまで遅かったクエリをキャッシュヒットできるようになったことでSolr側に遅いクエリが来なくなったことが要因な気がします(本当はRedisのキャッシュを切った上で性能試験などをすれば詳細に分かるのですが、今回はそこまでは実施していないため、あくまでも推測です)。

Solrのレスポンスタイム

ただし、実際に大きな改善につながったことからも課題であったサービスに限らず多くのサービスで現在時刻をそのまま利用した検索が行われていたということになります。 これを各サービスごとに依頼を出して対応してもらっていたらいたちごっこのように検索機能を導入するたびに同じようなやり取りをすることになっていたと思うので、 この段階で検索APIで吸収して実装してしまったのは個人的には良かったと思っています。

まとめ

以上、検索APIの日時フィールド丸め実装について簡単にではありますが記載させていただきました。

今回は検索というコアなシステムにおける話にはなりましたが、通常のAPI開発などでも日時フィールドをどのように取り扱うかは検討の余地が十分にあると思います。 それぞれのサービス特性に合わせて要件などは変わると思いますが、キャッシュ周りの改善など何かの参考になれば幸いです。