ベスト プラクティス

ここで紹介するベスト プラクティスは、Datastore モードの Firestore を使用するアプリケーションを作成する際のクイック リファレンスとして利用できます。このページは Datastore モードの使い方の基礎を解説するものではないため、Datastore モードをこれから始める方の出発点としては不適当かもしれません。Datastore モードの Firestore を開始するから始めることをおすすめします。

全般

  • 名前空間名、種類名、プロパティ名、カスタムキー名には常に UTF-8 文字を使用します。これらの名前に非 UTF-8 文字を使用すると、Datastore モードの機能に影響する可能性があります。たとえばプロパティ名で非 UTF-8 文字を使用すると、そのプロパティを使用するインデックスを作成できない場合があります。
  • 種類名またはカスタムキー名でスラッシュ(/)を使用しないでください。これらの名前でスラッシュを使用すると、将来リリースされる機能に影響する可能性があります。
  • Cloud のプロジェクト ID には機密情報を含めないでください。Cloud のプロジェクト ID は、プロジェクトの有効期間を超えて存続する場合があります。

API 呼び出し

  • 読み取り、書き込み、削除には、単一のオペレーションではなくバッチ オペレーションを使用します。バッチ オペレーションでは、単一オペレーションと同じオーバーヘッドで複数のオペレーションが実行されるため効率的です。
  • トランザクションが失敗した場合はロールバックを試行してください。ロールバックを使用すると、トランザクション内で同じリソースを使用する別のリクエストの再試行レイテンシが最小限に抑えられます。ロールバック自体が失敗する可能性もあるため、ロールバックはベスト エフォート型の試行である点に注意してください。
  • 可能な場合は、同期呼び出しではなく非同期呼び出しを使用します。非同期呼び出しでは、レイテンシの影響が最小限に抑えられます。たとえば、同期 lookup() の結果とクエリの結果に従ってレスポンスをレンダリングするアプリケーションについて考えてみましょう。lookup() とクエリにデータの依存関係がなければ、lookup() が完了するまで待ってからクエリを開始する同期処理は必要はありません。

エンティティ

  • 1 回の commit に同じエンティティ(キーが同一のエンティティ)を複数回含めないでください。同じ commit に同じエンティティを複数回含めると、レイテンシに影響することがあります。

  • エンティティの更新に関するセクションをご覧ください。

キー

  • エンティティ作成時にキー名が指定されていない場合、キー名は自動生成されます。これらは、キースペースに均等に分散するように割り当てられます。
  • カスタム名を使用するキーでは、常にスラッシュ(/)を除く UTF-8 文字セットを使用します。UTF-8 以外の文字セットを使用すると、Datastore モードのエクスポート ファイルを BigQuery にインポートするなど、さまざまな処理に影響します。また、スラッシュは将来リリースされる機能に影響する可能性があります。
  • 数値 ID を使用するキーの場合:
    • ID には負の数値を使用しないでください。負の ID は並べ替えに影響する場合があります。
    • ID には値 0(ゼロ)を使用しないでください。使用すると、自動的に ID が割り当てられます。
    • 作成したエンティティに対して独自の数値 ID を手動で割り当てる場合は、アプリケーションで allocateIds() メソッドを使用して ID のブロックを取得します。そうすれば、Datastore モードで手動の数値 ID が別のエンティティに割り当てられることを防止できます。
  • 作成するエンティティに独自の手動数値 ID またはカスタム名を割り当てる場合は、次のような単調に増加する値を使用しないでください。

    1, 2, 3, …,
    "Customer1", "Customer2", "Customer3", ….
    "Product 1", "Product 2", "Product 3", ….
    

    アプリケーションが大量のトラフィックを生成する場合、このような連続番号によりホットスポットが発生し、Datastore モードのレイテンシに影響する可能性があります。連続する数値 ID に関する問題を回避するには、allocateIds() メソッドから数値 ID を取得します。allocateIds() メソッドを使用することで、適度に分散された順序の数値 ID が生成されます。

  • キーを指定するか生成された名前を格納することにより、エンティティを見つけるためにクエリを発行しなくても、後でそのエンティティに対して lookup() を実行できます。

インデックス

  • クエリがプロパティを必要としない場合は、インデックスからプロパティを除外します。プロパティに対して不必要なインデックス作成を行うと、レイテンシが増加し、インデックス エントリのストレージ費用が増える可能性があります。
  • 複合インデックスが多くなりすぎないようにしてください。複合インデックスを過度に使用すると、書き込みレイテンシが増加し、インデックス エントリのストレージ費用が増える可能性があります。インデックスがまだ定義されていない大規模なデータセットに対して非定型クエリを実行する必要がある場合は、BigQuery を使用します。
  • プロパティにインデックスを付ける際、単調に増加する値(NOW() タイムスタンプなど)を使用しないでください。このようなインデックスを使い続けると、ホットスポットが発生して、読み取りと書き込みを頻繁に行うアプリケーションでは Datastore モードのレイテンシに影響する可能性があります。単調なプロパティの扱いに関する詳細なガイダンスについては、下記の狭いキー範囲への高頻度での読み取りと書き込みをご覧ください。

プロパティ

  • 文字列型のプロパティには常に UTF-8 文字を使用します。文字列型のプロパティで UTF-8 以外の文字セットを使用すると、クエリが妨げられることがあります。UTF-8 以外の文字セットでデータを保存する必要がある場合はバイト型文字列を使用します。
  • プロパティ名にはドットを使用しないでください。プロパティ名にドットを使用すると、埋め込みエンティティ プロパティのインデックス作成に影響します。

クエリ

  • クエリ結果のキーだけにアクセスする場合は、キーのみのクエリを使用します。キーのみのクエリでは、エンティティ全体を取得する場合よりも低いレイテンシとコストで結果が返されます。
  • エンティティの特定のプロパティだけにアクセスする場合は、射影クエリを使用します。射影クエリでは、エンティティ全体を取得する場合よりも低いレイテンシとコストで結果が返されます。
  • 同様に、クエリフィルタに含まれるプロパティ(たとえば、order by 句でリストされるもの)のみにアクセスする場合も、射影クエリを使用します。
  • オフセットは使用しないでください。代わりにカーソルを使用します。オフセットを使用しても、スキップされたエンティティがアプリケーションに返されなくなるだけで、内部的にはそのようなエンティティも引き続き取得されます。スキップされたエンティティはクエリのレイテンシに影響し、そのようなエンティティの取得に必要な読み取りオペレーションについてアプリケーションに課金されます。

スケールを考慮して設計する

ここでは、競合の発生を防ぐためのベスト プラクティスについて説明します。

エンティティの更新

アプリを設計する際は、アプリが単一エンティティを更新するのに要する時間を考慮してください。ワークロードのパフォーマンスを判断する最良の方法は、負荷テストを実行することです。アプリが単一エンティティを更新できる正確な最大レートは、ワークロードによって大きく異なります。このような要素には、書き込みレート、リクエスト間の競合、影響を受けるインデックスの数などがあります。

エンティティの書き込みオペレーションでは、エンティティとその関連インデックスを更新します。Datastore モードの Firestore では、レプリカのクォーラムに書き込みオペレーションが同期的に適用されます。書き込みレートが高いと、データベースで競合、高レイテンシ、その他のエラーが発生するようになります。

狭いキー範囲への高頻度での読み取りと書き込み

辞書順が近い一連のドキュメントに対して、頻繁に読み取りや書き込みを行わないでください。これは、ホットスポットと呼ばれる問題を引き起こします。次のいずれかを行うと、アプリケーションでホットスポットが発生する可能性があります。

  • 非常に高い頻度で新しいエンティティを作成し、単調に増加する ID を割り当てる。

    Datastore モードでは、散布アルゴリズムを使用してキーを割り当てます。自動エンティティ ID 割り当てを使用して新しいエンティティを作成すれば、書き込みでホットスポットが発生することはありません。

  • 旧来の順次 ID 割り当てポリシーを使用して、非常に高い頻度で新しいエンティティを作成する。

  • エンティティ数の少ない種類のエンティティを高頻度で作成する。

  • インデックス付きの、タイムスタンプのような単調に増加するプロパティ値を持つ新しいエンティティを非常に高い頻度で作成する。

  • 高頻度で 1 つの種類からエンティティを削除する。

  • トラフィックを徐々に増やさずに、非常に高い頻度でデータベースに書き込みを行う。

小さい範囲のキーに対する書き込み頻度が急に上昇すると、ホットスポットが原因で書き込みが遅くなる場合があります。Datastore モードは、最終的にはキースペースを分割して高負荷に対応します。

一般的に、読み取りの上限は、単一のキーを高頻度で読み取る場合を除き、書き込みの上限を大きく上回ります。

ホットスポットは、エンティティ キーとインデックスの両方が使用するキー範囲で発生することがあります。

ホットスポットは、場合によっては、狭いキー範囲の読み取りや書き込みへの悪影響以外の影響をアプリケーションに及ぼすことがあります。たとえば、ホットキーの読み取りや書き込みがインスタンス起動時に行われると、読み込みリクエストが失敗します。

単調に増加するキーまたはインデックス付きプロパティがある場合、それらの先頭にランダムなハッシュを付加すると、キーが確実に複数のタブレットにシャーディングされるようになります。

同様に、単調に増加する(または減少する)プロパティに対して並べ替えまたはフィルタを使用するクエリを実行する必要があるときは、そうする代わりに新しいプロパティのインデックスを作成する方法があります。この場合、データセット全体でのカーディナリティは高いが実行するクエリの範囲内のすべてのエンティティに共通するような値を、単調に増加する値の先頭に付加します。たとえば、タイムスタンプを使用してエントリにクエリを実行したいが、一度に 1 人のユーザーの結果だけを返す必要がある場合、タイムスタンプの先頭にユーザー ID を付加して、その新しいプロパティのインデックスを作成する方法があります。この方法を使用すれば、そのユーザーに対するクエリを実行して順序付きの結果を取得できるだけでなく、ユーザー ID の存在によってインデックス自体が適切にシャーディングされることが保証されます。

トラフィックを徐々に増やす

キースペースの新しい種類または部分へのトラフィックは徐々に増やしてください。

Datastore モードの Firestore に対してトラフィックの増大に備える十分な時間を与えるために、新しい種類へのトラフィックは徐々に増やす必要があります。新しい種類に対するオペレーションは毎秒 500 回を上限とし、その後 5 分に 50% ずつトラフィックを増やしていくことをおすすめします。理論上は、この増加スケジュールを使用すると 90 分後に毎秒 740,000 回までオペレーションを増やすことができます。書き込みがキー範囲全体に比較的均等に分散するよう注意してください。Google の SRE は、これを「500/50/5」ルールと呼んでいます。

このような徐々に増やしていく手法が特に重要になるのは、コードを変更して種類 A の使用をやめ、代わりに種類 B を使用するような場合です。この移行を処理する単純な方法は、種類 B を読み取って、種類 B が存在しなければ種類 A を読み取るようにコードを変更することです。しかし、この方法では、キースペースのごく小さい部分を使用する新しい種類へのトラフィックが突然増加するおそれがあります。

これと同じ問題は、エンティティを移行した結果として同じ種類の中の別のキー範囲が使用される場合にも起きることがあります。

どのような方法でエンティティを新しい種類またはキーに移行するかは、データモデルによって異なります。次の例で示しているのは、「同時読み込み」と呼ばれる方法です。この方法が実際のデータに対して効果的かどうかは、ご自身で判断する必要があります。重要な考慮事項の 1 つとして、移行中の並列オペレーションによる費用面での影響があります。

最初に古いエンティティまたはキーから読み取ります。見つからない場合は、新しいエンティティまたはキーから読み取ります。存在しないエンティティを高頻度で読み取ると、ホットスポット発生につながる可能性があるため、負荷を徐々に増やしていくことが必要です。より良い方法としては、古いエンティティを新しいエンティティにコピーしてから古いほうを削除するというものがあります。同時読み込みを徐々に増やしていくと、新しいキースペースが適切に分割されます。

新しい種類への読み取りや書き込みを徐々に増やしていくための方法として考えられるのは、ユーザー ID の決定論的ハッシュを使用して、新しいエンティティに書き込むユーザーの割合をランダムに決めるというものです。ユーザー ID ハッシュの結果が、ランダム関数によっても、ユーザーの行動によっても、偏ることがないようにしてください。

その一方で、Dataflow ジョブを実行して、すべてのデータを古いエンティティまたはキーから新しいエンティティまたはキーにコピーします。ホットスポットを防ぐため、バッチジョブでは連続するキーへの書き込みを避ける必要があります。バッチジョブが完了すると、読み取りは新しい場所からのみ可能になります。

この方法に改良を加えるとすれば、一度に移行するユーザーを小さいバッチにまとめるというものがあります。ユーザー エンティティにフィールドを追加して、そのユーザーの移行ステータスを記録します。ユーザー ID のハッシュに基づいて、移行するユーザーのバッチを選択します。MapReduce または Dataflow のジョブで、そのバッチのユーザーのキーを移行します。移行進行中のユーザーは、同時読み取りを使用します。

ロールバックは簡単にはできないことに注意してください。そのため、移行フェーズ中に、新旧両方のエンティティを二重に書き込む必要があります。これを行うと、Datastore モードの費用が増加します。

削除

狭いキー範囲から大量のエンティティを削除しないでください。

Datastore モードの Firestore は、定期的にテーブルをリライトします。その目的は、削除済みのエントリを除去することと、読み取りと書き込みの処理効率が向上するようにデータを再編成することです。このプロセスは「コンパクション」と呼ばれます。

狭いキー範囲で Datastore モードのエンティティを大量に削除すると、コンパクションが完了するまで、インデックスの該当部分のクエリが低速になります。極端な場合、結果が返される前にクエリがタイムアウトすることもあります。

エンティティの有効期限を表すために、タイムスタンプをインデックス付きフィールドの値として使用するのは、不適切な方法です。有効期限切れのエンティティを取り出すには、このインデックス付きフィールドに対してクエリを実行することになりますが、該当するキースペースは、最近削除されたエンティティのインデックス エントリのスペースと重なっている可能性が高くなります。

「分割クエリ」のパフォーマンスを向上させるには、固定長の文字列を有効期限タイムスタンプの前に付加します。インデックスはこの文字列全体の順に並べられるので、同じタイムスタンプを持つ複数のエンティティがインデックスのキー範囲全体に分散されます。複数のクエリを同時に実行して各シャードから結果を取得することができます。

有効期限タイムスタンプの問題を解決するための、より完成度の高い方法は、定期的に更新されるグローバル カウンタである「世代番号」を使用するというものです。この世代番号を有効期限タイムスタンプの前に付加すると、クエリの結果は世代番号、シャード、タイムスタンプの順に並べ替えられます。古いエンティティの削除は、前の世代で行われます。削除されないエンティティは、その世代番号に 1 が加算されます。削除が完了したら、次の世代に進みます。古い世代に対するクエリのパフォーマンスは、コンパクションが完了するまでの間は低くなります。削除するエンティティのリストを取得するためにインデックスに対するクエリを実行する前に、何世代かの完了を待つことが必要になることがあります。これは、結果整合性が理由で結果が欠落するリスクを軽減するためです。

シャーディングとレプリケーション

ホットスポットに対処するには、シャーディングまたはレプリケーションを使用します。

Datastore モードの Firestore が許容する頻度より高い頻度でキー範囲の一部を読み取る必要がある場合は、レプリケーションを使用します。この方法を使用すると、同じエンティティのコピーが N 個保存されるので、単一エンティティの場合に比べて読み取り頻度を N 倍にすることができます。

Datastore モードの Firestore が許容する頻度より高い頻度でキー範囲の一部に書き込む必要がある場合は、シャーディングを使用します。シャーディングを行うと、1 つのエンティティが小さな断片に分割されます。

シャーディングを行う際のよくある誤りとして、次のものがあります。

  • シャーディングに時間接頭辞を使用している。時間が次の接頭辞に移ると、新しい未分割の部分がホットスポットになります。代わりに、書き込みの部分を徐々に新しい接頭辞に移すようにしてください。

  • 最もホットなエンティティのみをシャーディングしている。エンティティ総数のうち、小さな割合だけをシャーディングすると、ホットなエンティティの間の行数が十分ではなくなるため別々の分割に維持できなくなる可能性がります。

次のステップ