この記事は Todd Kerpelman(Developer Advocate) による The Firebase Blog の記事 "Why is my Cloud Firestore query slow?" を元に翻訳・加筆したものです。詳しくは元記事をご覧ください。

Cloud Firestore のパフォーマンスについては、「ベースのデータセットではなく結果セットのサイズに比例して高速化する」、「低速のクエリを作成するのは実質的に不可能だ」など、その素晴らしさを評価する声がよく聞かれます。そしてほとんどの場合、それは事実です。アプリでは莫大な数のレコードが格納されたデータセットに対するクエリを実行し、ユーザーが画面から親指を離すよりも早く結果を取得できます。

とは言え、デベロッパーからは「特定の状況では Cloud Firestore の動作が遅く感じられ、クエリの結果を取得するのに予想よりも時間がかかる」という意見を聞くこともあります。それはなぜでしょうか。今回は、Cloud Firestore の動作が遅くなったように見える場合に考えられる原因と、その対処方法をご紹介します。

理由その 1: データが多すぎる

クエリが遅くなったと感じる場合に第一に考えられる原因としてクエリ自体は非常に高速に実行されているのですが、クエリが完了した後にすべてのデータをデバイスに転送する必要があり、実際にはその処理に時間がかかっているのです。

たとえば、組織内の全営業担当者を対象にしたクエリを実行するとしましょう。このクエリは非常に高速に実行されます。しかし、その結果セットが 2,000 人の従業員のドキュメントで構成されていて、各ドキュメントに 75 KB のデータが含まれていたらどうでしょう。デバイスでは 150 MB のデータをダウンロードする必要があり、それが完了するまで結果は確認できません。


パフォーマンスを改善するには

この問題を解決するには、必要なデータ以外は転送しないようにするとよいでしょう。簡単なのは、クエリに制限を追加する方法です。従業員リストの結果のうち、ユーザーが必要なのは先頭のごく一部だけだと思われる場合は、クエリの最後に limit(25) を追加すれば、最初の方のデータのみダウンロードされ、その他のレコードはユーザーがリクエストした場合に限りダウンロードされるようにすることができます。これについてはこちらの動画で詳しく説明していますので、ぜひご覧ください。



一方、営業担当者 2,000 人全員のデータをクエリで取得する必要があると考える場合は、別の方法があります。最初のクエリで取得したいデータのみを含むドキュメントをこれらのレコードから分離し、その他の詳細なデータを含むドキュメントは個別のコレクションまたはサブコレクションにまとめるのです。後者のドキュメントは最初の取得時には転送されませんが、ユーザーの必要に応じて後からリクエストできます。



ドキュメントのサイズを小さくすると他にもメリットがあります。たとえば、クエリにリアルタイム リスナーを設定して、ドキュメントが更新されるとそのドキュメントがデバイスに送信されるようにしたい場合です。ドキュメントのサイズを小さくしておけば、リスナーで変更が発生するたびに転送されるデータの量も少なくなります。

理由その 2: オフライン キャッシュが大きすぎる

Cloud Firestore のオフライン キャッシュはとても優れた機能です。オフラインの永続性を有効にすると、ユーザーがトンネル内に入ったときや飛行機に 9 時間乗っている間でも、アプリはオンラインと「同じように機能」します。つまり、オンライン中に読み取ったドキュメントをオフラインでも利用でき、オフライン中に書き込んだデータは、アプリがオンラインに戻るまでローカルでキューに追加されます。さらに、クライアントの SDK ではこのオフライン キャッシュを利用して、大量のデータがダウンロードされないようにすることができるため、ドキュメントの書き込みなどのアクションを高速化できます。ただし Cloud Firestore は「オフライン優先」のデータベースとして設計されているわけではないので、今のところローカルでの大量のデータの処理には向いていません。

Cloud Firestore はクラウド内で、すべてのコレクションの全ドキュメントとそのフィールドをもれなくインデックスに登録しますが、(現時点では)オフライン キャッシュ用にこうしたインデックスを作成することはありません。つまり、オフライン キャッシュ内のドキュメントにクエリを実行する場合、Cloud Firestore ではクエリ対象のコレクションについて、ローカルに保存されているすべてのドキュメントを展開してからクエリと照合する必要があります。

別の言い方をすれば、バックエンドでのクエリは結果セットのサイズに応じて遅くなりますが、ローカルのオフライン キャッシュ内でのクエリは、クエリ対象のコレクションに含まれるデータのサイズに応じて遅くなります。

ところで、ローカルでのクエリが実際にどの程度遅くなるかは、状況によって異なります。ここで話題にしているのはローカル、つまりネットワークに接続していない状態でのオペレーションですが、これはネットワーク呼び出しを行うよりも速い可能性が(しかもかなりの確率で)あります。ただし、1 つのコレクションに含まれる大量のデータを分類しなければならない場合、あるいは単に動作が遅いデバイスを使用している場合、大きなオフライン キャッシュに対するローカル オペレーションは著しく遅くなる可能性もあります。

パフォーマンスを改善するには

最初に、前のセクションで説明したおすすめの方法を試してみてください。つまり、ユーザーが必要とするデータのみを取得できるようクエリに制限を追加し、不要な細かいデータはサブコレクションに移動することを検討します。また、以前の投稿の最後に取り上げた「複数のサブコレクションと単独の最上位コレクション」のどちらを使うべきかという観点で考えた場合、このケースでは「複数のサブコレクション」を採用した方がよいでしょう。そうすれば、キャッシュの検索対象が、こうした小さめのコレクションに含まれるデータのみとなるからです。

2 番目の方法は、必要以上のデータをキャッシュに詰め込まないようにすることです。私は、デベロッパーがキャッシュを意図的にこのように使用するケースをいくつか見たことがあります。アプリの最初の起動時に膨大な数のドキュメントにクエリを実行し、その後のすべてのデータベース リクエストにローカル キャッシュを強制的に参照させるというものです。通常、データベース コストを削減したり、以降の呼び出しを高速化したりする目的で用いられますが、実際にはメリットよりデメリットの方が大きい場合がほとんどです。

3 番目の方法は、オフライン キャッシュのサイズを小さくすることです。モバイル デバイスのキャッシュのサイズはデフォルトで 100 MB に設定されていますが、状況によっては(特に、大規模な 1 つのコレクションにほとんどのデータが格納されている場合は)、このサイズではデータが多すぎてデバイスで処理できない可能性があります。このサイズは、Firebase の設定で cacheSizeBytes の値を変更することで調整できます。特定のクライアントに対してサイズを調整するとよいでしょう。

4 番目に、永続性を完全に無効にして、何か変化があるか確認してみてください。この方法は通常はおすすめしません。前に述べたように、オフライン キャッシュは非常に優れた機能だからです。それでも、クエリが遅くなったように感じて、その理由がわからない場合は、永続性を無効にしてアプリを再実行することでキャッシュが問題の原因かどうかを判断できるでしょう。

理由その 3: ジグザグマージ結合に問題がある

「ジグザグマージ結合」という名前を私が個人的に気に入っていることはさておき、このアルゴリズムは複合インデックスに依存せずに複数のインデックスからの結果を統合できる点で非常に便利です。基本的には、ドキュメント ID 順に並べ替えた 2 つ(またはそれ以上)のインデックス間を行き来して一致を見つける仕組みになっています。

しかし、ジグザグマージ結合にも 1 つ弱点があります。どちらの結果セットもサイズが大きいにもかかわらず両者の共通部分が少ないと、パフォーマンスの問題が発生する場合があるのです。一例として、カウンター サービスも提供している高級レストランを検索するクエリを考えてみましょう。

restaurants.where('price', '==', '$$$$').where('orderAtCounter', '==', 'true')

両方ともかなり大きいグループですが、共通部分はおそらくほとんどありません。この場合、ジグザグマージ結合では、必要な結果を取得するために多くの検索を行う必要があります。

クエリのほとんどが高速に実行されているのに、特定のクエリを複数のフィールドに対して一度に実行している最中に遅くなる場合は、この状況に陥っている可能性があります。

パフォーマンスを改善するには

複数のフィールドにわたるクエリがが遅くなる場合、それらのフィールドに対する複合インデックスを作成することでパフォーマンスを改善できます。バックエンドでは、その後のすべてのクエリで、ジグザグマージ結合ではなくこの複合インデックスを使用するようになります。つまり、クエリは結果セットのサイズに比例して高速になるということです。

理由その 4: Realtime Database に慣れてしまっている

Cloud Firestore は、クエリ機能、信頼性、スケーラビリティの点で Firebase Realtime Database を上回ります。ただし北米で利用する場合は、一般に Realtime Database の方が低レイテンシです。通常はそれほど大きな差はなく、チャットアプリなどでは違いに気付くことはおそらくないでしょう。しかし、データベースの超高速応答に依存するアプリ(たとえばリアルタイム描画アプリやマルチプレーヤー型ゲームなど)の場合は、Realtime Database の方が「まさにリアルタイム」だと感じられるかもしれません。

パフォーマンスを改善するには

Realtime Database の低レイテンシが求められるプロジェクトを進めている(かつ、大部分のユーザーが北米にいると予想される)場合、もし Cloud Firestore の特有の機能が必要ないのであれば、そのプロジェクトの該当箇所には Realtime Database を使用するとよいでしょう。ただしその前に、以前のブログ投稿公式ドキュメントを確認して、2 つのデータベースそれぞれのメリットとデメリットを十分に理解しておくことをおすすめします。

理由その 5: 物理的な制約がある

ほぼ完ぺきな状況であっても、利用している Cloud Firestore インスタンスがオクラホマでホストされていて、ユーザーがニューデリーにいる場合、「光の速度」の関係で少なくとも 80 ミリ秒のレイテンシが発生します。そして現実的に、どのネットワーク呼び出しでも 242 ミリ秒前後のラウンドトリップ時間がかかります。そのため、Cloud Firestore がどれほど高速に応答しても、その応答が Cloud Firestore とデバイス間を移動する時間がどうしてもかかってしまうのです。

パフォーマンスを改善するには

まず、単回のフェッチの代わりにリアルタイム リスナーを使用することをおすすめします。クライアントの SDK 内でリアルタイム リスナーを使用すると、非常に優れた多くのレイテンシ補正機能が提供されるためです。たとえば Cloud Firestore は、ネットワーク呼び出しが戻るのを待っている間、キャッシュされたデータをリスナーに提示するため、ユーザーに結果を表示する時間がより高速になります。また、データベースへの書き込みはすぐにローカル キャッシュに適用されます。つまり、デバイスがサーバーによる確認を待っている間に、これらの変更が即座に反映されることがわかるでしょう。

次に、ユーザーの大半が所在する場所でデータをホストするようにします。最初にデータベース インスタンスを初期化する際に Cloud Firestore のロケーションを選択できるので、アプリに最適なロケーションはどこか、コスト面だけでなくパフォーマンス面も含めて十分に検討しましょう。

3 番目の方法としては、量子エンタングルメントに基づいた信頼性の高い安価なグローバル通信ネットワークを実装することが挙げられます。これにより、光の速度の問題を回避できるためです。この対応が済めば、おそらくライセンス料の支払いを終わらせることができ、そもそもどんなアプリを作っていたかも忘れてしまうかもしれません。

最後に

今後、Cloud Firestore のクエリが遅く感じる場面に遭遇したときは、上記の内容に目を通していずれかの状況に当てはまるかどうかを確認してください。なお、アプリのパフォーマンスを確認するには、実際の使用環境でのパフォーマンスを測定するのが一番です。この目的に最適なサービスとして、Firebase Performance Monitoring があります。

Performance Monitoring をアプリに統合して、クエリの実際のパフォーマンスを確認できるようカスタム トレースを 1~2 つ設定してみることをおすすめします。

Reviewed by Sophia Hu - Data Management Specialist, Google Cloud