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

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

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

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

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

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

GoogleSQL

CREATE TABLE UserAccessLogs (
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslog (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
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 値が生成されるようにできます。

たとえば、次のテーブルの場合:

GoogleSQL

CREATE TABLE UserAccessLogs (
  LogEntryId STRING(36) NOT NULL,
  LastAccess TIMESTAMP NOT NULL,
  UserId STRING(1024),
  ...
) PRIMARY KEY (LogEntryId, LastAccess, UserId);

PostgreSQL

CREATE TABLE useraccesslog (
  logentryid VARCHAR(36) NOT NULL,
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

GENERATE_UUID() を挿入して LogEntryId 値を生成できます。GENERATE_UUID()STRING 値を生成するため、LogEntryId 列には GoogleSQL の場合は STRING 型、PostgreSQL の場合は text 型を使用する必要があります。

GoogleSQL

INSERT INTO
  UserAccessLog (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslog (logentryid, lastaccess, userid)
VALUES
  (spanner.generate_uuid(),'2016-01-25 10:10:10.555555-05:00', 'TomSmith');

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

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

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

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

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

キーの順序を入れ替える

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

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslog (
userid bigint NOT NULL,
lastaccess TIMESTAMPTZ NOT NULL,
...
PRIMARY KEY (UserId, LastAccess)
);

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

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

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

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

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

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

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

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

GoogleSQL

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 を計算します。次に例を示します。

GoogleSQL

ShardId = hash(LastAccess and UserId) % N

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

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

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

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

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

この処理を GoogleSQL で行うには、次の例に示すように、書き込み時に 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 キーワードを追加します。次に例を示します。

GoogleSQL

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

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

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

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

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

GoogleSQL

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 列にインデックスを定義するのが便利なように見えます。

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

PostgreSQL

CREATE INDEX usersbylastaccess ON users(lastaccess)
WHERE lastaccess IS NOT NULL;

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

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

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

次のステップ