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

Spanner の分散アーキテクチャでは、ホットスポット(同じサーバーに多くのリクエストが送信され、サーバーのリソースが飽和し、遅延が大きくなる可能性がある状況)を回避するようにスキーマを設計できます。

このページでは、ホットスポットの作成を回避するように、スキーマを設計するためのベスト プラクティスについて説明します。ホットスポットを回避する方法の 1 つは、Spanner が複数のサーバーにデータを分割して分散できるようにスキーマ設計を調整することです。サーバー間でデータを分散すると、特にデータの一括挿入を行うときに、Spanner データベースが効率的に動作するようになります。

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

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

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

GoogleSQL


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

PostgreSQL


CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (LastAccess, UserId)
);

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

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

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

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

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

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

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

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

Universally Unique Identifier(UUID)を使用する

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

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

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

STRING(36) 列では、Spanner GENERATE_UUID() 関数(GoogleSQL または PostgreSQL)を列のデフォルト値として使用して、Spanner によって自動的に UUID 値が生成されるようにできます。

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

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

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

数値形式(GoogleSQL の INT64 または PostgreSQL の bigint)の主キーが、順次増加または減少していないことを確認する必要があります。シーケンシャルな主キーでは、ホットスポット化が広範囲で発生する可能性があります。この問題を回避する方法の一つは、連続する値をビット反転させることで、主キーの値がキー空間全体に均等に分散されるようにすることです。

Spanner はビット反転シーケンスをサポートしており、一意の整数ビット反転値を生成します。ホットスポットの問題を回避するには、主キーの最初の(または唯一の)コンポーネントでシーケンスを使用します。詳細については、ビット反転シーケンスをご覧ください。

キーの順序を入れ替える

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

GoogleSQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

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

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

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

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

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

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

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

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

Google SQL

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

PostgreSQL

CREATE TABLE UserAccessLog (
ShardId bigint NOT NULL,
LastAccess TIMESTAMPTZ NOT NULL,
UserId bigint NOT NULL,
...
PRIMARY KEY (ShardId, LastAccess, UserId)
);

ShardId を計算するため、主キー列の組み合わせのハッシュ値を作成し、ハッシュの剰余 N を計算します。例:

ShardId = hash(LastAccess and UserId) % N

選択したハッシュ関数と列の組み合わせにより、キー空間に分散する行数が決まります。パフォーマンスを最適化するため、Spanner は行全体にスプリットを作成します。

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

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

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

Spanner では、生成された列にハッシュ関数を作成することもできます。

この処理を Google SQL で行うには、次の例に示すように、書き込み時に FARM_FINGERPRINT 関数を使用します。

GoogleSQL

CREATE TABLE UserAccessLog (
ShardId INT64 NOT NULL
AS (MOD(FARM_FINGERPRINT(CAST(LastAccess AS STRING)), 2048)) STORED,
LastAccess TIMESTAMP NOT NULL,
UserId    INT64 NOT NULL,
) PRIMARY KEY (ShardId, LastAccess, UserId);

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

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

タイムスタンプ キーを降順に並べ替えます。

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

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

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

Google SQL

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

スキーマ設計のベスト プラクティス 2: 降順または昇順は、ユーザークエリによって異なります。たとえば、上位のクエリが新しいものや、最も古いものが古いクエリになります。

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

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

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

Google SQL

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

PostgreSQL

CREATE TABLE Users (
UserId     bigint NOT NULL,
LastAccess TIMESTAMPTZ,
...
PRIMARY KEY (UserId)
);

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

Google SQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX UsersByLastAccess ON Users(LastAccess)
WHERE LastAccess IS NOT NULL;

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

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

スキーマ設計のベスト プラクティス #3: 値が単調に増加または減少する書き込みレートが高い列に、インターリーブされていないインデックスを作成しない。インターリーブされたインデックスを使用する代わりに、インデックス列を設計する際にベーステーブルの主キー設計に使用するのと同じ方法を使用します(例: shardId を追加する)。

次のステップ