普段業務でCloud Spannerを使っているが、雰囲気で使っている自覚が大いにあるのでドキュメントやブログを読んで知らなかったことを自分用のメモとしてまとめてみる。

Spanner のスキーマ設計の最適化  |  Google Cloud#

  • キー定義とインターリーブの2つはスケーラビリティに大きな影響を与える
  • Spannerにはルートテーブルとインターリーブされたテーブルの2種類のテーブルがある。1

テーブルレイアウト#

Spannerテーブルの行はPRIMARY_KEYによって辞書順に並べかえられる。したがって以下の特性を示す。

  • 辞書順のテーブルスキャンは効率的
  • 十分に近い行は同じディスクブロックに格納され、一緒に読み込まれてキャッシュされる

各Spannerレプリカ内のデータはスプリットブロックという2つの物理階層レベルで編成される。

インターリーブ#

ルートテーブルの各行をルート行と呼び、インターリーブされたテーブルの各行を子行と呼ぶ。ルート行とそのすべての子孫のコレクションは行ツリーと呼ばれる。

行ツリー内のオペレーションは他のスプリットとの通信を必要としないケースが多いため基本的には効率性が高い。ただし子行にホットスポットがある場合Spannerはホットスポットの行とそれ以降の子行を分離するためにスプリット境界を追加しようとする。そのため必ずしも効率性が高いという保証はない。

局所性のトレードオフ#

以下の理由で局所性を高めることが重要。

  • 通信するサーバが多いほど一時的にビジー状態のサーバに遭遇する可能性が高くなり、レイテンシの増加につながる。
  • 複数のスプリットにまたがるトランザクションは二層コミットの特性によってCPUコストとレイテンシがわずかに増加する。

局所性の勘所#

  • 分散DBである以上、一定レベルの二層コミットや非ローカルデータオペレーションは避けられない。
  • すべてのオペレーションの局所性を完璧にするのではなく最も重要なルートエンティティと最も一般的なアクセスパターンについて必要な局所性を実現することが重要。その他のオペレーションについてはそのままにすることを推奨。

インデックスのオプション#

  • デフォルトではインターリーブされていないインデックスを作成されるが、インターリーブされたインデックスを作成することもできる。
    • インターリーブされたインデックスはインターリーブされたテーブルにデータを格納する。そのためデータとインデックスが強制的に同じ行ツリーに格納され、データとインデックスの結合効率が高くなる。
  • Spannerはテーブルと同じ方法でインデックスデータを格納する。 換言すると、インターリーブされていないインデックスはルートテーブルにデータを格納する。

そのため以下が推奨される。

  • 検索範囲が単一のエンティティのときは常にインターリーブされたインデックスを使用する。
  • 逆にデータベース内のどこからでも行を検索する必要がある場合はインターリーブされていないインデックスを使用する。

STORING#

要求されたすべてのデータがインデックス自体に含まれる場合、STORING句を使用してインデックスを作成することができる。これによりベーステーブルと結合することなくクエリを完了できるためより効率的に結果を取得することができる。

現在はインデックスキーは16個、合計サイズが6KiBまでに制限されているため、STORING句を使用することで任意のインデックスに対してデータを格納することができる。

つまりSTORINGを使用するとWRITEコストおよびストレージコストの上昇と引き換えにREADコストを削減できる。

また、ドキュメントではSTORINGの便利な応用例としてNULL_FILTEREDインデックスとの併用を紹介している。それについてはこちらの記事が大変詳しいので参照されたい。

アンチパターン#

ルートテーブルをタイムスタンプ順にしてしまうとテーブルの最後に巨大なホットスポットが生まれてしまう。

タイムスタンプ順に並んだレコードが必要な場合は以下の対策が有効。

  1. 他のルートテーブルの1つにインターリーブする(ただし各ルートへの書き込みレートが十分低くなるようにする)
  2. シャーディングを使用して時系列的に連続したデータを複数のスプリットに分散する

この例ではPKにShardId(0~N-1)を追加する方法を紹介している。

spanner-sharding.webp

スキーマ設計のベスト プラクティス  |  Spanner  |  Google Cloud#

次のような場合はキー列をタイムスタンプ降順に格納することでホットスポットを回避する。

  • 最新の履歴を読み取る際、履歴にインターリーブ テーブルを使用しており、親行を読み取る場合
  • 連続したエントリを日付の新しい順に読み込む場合に、いつまで日付をさかのぼるか不明なとき
CREATE TABLE UserAccessLog (
UserId     INT64 NOT NULL,
LastAccess TIMESTAMP NOT NULL,
...
) PRIMARY KEY (UserId, LastAccess DESC);

筆者はこれまでこのようなケースではUserAccessLogテーブルにUUIDを付与しLastAccess降順なインデックスを作成することで対応することが多かった。この方法は選択肢として持てていなかったので必要になったら思い出せるとよさそう。

このケースはアクセスログなのでWRITE頻度が高いテーブルを想定していると思われる。インデックスで対応する方式と比較するとWRITEコストを下げることができる点でメリットがありそう。

と思っていたら続きにそのパターンの実装方法も書かれていた。

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

次のようなテーブルがあるとする。

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

このテーブルに対して次のような(インターリーブされていない && 最初のキーが単調増加する)インデックスを作成した場合、LastAccessは単調増加する値なので最後のスプリットに書き込みが集中し、ホットスポットが発生してしまう。

CREATE NULL_FILTERED INDEX UsersByLastAccess ON Users(LastAccess);

このようなケースでは次のような対処が考えられる。

  1. インデックスにShardIdを追加する
  2. (そもそも)インデックスをインターリーブする

セカンダリ インデックス  |  Spanner  |  Google Cloud#

  • Spannerではセカンダリインデックスに次のデータが格納される。
    • ベーステーブルのすべてのキー列
    • インデックスに含まれるすべての列
    • STORING句で指定されたすべての列

NULL値の並び替え順#

SpannerではNULLを最小値として扱う。そのため昇順の場合はNULLが先頭に、降順の場合は末尾に並ぶ。

末尾のレコードの取得#

次のようなクエリを実行する場合は結果をすばやく取得することができる。これはSpannerがテーブルの行をPRIMARY_KEYによって辞書順に並べかえるため。

-- テーブル定義
CREATE TABLE Songs (
    SongId INT64 NOT NULL,
    ...
) PRIMARY KEY (SongId);

-- クエリ
SELECT SongId FROM Songs LIMIT 1;

しかしSpannerではテーブル全体をスキャンしないと列の最大値を取得することができないため次のようなクエリはすばやく結果を返さない。

SELECT SongId FROM Songs ORDER BY SongId DESC LIMIT 1;

このようなケースでは次のようにPK降順のインデックスを明示的に作成することで読み取りパフォーマンスを向上させることができる。

CREATE INDEX SongIdDesc On Songs(SongId DESC);

Sharding of timestamp-ordered data in Cloud Spanner - googblogs.com#

Sharding of timestamp-ordered data in Cloud Spanner - googblogs.com
By Karthi Thyagarajan, Solutions Architect Cloud Spanner was designed from the ground up to offer horizontal scalability and a developer-friendly SQL interface. As a managed service, Google Cloud handles most database management tasks, but it’s up to you to ensure that there are no hotspots, as described in Schema Design Best Practices and Optimizing Schema Design for Cloud Spanner. In this article, we’ll look at how to efficiently insert and retrieve records with timestamp ordering. We’ll start with the high-level guidance provided in Anti-pattern: timestamp ordering and explore the scenario in more detail with a concrete example. Scenario Let’s say we’re building an app that logs user activity along with timestamps and also allows users to query this activity by user id and time range. A good primary key for the table storing user activity (let’s call it LogEntries) is (UserId, Timestamp), as this gives us a uniform distribution of activity logs. Cloud Spanner inserts log entries sequentially, but they’re naturally sharded by UserId, resulting in uniform key distribution. Table LogEntries UserId (PK) Timestamp (PK) LogEntry 15b7bd1f-8473 2018-05-01T15:16:03.386257Z Here’s a sample query to retrieve a list of log entries by user and time range:SELECT UserId, Timestamp, LogEntry FROM LogEntries WHERE UserID = '15b7bd1f-8473' AND Timestamp BETWEEN '2018-05-01T15:14:10.386257Z' AND '2018-05-01T15:16:10.386257Z'; This query takes advantage of the primary key and thus performs well. Now let’s make things more interesting. What if we wanted to group users by the company they work for so we can segment reports by company? This is a fairly common use case for Cloud Spanner, especially with multi-tenant SaaS applications. To support this, we create a table with the following schema.Table LogEntries CompanyId (PK) UserId (PK) Timestamp (PK) LogEntry Acme 15b7bd1f-8473 2018-05-01T15:16:03.386257Z And here’s the corresponding query to retrieve the log entries: SELECT CompanyId, UserId, Timestamp, LogEntry FROM LogEntries WHERE CompanyID = 'Acme' AND UserID = '15b7bd1f-8473' AND Timestamp BETWEEN '2018-05-01T15:14:10.386257Z' AND '2018-05-01T15:16:10.386257Z'; Here’s the query to retrieve log entries by CompanyId and time range (user field not specified):SELECT CompanyId, UserId, Timestamp, LogEntry FROM LogEntries WHERE CompanyID = 'Acme' AND Timestamp BETWEEN '2018-05-01T15:14:10.386257Z' AND '2018-05-01T15:16:10.386257Z'; To support the above query, we add a separate, secondary index. Initially, we include just two columns:CREATE INDEX LogEntriesByCompany ON UserActivity(CompanyId, Timestamp) Challenge: hotspots during inserts The challenge here is that some companies may have a lot more (orders of magnitude more) users than others, resulting in a very skewed distribution of log entries. The challenge is particularly acute during inserts as described in the opening paragraph above. And even if Cloud Spanner helps out by creating additional splits, nodes that service new splits become hotspots due to uneven key distribution. The above diagram depicts a scenario where Company B has three times more users than Company A or Company C. Therefore, log entries corresponding to Company B grow at a higher rate, resulting in the hotspotting of nodes that service the splits where Company B’s log entries are being inserted. Hotspot mitigation There are multiple aspects to our hotspot mitigation strategy: schema design, index design and querying. Let’s look at each of these below. Schema and index design  As described in Anti-pattern: timestamp ordering, we’ll use application-level sharding to distribute data evenly. Let’s look at one particular approach for our scenario: instead of (CompanyId, UserId, Timestamp), we’ll use (UserId, CompanyId, Timestamp).Table LogEntries (reorder columns CompanyId and UserId in Primary Key) UserId (PK) CompanyId (PK) Timestamp (PK) LogEntry 15b7bd1f-8473 Acme 2018-05-01T15:16:03.386257Z By placing UserId before CompanyId in the primary key, we can mitigate the hotspots caused by the non-uniform distribution of log entries across companies. Now let’s look at the secondary index on CompanyId and timestamp. Since this index is meant to support queries that specify just CompanyId and timestamp, we cannot address the distribution problem by simply incorporating UserId. Keep in mind that indexes are also susceptible to hotspots and we need to design them so that their distribution is uniform. To address this, we’ll add a new column, EntryShardId, where (in pseudo-code): entryShardId = hash(CompanyId + timestamp) % num_shards The hash function here could be a simple crc32 operation. Here’s a python snippet illustrating how to calculate this hash function before a log entry is inserted:... import datetime import zlib ... timestamp = datetime.datetime.utcnow() companyId = 'Acme' entryShardId = (zlib.crc32(companyId + timestamp.isoformat()) & 0xffffffff) % 10 ... In this case, num_shards = 10. You can adjust this value based on the characteristics of your workload. For instance, if one company in our scenario generates 100 times more log entries on average than the other companies, then we would pick 100 for num_shards in order to achieve a uniform distribution across entries from all companies. This hashing approach essentially takes the sequential, timestamp-ordered LogEntriesByCompany index entries for a particular company and distributes them across multiple application (or logical) shards. In this case, we have 10 such shards per company, resulting from the crc32 and modulo operations shown above.Table LogEntries (with EntryShardId added) CompanyId (PK) UserId (PK) Timestamp (PK) EntryShardId LogEntry ‘Acme’ 1 2018-05-01T15:16:03.386257Z 8 And the index: CREATE INDEX LogEntriesByCompany ON LogEntries(EntryShardId, CompanyId, Timestamp) Querying Evenly distributing data using a sharding approach is great for inserts but how does it affect retrieval? Application-level sharding is no good to us if we cannot retrieve the data efficiently. Let’s look at how we would query for a list of log entries by CompanyId and time range, but without UserId:SELECT CompanyId, UserId, Timestamp, LogEntry FROM LogEntries@{FORCE_INDEX=LogEntriesbyCompany} WHERE CompanyId = 'Acme' AND ShardedEntryId BETWEEN 0 AND 9 AND Timestamp > '2018-05-01T15:14:10.386257Z' AND Timestamp The above query illustrates how to perform a timestamp range retrieval while taking sharding into account. By including the ShardedEntryId in the query above, we tell Spanner to ‘look’ in all 10 logical shards to retrieve the timestamp entries for CompanyId ‘Acme’ for a particular range. Cloud Spanner is a full-featured relational database service that relieves you of most—but not all—database management tasks. For more information on Cloud Spanner management best practices, check out the recommended reading.Anti-pattern: timestamp orderingOptimizing Schema Design for Cloud SpannerBest Practices for Schema Design
https://www.googblogs.com/sharding-of-timestamp-ordered-data-in-cloud-spanner/

タイムスタンプ順に並んだレコードをいかに効率よく挿入、取得するかについての解説記事。

レコードの挿入時#

次のようにCompanyID、UserID、Timestampの3つのキーを持つログテーブルがあるとする。

CompanyId(PK) UserId(PK) Timestamp(PK) LogEntry
Acme 15b7bd1f-8473 2018-05-01T15:16:03.386257Z

このスキーマにしたがって単純にデータを挿入していくとユーザー数が多い企業があるとホットスポットが発生してしまう。

そのための次のような疑似コードで表現できるEntryShardIdを用いることで挿入時のホットスポットを回避できる。

entryShardId = hash(CompanyId + timestamp) % num_shards

レコードの読み取り時#

次のようなインデックスを追加する。

CREATE INDEX LogEntriesByCompany ON LogEntries(EntryShardId, CompanyId, Timestamp)

そして以下のようなクエリを実行する。

SELECT CompanyId, UserId, Timestamp, LogEntry
FROM LogEntries@{FORCE_INDEX=LogEntriesByCompany}
   WHERE CompanyId = 'Acme'
   AND EntryShardId BETWEEN 0 AND 9
   AND Timestamp > '2018-05-01T15:14:10.386257Z'
   AND Timestamp < '2018-05-01T15:16:10.386257Z'
ORDER BY Timestamp DESC;

こうすることでインデックスを使って10個の論理シャードから条件にあったレコードを効率よく取得することができる。

余談#

前章のクエリのWHERE句内で以下のようにCompanyId, EntryShardIdの順で指定していたので正しくインデックスが使えるのか気になったので試してみた。

   WHERE CompanyId = 'Acme'
   AND EntryShardId BETWEEN 0 AND 9

準備#

任意のGoogle CloudプロジェクトでSpannerインスタンスを作成し、以下のDDLを実行してテーブルとインデックスを作成する。

CREATE TABLE LogEntries (
  CompanyId STRING(36) NOT NULL,
  UserId STRING(36) NOT NULL,
  Timestamp TIMESTAMP NOT NULL,
  EntryShardId INT64 NOT NULL,
  LogEntry STRING(MAX) NOT NULL,
) PRIMARY KEY (CompanyId, UserId, Timestamp);
CREATE INDEX LogEntriesByCompany ON LogEntries(EntryShardId, CompanyId, Timestamp);

次にテストデータをINSERTする。

項目 条件
シャード数 10
Companyの数 5
従業員数 20

上記の条件で合計1000レコードを挿入するINSERT文を作成した。(スクリプトはこちら

クエリの実行#

これに対して次の2つのクエリを実行し、実行計画を確認した。

-- 記事で紹介されていたクエリ
SELECT
  CompanyId,
  UserId,
  Timestamp,
  LogEntry
FROM
  LogEntries@{FORCE_INDEX=LogEntriesByCompany}
WHERE
  CompanyId = 'amazon'
  AND EntryShardId BETWEEN 0 AND 9
  AND Timestamp > '2023-01-01T00:00:00.386257Z'
  AND Timestamp < '2023-01-01T00:00:05.386257Z'
ORDER BY
  Timestamp DESC;
-- CompanyId, EntryShardIdの順番を逆にしてみたクエリ
SELECT
  CompanyId,
  UserId,
  Timestamp,
  LogEntry
FROM
  LogEntries@{FORCE_INDEX=LogEntriesByCompany}
WHERE
  EntryShardId BETWEEN 0 AND 9
  AND CompanyId = 'amazon'
  AND Timestamp > '2023-01-01T00:00:00.386257Z'
  AND Timestamp < '2023-01-01T00:00:05.386257Z'
ORDER BY
  Timestamp DESC;

結果#

結論からいうとどちらのクエリでも同様にインデックスを利用できていた。おそらくSpannerのオプティマイザがいい感じに判断してくれていると思われる。以下が実際の実行計画。

記事で紹介されていたクエリ original.webp

CompanyId, EntryShardIdの順番を逆にしてみたクエリ swap.webp

Cloud Spanner におけるトランザクションのロックについて#

Spanner におけるトランザクションのロックの粒度は、セル、つまり行と列の交点となります。

なのでREAD, WRITEの範囲(行および列)を最小限にすることでロック範囲を最小限にすることができる。結果として他のトランザクションに与えるパフォーマンス影響を最小限にすることができる。

他には複数のトランザクションが同時に実行された際の優先度について詳しく解説してあった。

Spanner の読み取りと書き込みのライフサイクル  |  Google Cloud#

Spannerのレプリカセットの構成については次の記事が非常にわかりやすい。

読み込みおよび書き込み時のロックの取り方について詳しく解説されていた。

読み取り専用トランザクション#

  • ロックを取得せずに実行できるため高速
  • ステイル読み込みを使用できる場合はより高速にすることができる(他のオペレーションに与えるパフォーマンス影響も抑えることができる)
    • Spannerはレプリカへの同期を10秒ごとに行っているため、10秒以上前のデータをステイル読み込みできる場合は読み取りのスループットをさらに高めることができる。

読み取り / 書き込みトランザクション#

  • 読み取った行に対して共有ロックを取得する
  • 書き込む行に対して排他ロックを取得する

ちなみに複数のトランザクションが同時に実行された場合の挙動については次の記事が詳しかった。

SQL のベスト プラクティス  |  Spanner  |  Google Cloud#

クエリパラメータの使用#

クエリパラメータを使用することで次のメリットがある。

  • キャッシュが容易になるためクエリのパフォーマンスが向上する
  • 文字列の値をエスケープする必要がないため構文エラーのリスクが減る
  • SQLインジェクションを防ぐことができる

範囲キーのルックアップを最適化する#

キーのリストが短く、連続していない場合は次のようにIN UNNESTを使用する。

SELECT *
FROM Table AS t
WHERE t.Key IN UNNEST (@KeyList)

キーのリストが連続して範囲内である場合には次のように下限と上限を設定する。[@min, @max]の範囲をすべてスキャンするため効率的にスキャンできる。

SELECT *
FROM Table AS t
WHERE t.Key BETWEEN @min AND @max

結合を最適化する#

可能な限りインターリーブされたテーブルのデータを主キーによって結合する#

インターリーブされた子行とそのルート行は基本的に同じスプリットに格納されるためローカルで結合することができ、効率的に結合できるため。

結合の順序を強制する#

Spanner側の最適化によって結合順序が変更され(この場合はSingers JOIN Albumsと記述したがAlbums JOIN Singersの順序に変更されたケース)パフォーマンスが低下した場合などはFORCE_JOIN_ORDERヒントを使用することで結合順序を強制することができる。

SELECT *
FROM Singers AS s JOIN@{FORCE_JOIN_ORDER=TRUE} Albums AS a
ON s.SingerId = a.Singerid
WHERE s.LastName LIKE '%x%' AND a.AlbumTitle LIKE '%love%';

JOINアルゴリズムを指定する#

次のようにJOIN_METHODヒントを使用することでJOINアルゴリズムを指定することができる。

SELECT *
FROM Singers s JOIN@{JOIN_METHOD=HASH_JOIN} Albums AS a
ON a.SingerId = a.SingerId

JOINアルゴリズムについては次の記事が詳しい。

LIKEの代わりにSTARTS_WITHを使用する#

Spannerはパラメータ化されたLIKEパターンを実行時まで評価しないのですべての行を読み取ったうえでLIKE式で評価し、一致しない行を除外するためパフォーマンスが悪い。

適切なインデックスが作成されている場合はLIKEの代わりにSTARTS_WITHを使用するとSpannerはクエリ実行プランをより効率的に最適化することができる。

-- 非推奨
SELECT a.AlbumTitle FROM Albums a
WHERE a.AlbumTitle LIKE @like_clause;

-- 推奨
SELECT a.AlbumTitle FROM Albums a
WHERE STARTS_WITH(a.AlbumTitle, @prefix);

クエリ実行プラン  |  Spanner  |  Google Cloud#

CPUの消費量が多いクエリの場合、実行計画は30日間保存されている。

確認方法は以下。

クエリごとにレイテンシや返された行数、スキャン行数の平均値や実行回数確認することができるためチューニングの際にかなり便利そう。

まとめ#

Spannerの気持ちが少しわかった。すごく強引にまとめると以下のようなポイントが重要そう。

  • ホットスポットに注意する
  • 結合コストを最小限にする
  • スキャン行数を最小限にする
  • ロック範囲を最小限にする

これまで漫然とドキュメントを読んで頭に入らなかったり記憶が定着しなかったりしたが、ブログにアウトプットしながら読むと理解度も定着度も(モチベーションも)上がるのでやってよかった。

次はCloud Runとも仲良くなりたい。


  1. ①ルートテーブル②インターリーブされたテーブル③インターリーブの子を持たない通常のテーブルの3種類だと思い込んでいたが③は①として扱われるようだ。 ↩︎