MySQL ユーザーが Cloud Spanner でのテーブル設計を学んだ話
はじめに
MySQL は触ったことがあるけれど、Cloud Spanner(以下、Spanner)はまだこれから……という人向けに、
「MySQL の感覚のまま設計するとハマりやすいポイント」を中心に、学んだことを整理します。
特に以下のような人を想定しています。
- MySQL のテーブル設計やインデックス設計は一通り経験がある
- Spanner をこれから使い始める/検証している
- 「RDB っぽいけどスケーラブルってどういうこと?」と気になっている
Cloud Spanner とはなにか?
Spanner は、Google Cloud が提供するフルマネージドなリレーショナルデータベース(いわゆる NewSQL)です。
特徴をざっくり挙げると、次のようになります。
- 無制限に近いスケーリング
- 強い整合性を保ったトランザクション
- 最大 99.999% の可用性
物理的には、データは複数リージョン・複数サーバーに分散して保存されていますが、
アプリケーションから見ると、あたかも 1 台のデータベースサーバー のように扱えます。
ただし、内部構造(データの分散方法)が MySQL と大きく異なるため、
「MySQL と同じノリで ID やインデックスを設計すると、パフォーマンス問題(ホットスポット)を起こしやすい」のが注意点です。
以下では、MySQL ユーザー視点で押さえておきたい設計ポイントを見ていきます。
1. ID は単調増加にしない ― ホットスポットの話
MySQL では、AUTO_INCREMENT のように 1, 2, 3 ... と増えていく ID を使うことがあると思います。
しかし Spanner では、単調増加する ID を安易に使うと「ホットスポット」 という問題を引き起こします。
Spanner の内部構造:スプリットとキー範囲
Spanner は、データを 「スプリット(split)」という単位 に物理的に分割して持っています。
- 各スプリットは「あるキー範囲(例:
1〜1000,1001〜2000)」を担当 - スプリットは複数サーバーに分散配置される
- データは 物理的にキーの昇順(または降順)で並べて保存 される
ここで、主キーに単調増加する ID(1, 2, 3...)を使うと、
新しく挿入されるレコードは常に「最新の ID が入るスプリット」にだけ集中してしまいます。
結果として、
- ある 1 台のサーバー(=そのスプリット)が 全書き込みを受け続ける
- そのサーバーだけがボトルネックになり、スケールしない
これが ホットスポット と呼ばれる現象です。
回避策:UUID など単調増加しないキーを使う
Spanner では、ホットスポットを避けるために 主キーに単調増加しない値 を使うのが基本です。
- UUID(ランダムな ID)
- 時刻ベースでも、ランダム要素を含めて偏りを減らす
ポイントは、新規書き込みがキー空間全体にばらけるようにすること。
これにより、スプリットが複数サーバーに分かれていても、書き込み負荷を分散できます。
2. インターリーブで「親子関係」を物理的にも近づける
MySQL では、親子関係のテーブルを作るときは単に外部キーで紐づけることが多いと思います。
Spanner では、これに加えて インターリーブ(INTERLEAVE) という仕組みがあります。
インターリーブとは?
インターリーブを使うと、親子の行を物理的にも近い場所に配置 できます。
これにより、
- 親と子をまとめて読むクエリが速くなりやすい
- データの論理的な階層構造(親 → 子 → 孫)をテーブル構造としても表現できる
インターリーブの前提:主キーの接頭辞
インターリーブを使うには、親テーブルの主キー列が、子テーブルの主キーの先頭に来ている必要があります。
例として、Singers(歌手)、Albums(アルバム)、Songs(曲)という 3 つのテーブルを考えます。
CREATE TABLE Singers (
SingerId INT64 NOT NULL PRIMARY KEY,
FirstName STRING(1024),
LastName STRING(1024),
SingerInfo BYTES(MAX),
);
CREATE TABLE Albums (
SingerId INT64 NOT NULL,
AlbumId INT64 NOT NULL,
AlbumTitle STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId),
INTERLEAVE IN PARENT Singers ON DELETE CASCADE;
CREATE TABLE Songs (
SingerId INT64 NOT NULL,
AlbumId INT64 NOT NULL,
TrackId INT64 NOT NULL,
SongName STRING(MAX),
) PRIMARY KEY (SingerId, AlbumId, TrackId),
INTERLEAVE IN PARENT Albums ON DELETE CASCADE;
このように、
-
AlbumsはSingersの子 -
SongsはAlbumsの子 - 主キーは必ず「親の主キー → 自分のキー」の順番
となります。
インターリーブを使うメリット
- 親と子をまとめて読むクエリ(例:ある歌手の全アルバム・全曲)が高速になりやすい
- 物理的にも近くに置くことで、I/O の局所性が上がる
一方で、すべての親子関係でインターリーブを使うべきではなく、
- 親子を 一緒に読むユースケースが多いか
- 子テーブルが極端に肥大化しないか
といった点を考慮して設計するのがよさそうです。
3. セカンダリインデックス ― MySQL と似ているけど考え方が少し違う
Spanner にも セカンダリインデックス があります。
ざっくり言うと、MySQL のインデックスと役割は似ています。
- 検索を効率化するための仕組み
- 主キー以外の列で高速に探したいときに使う
しかし、Spanner ではインデックスの内部表現が少し違います。
インデックスは「別テーブル」として保存される
Spanner では、インデックスは ベーステーブルとは別のテーブルとして保存 されます。
インデックスには、次の列が含まれます。
- インデックスに指定した列
- ベーステーブルの主キー列
STORING句で指定した列
重要なのは、インデックスも 1 つのテーブルと同じく、スプリットされて分散配置される ということです。
そのため、インデックス設計を誤ると インデックス側でホットスポット を起こします。
- 先頭列に単調増加する値を置くと、
→ 新規書き込みが特定サーバーに集中するのは「テーブル」と同じ
インデックスの例
CREATE INDEX SongsBySingersSongName
ON Songs(SingerId, SongName)
INTERLEAVE IN Singers;
このように、インデックス自体も INTERLEAVE できます。
Singers 単位で曲名検索をしたいときなどに有効です。
STORING 句(カバリングインデックス)
STORING 句を使うと、インデックス上に検索以外の列のコピーも保存 できます。
これにより、インデックスだけでクエリが完結し、ベーステーブルとの JOIN を避けられることがあります。
CREATE INDEX SingersByLastNameStoringFirstName
ON Singers (LastName)
STORING (FirstName);
MySQL でいう「カバリングインデックス」と似た考え方です。
FORCE_INDEX で明示的にインデックスを指定する
Spanner では、クエリを投げると 最適と思われるインデックスが自動で利用 されます。
ただし、パフォーマンスがシビアなクエリでは、どのインデックスを使うかを明示したい 場面もあります。
その場合に使うのが FORCE_INDEX ディレクティブです。
SELECT AlbumId, AlbumTitle, MarketingBudget
FROM Albums@{FORCE_INDEX=AlbumsByAlbumTitle}
WHERE AlbumTitle >= "Aardvark" AND AlbumTitle < "Goo";
4. MySQL にはない便利な型:STRUCT と ARRAY
Spanner には、MySQL にはない型がいくつかあります。
その中でも、クエリを書くうえで特に印象的だったのが STRUCT と ARRAY です。
STRUCT 型
STRUCT は、異なるデータ型のフィールドを 1 つのオブジェクトとしてまとめる 型です。
- 「カラム名 + 値」のペアを 1 単位として扱える
- クエリの中で複雑な構造を表現するのに便利
ARRAY 型
ARRAY は、同じ型の値を複数個まとめて 1 つの列として持てる 型です。
-
ARRAY<INT64>のように、同じ型の値の配列を表現 -
ARRAY<STRUCT<...>>のように、構造体の配列も扱える
STRUCT × ARRAY の利用イメージ
以下は、歌手ごとに「アルバム一覧」を ARRAY<STRUCT<AlbumId, AlbumTitle>> として返す例です。
SELECT
SingerId,
FirstName,
LastName,
-- アルバム情報を STRUCT の ARRAY として集約して取得
ARRAY(
SELECT AS STRUCT AlbumId, AlbumTitle
FROM Albums
WHERE Albums.SingerId = Singers.SingerId
ORDER BY AlbumId
) AS AlbumList
FROM Singers
WHERE SingerId = 1;
このクエリの結果は、イメージとしては次のようになります。
| SingerId | FirstName | LastName | AlbumList |
|---|---|---|---|
| 1 | Marc | Richards | [{"AlbumId": 1, "AlbumTitle": "Total Junk"}, {"AlbumId": 2, "AlbumTitle": "Go, Go, Go"}] |
5. その他の型:NUMERIC と TIMESTAMP
Spanner には、型まわりでも MySQL と違うポイントがあります。
特に覚えておきたいのが NUMERIC と TIMESTAMP です。
NUMERIC 型
NUMERIC は、金額など誤差が許されない数値計算 に向いた型です。
- 浮動小数点数のような丸め誤差を避けたいときに使う
- 金銭計算やポイント計算などで活躍
MySQL で DECIMAL を使うシーンに近いイメージですが、
Spanner ではより積極的に NUMERIC を選ぶのが良さそうです。
TIMESTAMP 型
TIMESTAMP は MySQL にもありますが、Spanner では少し背景が異なります。
- MySQLのTIMESTAMPは基本的にサーバーのタイムゾーン設定に依存
- Spanner の
TIMESTAMPは、
Google 全体で同期された時計 を基盤としており、
より厳密な時間管理が可能
グローバルに分散したトランザクションでも一貫した時間順序を保証できるのが、
Spanner の大きな強みです。
まとめ:MySQL ユーザーが Spanner 設計で意識したいポイント
MySQL の経験があると、Spanner も「なんとなく似ている」ように感じますが、
内部構造を意識せずに設計するとハマりやすい箇所も多いです。
特に意識したいポイントをまとめると:
-
ID に単調増加する値を使わない
- ホットスポットを避けるために、UUID などランダムなキーを選ぶ
-
インターリーブを使って、親子テーブルを物理的にも近づける
- 一緒に読むテーブルは、主キー設計も含めて慎重に
-
セカンダリインデックスも 1 つのテーブルとしてスケールすることを意識する
- インデックス先頭の列に単調増加する値を置かない
- 必要に応じて
STORINGやFORCE_INDEXを活用
- STRUCT / ARRAY / NUMERIC / TIMESTAMP など、Spanner ならではの型を知っておく
Spanner は、RDB の使いやすさと分散システムのスケーラビリティを両立した強力なサービスです。
MySQL の知識を土台にしつつ、「データが分散されている前提」でテーブル設計やインデックス設計を考える と、スムーズに理解が進むと思います。
これから Spanner を触る MySQL ユーザーの参考になれば幸いです。
参考文献
スキーマ設計のベスト プラクティス
スキーマの概要
セカンダリ インデックス
Sharding of timestamp-ordered data in Cloud Spanner
Google Cloud Spannerにおけるページネーションとインデックスの考え方
Cloud Spannerでセカンダリインデックスを使うときの勘所
本番稼働中の Spanner にダウンタイム無しに57時間かけてインデックスを追加して得た知見
Discussion