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

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

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

ホットスポットを防ぐように主キーを選ぶ

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

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

GoogleSQL

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

PostgreSQL

CREATE TABLE useraccesslogs (
  lastaccess timestamptz NOT NULL,
  userid text,
  ...
PRIMARY KEY (lastaccess, userid)
);

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

次の図は、この落とし穴を示しています。

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

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

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

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 useraccesslogs (
  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
  UserAccessLogs (LogEntryId, LastAccess, UserId)
VALUES
  (GENERATE_UUID(), '2016-01-25 10:10:10.555555-05:00', 'TomSmith');

PostgreSQL

INSERT INTO
  useraccesslogs (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 UserAccessLogs (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess);

PostgreSQL

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

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

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

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

ここで Spanner は、UserAccessLogs データを 3 つのスプリットに分け、各スプリットには約 1,000 行の UserId 値が順序付けして含まれます。ユーザー イベントは約 1 ミリ秒間隔で発生しますが、各イベントは異なるユーザーによって発生するため、挿入の順序はタイムスタンプによる順序付けよりもホットスポットが作成される可能性がかなり下がります。スプリットの作成方法の詳細については、負荷ベースの分割をご覧ください。

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

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

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

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

GoogleSQL

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

PostgreSQL

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

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

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

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

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

GoogleSQL

CREATE TABLE UserAccessLogs (
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 は、タイムスタンプ キーが降順で格納されているためこの処理を効率的に実行します。

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

GoogleSQL

CREATE TABLE UserAccessLogs (
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 は内部でテーブルとしてインデックスを実装し、結果のインデックス テーブルは、値が単調に増加する列を最初のキー部分として使用します。

最後のアクセス行が対応するユーザー行の下にインターリーブされるインターリーブ インデックスを作成しても問題ありません。これは、単一の親行で毎秒数千件のイベントが生成される可能性は低いためです。

GoogleSQL

CREATE NULL_FILTERED INDEX UsersByLastAccess
ON Users(UserId, LastAccess),
INTERLEAVE IN Users;

PostgreSQL

CREATE INDEX usersbylastaccess ON users(userid, lastaccess)
WHERE lastaccess IS NOT NULL,
INTERLEAVE IN Users;

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

次のステップ