スキーマ設計のベスト プラクティス

このページでは、ホットスポットを回避するように Cloud Spanner スキーマを設計し、データを Cloud Spanner に読み込む場合のベスト プラクティスについて説明します。

ホットスポットを防ぐ主キーの選択方法

スキーマとデータモデルで説明したように、データベースに誤ってホットスポットを作成しないように、主キーを慎重に選択する必要があります。たとえば、値が単調に増加する列を最初のキー部分に選択すると、キー空間の最後にすべての挿入が実行されるため、誤ってホットスポットが作成される可能性があります。Cloud Spanner は、キー範囲を使用してサーバー間でデータを分割するため、これは好ましい状況ではありません。つまり、単一のサーバーにすべての挿入が割り振られ、すべての作業が実行されることになります。

たとえば、Users データの行に最終アクセス タイムスタンプ列を保持する必要があるとします。以下のテーブル定義は、最初のキー部分としてタイムスタンプに基づく主キーを使用しているため、不適切な方法です。

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART

CREATE TABLE Users (
  LastAccess TIMESTAMP NOT NULL,
  UserId     INT64 NOT NULL,
  ...
) PRIMARY KEY (LastAccess, UserId);

この場合、最終アクセス タイムスタンプの順序でテーブルに行が書き込まれますが、最終アクセス タイムスタンプは常に増加するため、常にテーブルの最後に書き込みが実行されます。1 つの Cloud Spanner サーバーがすべての書き込みを受信するため、ホットスポットが作成されます。これにより、1 つのサーバーに過剰な負荷がかかります。

以下の図に全体像を示します。

タイムスタンプ順の Users テーブルと対応するホットスポット

上記の Users テーブルには、サンプルの 5 行のデータが格納されています。5 人のユーザーが約 1 ミリ秒おきになんらかのユーザー アクションを実行しています。この図には、行が挿入された順番も記載されています。ラベル付きの矢印が、各行の書き込み順を表しています。挿入は、タイムスタンプ順に行われ、タイムスタンプ値は常に増加しています。このため、挿入は常にテーブルの最後に追加され、同じスプリットに送信されます。スキーマとデータモデルの説明のように、スプリットは 1 つ以上の関連テーブルの行から構成され、行のキー順で保管されます。

Cloud Spanner は、異なるサーバーに作業をスプリット単位で割り当てます。そのため、この特定のスプリットに割り当てられたサーバーがすべての挿入リクエストを処理することになります。ユーザー アクセス イベントの頻度が高くなると、対応するサーバーへの挿入リクエストの頻度も高くなります。上の図で、赤い枠線で囲まれ、背景も赤いサーバーがホットスポットになる可能性があります。この簡単な図では、各サーバーが 1 つのスプリットを処理していますが、実際には各 Cloud Spanner サーバーに複数のスプリットが割り当てられる可能性もあります。

テーブルに追加される行が増えていくと、スプリットも大きくなり、その最大サイズ(約 4 GB)に達すると Cloud Spanner は別のスプリットを作成します。詳細については、負荷に基づいた分割をご覧ください。それ以降の行がこの新しいスプリットに付加され、そのスプリットに割り当てられたサーバーが新たなホットスポットになる可能性があります。

ホットスポットが発生すると、挿入が遅くなり、同じサーバー上の他の作業も遅くなる可能性があります。LastAccess 列を昇順に並べ替えても、この問題は解決しません。すべての書き込みがテーブルの先頭に挿入されるため、引き続きすべての挿入が単一のサーバーに送信されます。

スキーマ設計のベスト プラクティス #1: 値が単調に増加または減少する列をキーの最初の部分として使用しない。

キーの順序を入れ替える

キー空間で書き込みを分散させるには、単調に増加または減少する値を含む列がキーの最初の部分にならないようにキーの順番を入れ替えます。

CREATE TABLE Users (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess);

修正後のスキーマでは、時系列の最終アクセス タイムスタンプではなく、UserId で挿入の並べ替えが行われます。1 人のユーザーが毎秒数千件のイベントを生成することはまずないため、書き込みは異なるスプリットに分散します。

下の図では、Users テーブルの 5 つの行がアクセス タイムスタンプではなく、UserId で並べ替えられています。

書き込みスループットを分散するためにユーザー テーブルを UserId で並べ替える

ここで、Users データは 3 つのスプリットにチャンクされています。それぞれのスプリットには数千行が UserId 値の順番で格納されています。ユーザーデータの分割方法は、各行に約 1 MB のユーザーデータが保存され、最大分割サイズが GB 程度という前提で考えています。ユーザー イベントがミリ秒間隔で発生しても、各イベントは別のユーザーによって発生します。したがって、タイムスタンプで並べ替えた場合よりも、挿入の順序でホットスポットが作成される可能性は非常に低くなります。

関連するベスト プラクティスについて、タイムスタンプ ベースのキーによる並べ替えもご覧ください。

一意キーのハッシュを作成して、論理シャード間に書き込みを分散する

別の方法でも複数のサーバー間で負荷を分散できます。たとえば、実際の一意キーのハッシュを含む列を作成し、そのハッシュ列を主キーとして使用します(またはハッシュ列と一意のキー列を一緒に使用します)。これにより、新しい行がキー空間全体に均等に分散されるため、ホットスポットの作成を回避できます。

ハッシュ値を使用して、データベース内に論理シャードまたはパーティションを作成できます。物理的に分割されたデータベースの場合、行は複数のデータベースに分散されます。論理的に分割されたデータベースの場合、テーブルのデータによってシャードが定義されます。たとえば、Users テーブルへの書き込みを N 個の論理シャードに分散する場合、テーブルの先頭に ShardId キー列を追加します。

CREATE TABLE Users (
  ShardId     INT64 NOT NULL,
  LastAccess  TIMESTAMP NOT NULL,
  UserId      INT64 NOT NULL,
  ...
) PRIMARY KEY (ShardId, LastAccess, UserId);

ShardId を計算するため、主キー列の組み合わせのハッシュ値を作成し、ハッシュの剰余 N を計算します(ShardId = hash(LastAccess and UserId) % N)。選択したハッシュ関数と列の組み合わせにより、キー空間に分散する行数が決まります。パフォーマンスを最適化するため、Cloud Spanner は行全体にスプリットを作成します。ただし、スプリットと論理シャードは一致しない場合があります。

下の図では、ハッシュを使用して 3 個の論理シャードを作成し、サーバー間で書き込みスループットを均等に分散しています。

書き込みスループットを分散するためにユーザー テーブルを ShardId で並べ替える

上の図では、Users テーブルが ShardId 順になっています。これはキー列のハッシュ関数として計算されています。5 つの Users 行が 3 つのシャードにチャンクされ、それぞれが異なるスプリットに存在します。挿入はスプリット間で均等に分散するため、書き込みスループットがスプリットを処理する 3 台のサーバーに分散します。

選択したハッシュ関数によって、キー範囲全体で挿入がどのように分散するのかが決まります。暗号学的ハッシュも良い選択ですが、使用する必要はありません。ハッシュ関数を選択する場合は、次のような要素を考慮する必要があります。

  • ホットスポットの回避。スプリットが多くなるほどホットスポットが少なくなる傾向があります。
  • 読み取りの効率。スキャンするスプリット数が少ないほど、読み取り速度が向上します。
  • ノード数。

Universally Unique Identifier(UUID)を使用する

RFC 4122 で定義されている Universally Unique Identifier(UUID)を主キーとして使用することもできます。ビット シーケンスのランダム値が使用されるため、バージョン 4 の UUID をおすすめします。バージョン 1 の UUID はタイムスタンプを上位ビットに格納するため 、使用しないでください。

UUID を主キーとして格納する場合、次のような方法があります。

  • STRING(36) 列に格納する。
  • INT64 列のペアに格納する。
  • BYTES(16) 列に格納する。

UUID にはいくつかの欠点があります。

  • サイズが大きく、16 バイト以上を使用します。他のオプションでは、このように多くのストレージを使用しません。
  • レコードに関する情報がありません。たとえば、SingerId と AlbumId の主キーには固有の意味がありますが、UUID にはありません。
  • 関連するレコード間の局所性が失われます(このため、UUID を使用するとホットスポットがなくなります)。

連続した値をビット反転する

一意の数値の主キーを生成する場合、後続の数値の上位ビットが数値空間全体にほぼ均等に分散する必要があります。これを行う 1 つの方法は、従来の方法で連続した数値を生成し、それをビット反転して最終値を得ることです。

ビット反転すると、主キー全体で一意の値が維持されます。アプリケーション コードで元の値を再計算できるので、反転値だけを格納する必要があります。

行サイズの制限

最高のパフォーマンスを得るには、行のサイズを 4 GB 未満にする必要があります。行のサイズには、最上位の行と、すべてのそのインターリーブされた子とインデックス行が含まれます。Cloud Spanner は、既存のスプリットが 4 GB に達すると新しいスプリットを作成します。Cloud Spanner は最上位の行間でのみスプリットを作成します。行が 4 GB を超えると、書き込みスループットが影響を受ける可能性があります。

ホットスポットを防ぐインターリーブ テーブルの設計方法

Cloud Spanner は最上位の行間でのみスプリットを作成します。次の例では、3 個のインターリーブ テーブルを使用しています。

-- Schema hierarchy:
-- + Singers
--   + Albums (interleaved table, child table of Singers)
--     + Songs (interleaved table, child table of Albums)

Cloud Spanner は、同じスプリット内に 1 人の歌手のアルバムと曲が格納されるようにスプリットを作成します。1 人の歌手の曲の読み込みまたは書き込みでホットスポットが発生している場合、Cloud Spanner はサーバー間で Songs テーブルの分割を行うことができません。Songs が最上位テーブルの場合、Cloud Spanner は曲に基づいてスプリットを作成できます。

-- Schema hierarchy:
-- + Singers (top-level table)
--   + Albums (interleaved table, child table of Singers)
-- + Songs (top-level table)

タイムスタンプ キーの降順での格納

タイムスタンプをキーとする履歴用のテーブルがある場合で、次のいずれかに該当するときは、キー列を降順に格納することを検討してください。

  • 履歴にインターリーブ テーブルを使用しており、親行も読み取ることになる場合DESC タイムスタンプ列を使用することで、最新の履歴エントリが親行に隣接して格納されます。そうしないと、親行とその最新の履歴を読み取る際、途中の古い履歴をスキップするためにシークが必要となります。
  • 連続したエントリを日付の新しい順に読み込む場合に、いつまで日付をさかのぼるか不明なとき。たとえば、LIMIT を指定した SQL クエリを使用して最新の N 個のイベントを取得したり、特定の行数を読み取った後に読み取りをキャンセルしたりする場合です。このような場合は、最新のエントリから始めて、条件が満たされるまでエントリを古いほうへ順番に読み取る必要があり、Cloud Spanner にとっては、タイムスタンプ キーが降順で格納されているほうが効率的です。

タイムスタンプ キーを降順にするには、DESC キーワードを追加します。次に例を示します。

CREATE TABLE Users (
  UserId     INT64 NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  ...
) PRIMARY KEY (UserId, LastAccess DESC);

スキーマ設計のベスト プラクティス #2: タイムスタンプ キーを降順に並べ替える。

値が単調に増加または減少する列へのインターリーブされたインデックスの使用

前に説明した主キーの不適切な使用方法と似ていますが、主キー列でなくても、値が単調に増加または減少する列にインターリーブされていないインデックスを作成するのも適切な方法とは言えません。

たとえば、以下のテーブルを定義する場合について考えてみましょう。ここで、LastAccess は非主キー列です。

CREATE TABLE Users (
  LastAccess TIMESTAMP,
  UserId     INT64 NOT NULL,
  ...
) PRIMARY KEY (UserId);

データベースで「時間 X 以降」のユーザー アクセスをすばやくクエリするには、次のように LastAccess 列にインデックスを定義するのが便利なように見えます。

-- ANTI-PATTERN: CREATING A NON-INTERLEAVED INDEX ON A COLUMN WHOSE VALUE
-- MONOTONICALLY INCREASES OR DECREASES

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess)

しかし、前のベスト プラクティスで説明した結果と同じ問題が発生します。インデックスは内部でテーブルとして実装されるため、作成されたインデックス テーブルは、値が単調に増加する列を最初のキー部分として使用します。

インターリーブされたインデックスの作成は問題ありません。インターリーブされたインデックスの行は、対応する親の行にインターリーブされるため、親の単一行で毎秒数千件のイベントが生成される可能性はありません。

スキーマ設計のベスト プラクティス #3: 値が単調に増加または減少する列に、インターリーブされていないインデックスを作成しない。

次のステップ

このページは役立ちましたか?評価をお願いいたします。

フィードバックを送信...

Cloud Spanner のドキュメント