スキーマ設計

Cloud Spanner の分散アーキテクチャでは、ホットスポットを回避するようにスキーマを設計できます。これにより、基盤となるサーバーがリクエストを多数のサーバーに並行して分散するのではなく、テーブル内の構造的な欠陥により誤って同様のリクエストを大量に処理するよう強制されることがなくなります。

このページでは、こうしたホットスポットを回避するためのスキーマの設計に関するベスト プラクティスについて説明します。これにより、特に一括データの挿入時に Cloud Spanner データベースが効率的に動作します。

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

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

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

-- ANTI-PATTERN: USING A COLUMN WHOSE VALUE MONOTONICALLY INCREASES OR
-- DECREASES AS THE FIRST KEY PART OF A HIGH WRITE RATE TABLE

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

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

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

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

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

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

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

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

スキーマ設計のベスト プラクティス #1: 書き込みレートが高いテーブルで、値が単調に増加または減少する列をキーの最初の部分として選択しないでください。

キーの順序を入れ替える

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

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

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

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

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

ここで、UserAccessLog データは 3 つのスプリットにチャンクされています。それぞれのスプリットには数千行が UserId 値の順番で格納されています。ユーザーデータの適切な分割方法として、各行に約 1 MB のユーザーデータを保存し、最大分割サイズは約 8 GB にすることを想定しています。ユーザー イベントは約 1 ミリ秒間隔で発生しますが、各イベントは異なるユーザーによって発生するため、挿入の順序はタイムスタンプによる順序付けよりもホットスポット化がかなり少なくなります。

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

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

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

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

CREATE TABLE UserAccessLog (
  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 個の論理シャードを作成し、サーバー間で書き込みスループットを均等に分散しています。

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

上の図では、UserAccessLog テーブルが ShardId 順になっています。これはキー列のハッシュ関数として計算されています。5 つの UserAccessLog 行が 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 つの方法は、従来の方法で連続した数値を生成し、それをビット順逆転して最終値を得ることです。

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

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

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

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

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

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

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

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

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

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

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

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

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

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess)

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

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

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

次のステップ