Cloud Datastore のベスト プラクティス

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

全般

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

API 呼び出し

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

エンティティ

  • 関連性の高いデータはエンティティ グループにグループ化します。エンティティ グループを使用すると祖先クエリが可能となり、強整合性のある結果が得られます。エンティティ グループ内のエンティティは Datastore サーバー上の物理的に近い場所に保存されるため、祖先クエリでは I/O を最小限に抑えて迅速にエンティティ グループをスキャンすることもできます。
  • エンティティ グループへの書き込みは 1 秒間に 1 回を超えないようにします。この上限を超える速度で書き込みを続けると、結果整合性の読み取りはさらに結果的になり、強整合性の読み取りはタイムアウトにつながります。そのためアプリケーションの全体的なパフォーマンスが低下します。エンティティ グループに対するバッチ処理またはトランザクション処理による書き込みは、この上限に対して 1 回の書き込みとしてカウントされます。
  • 1 回の commit に同じエンティティ(キーが同一のエンティティ)を複数回含めないでください。同じ commit に同じエンティティを複数回含めると、Datastore のレイテンシに影響することがあります。

キー

  • エンティティ作成時にキー名が指定されていない場合、キー名は自動生成されます。これらは、キースペースに均等に分散するように割り当てられます。
  • カスタム名を使用するキーでは、常にスラッシュ(/)を除く UTF-8 文字列を使用します。UTF-8 以外の文字セットは、Google BigQuery への Datastore バックアップのインポートなど、さまざまな処理に影響します。また、スラッシュは将来リリースされる機能に影響する可能性があります。
  • 数値 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() を実行できます。

インデックス

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

プロパティ

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

クエリ

  • クエリ結果のキーだけにアクセスする場合は、キーのみのクエリを使用します。キーのみのクエリでは、エンティティ全体を取得する場合よりも低いレイテンシとコストで結果が返されます。
  • エンティティの特定のプロパティだけにアクセスする場合は、射影クエリを使用します。射影クエリでは、エンティティ全体を取得する場合よりも低いレイテンシとコストで結果が返されます。
  • 同様に、クエリフィルタに含まれるプロパティ(たとえば、order by 句でリストされるもの)のみにアクセスする場合も、射影クエリを使用します。
  • オフセットは使用しないでください。代わりにカーソルを使用します。オフセットを使用しても、スキップされたエンティティがアプリケーションに返されなくなるだけで、内部的にはそのようなエンティティも引き続き取得されます。スキップされたエンティティはクエリのレイテンシに影響し、そのようなエンティティの取得に必要な読み取りオペレーションに対してアプリケーションに課金されます。
  • クエリに強整合性が必要な場合は、祖先クエリを使用します(祖先クエリを使用するには、まず強整合性に対応するデータ構造にする必要があります)。祖先クエリは強整合性のある結果を返します。キーのみの非祖先クエリに続けて lookup() を実行しても、強整合性のある結果は返されません。これは、キーのみの非祖先クエリはクエリの実行時点で一貫性が保たれていないインデックスから結果を取得する可能性があるためです。

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

単一エンティティ グループに対する更新

Datastore 内の 1 つのエンティティ グループだけを高い頻度で更新することは避けてください。

Datastore モードを使用する場合は、1 秒に 1 回以上エンティティを更新する必要がないようにアプリケーションを設計することをおすすめします。親も子も持たないエンティティは、それ自身がエンティティ グループとなることに留意してください。エンティティを頻繁に更新しすぎると、Datastore の書き込みのレイテンシが増加し、タイムアウトなどのエラーが増えます。このことを、競合(コンテンション)といいます。

1 つのエンティティに対する Datastore の書き込みは 1 秒に 1 回の上限を超えることがあるため、負荷テストではこの問題が現れない可能性があります。

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

辞書順が近い一連の Datastore キーに対して、頻繁に読み取りや書き込みを行わないでください。

Datastore は Google の NoSQL データベースである Bigtable の上に構築されており、Bigtable のパフォーマンス特性に左右されます。Bigtable のスケーリングは、それぞれ独立したタブレットに行を振り分けるという方法で行われますが、これらの行はキーで辞書順に並べられます。

Datastore を使用するときは、ホット タブレットが理由で書き込み速度が低下することがあります。これが起きるのは、小さい範囲の一連のキーに対する書き込み頻度が急に上昇し、1 つのタブレット サーバーの処理能力を超えた場合です。高負荷をサポートするために、最終的に Bigtable はキースペースを分割します。

読み取りの場合の上限は一般的に、書き込みの上限を大きく上回りますが、単一のキーを高頻度で読み取る場合を除きます。Bigtable は、単一のキーを複数のタブレットに分割することはできません。

ホット タブレットは、エンティティ キーとインデックスの両方に使用されるキー範囲に対して発生することがあります。

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

デフォルトでは、Datastore によるキーの割り当てには分散アルゴリズムが使用されます。したがって、新しいエンティティを作成するための書き込みが高頻度でも、デフォルトの ID 割り当てポリシーを使用していれば、Datastore への書き込みでホットスポットが発生することは通常ありません。次のようなケースでは、この問題が発生することがあります。

  • 新しいエンティティの作成をきわめて高頻度で行い、このときに以前の順次 ID 割り当てポリシーを使用する場合。

  • 新しいエンティティの作成をきわめて高頻度で行い、独自の ID を割り当てるが、その ID が単調に増加していく場合。

  • ある種類の新しいエンティティの作成をきわめて高頻度で行うが、その種類の既存のエンティティがごく少数であった場合。Bigtable は、最初はすべてのエンティティを同じタブレット サーバーに配置しますが、ある程度の時間が経過するとキー範囲を複数のタブレット サーバーに分割するようになります。

  • この問題は、新しいエンティティを高頻度で作成するときに、タイムスタンプのような、単調に増加していくインデックス付きプロパティがある場合にも発生します。このようなプロパティは、Bigtable のインデックス テーブル内の行のキーであるためです。

  • Datastore は、ルート エンティティ グループの名前空間と種類を Bigtable 行キーの前に付加します。新しい名前空間または種類への書き込みを開始するときに、トラフィックを徐々に増やしていかなかった場合は、ホットスポットが発生することがあります。

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

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

この問題の詳しい説明については、Datastore での単調に増加する値の保存に関する Ikai Lan のブログ投稿をご覧ください。

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

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

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

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

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

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

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

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

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

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

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

削除

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

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

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

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

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

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

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

Datastore のホットキーに対しては、シャーディングまたはレプリケーションを使用します。

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

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

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

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

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

次のステップ