【実務・中級編】 セカンダリインデックス – Cloud Spanner

【Spanner設計論】セカンダリインデックスの魔力を引き出せ:カバリングとNULL値の深淵

テックリードの私だ。
コードレビューや設計レビューの場において、Cloud Spannerのセカンダリインデックスの切り方で「おや?」と思う設計に直面することが多々ある。

「とりあえず検索条件に入っているカラムをインデックスにしておきました」
――このセリフを聞いた瞬間、私はそっとプルリクエストを差し戻す。

Cloud Spannerは、正しく使えば数千万・数億行のデータから一瞬で目的のレコードを抉り出す怪物的なデータベースだが、セカンダリインデックスの挙動(特に分散ストレージとしての物理構造とNULLの扱い)を理解せずに舐めてかかると、本番稼働後に必ずレイテンシの怪物に足元をすくわれる。

今回は、Spannerのセカンダリインデックスの本質である「分散と結合(Join)のコスト」を直視し、実務で即座に使える堅牢な設計パターンを伝授する。

—

1. Cloud Spannerセカンダリインデックスの物理的真実

まず前提を揃えよう。RDB(PostgreSQLやMySQLなど)のインデックス感覚でSpannerのインデックスを見てはならない。

Spannerのセカンダリインデックスは、本質的には「それ自体が独立したもう一つのテーブル」である。
インデックスの行は、インデックスキーとして定義されたカラムの値を先頭に持ち、その末尾に「元テーブルの主キー(Primary Key)」が自動的に付与されて分散配置される。

したがって、セカンダリインデックスを使った検索は、以下の2ステップで行われる。
1. インデックスのルックアップ: インデックスのキーから元テーブルの主キーを特定する。
2. テーブルのフェッチ(読込): 特定された主キーを使って、実テーブルから実際のカラム値を取得する。

ここでピンと来たはずだ。
「インデックスヒット=実テーブルへのランダムアクセス(Join)が発生する」。これがSpannerにおけるパフォーマンス劣化の最大の温床である。

—

2. `STORING`句:カバリングインデックスによる「フェッチ殺し」

実テーブルへのフェッチを回避し、インデックスだけでクエリを完結させる手法。それがカバリングインデックス(Covering Index)だ。Spannerでは `STORING` 句を使ってこれを実現する。

悪い例:フェッチが発生するインデックス

ユーザーのメールアドレスからステータスを検索するケースを考えてみる。

— ❌ ありがちだが、実テーブルへのフェッチが発生するアンチパターン
CREATE TABLE Users (
inyin STRING(36) NOT NULL,
Email STRING(100) NOT NULL,
Status STRING(20) NOT NULL,
CreatedAt TIMESTAMP NOT NULL,
) PRIMARY KEY(UserId);

— インデックス定義
CREATE INDEX UsersByEmail ON Users(Email);

このインデックスを使って以下のクエリを投げたとしよう。

SELECT Status FROM Users WHERE Email = ‘alice@example.com’;

Spannerの内部動作:
1. `UsersByEmail` インデックスから `Email = ‘alice@example.com’` に合致する `UserId` を探す。
2. 見つかった `UserId` を使って、`Users` テーブル本体にランダムアクセスし、`Status` を取得する。

データ量が数百万件を超え、キャッシュヒット率が下がった瞬間、この「フェッチ」がI/Oのボトルネックとなり、レイテンシが跳ね上がる。

良い例:`STORING` 句による完全カバリング

ここで `STORING` 句の登場だ。検索には使わないが、SELECT句で頻繁に取得するカラムをインデックス側に「持たせて」おく。

— ⭕️ フェッチをゼロにするカバリングインデックス
CREATE INDEX UsersByEmailIncludeStatus
ON Users(Email)
STORING (Status, CreatedAt);

このインデックスを定義した場合、先ほどのクエリ (`SELECT Status…`) は、インデックスのストレージ領域を読むだけで完結(Table Readゼロ)する。
分散データベースにおいて、ネットワークを跨ぐ、あるいはディスクからのランダムI/Oを伴うフェッチを削ぎ落とせるこの効果は、スケールすればするほど効いてくる。

> ⚠️ テックリードからの警告:
> 「じゃあ、必要なカラム全部 `STORING` すればいいじゃん」と思ったそこのあなた。
> `STORING` したカラムのデータは、インデックス側にも二重に保持される。当然、書き込み(INSERT/UPDATE)時のストレージ容量と書き込みレイテンシのコストが増大する。「リードの高速化」と「ライトのコスト・ストレージ容量」のトレードオフをロジカルに計算して決めろ。

—

3. 「NULL値の罠」:Spannerインデックスの特異な仕様

次に、設計レビューで最も見落とされがちな「NULLの扱い」についてだ。ここを間違えると、クエリがインデックスを全く使わなくなる(Full Table Scanの悲劇)。

Spannerインデックスは「NULLを格納しない」

ここが一般的なRDBとの最大の思想の違いである。
Cloud Spannerのセカンダリインデックスは、インデックスキーを構成するカラムのいずれかが `NULL` である場合、その行をインデックスに一切含めない。

実例で見せよう。

CREATE TABLE Devices (
DeviceId STRING(36) NOT NULL,
UserId STRING(36), — NULL許容
FCMToken STRING(255) NOT NULL,
) PRIMARY KEY(DeviceId);

— インデックス作成
CREATE INDEX DevicesByUserId ON Devices(UserId);

この状態で、以下のクエリを実行したとする。

— ❓ このクエリはインデックスを使うか?
SELECT DeviceId, FCMToken
FROM Devices
WHERE UserId = ‘user_123’;

答えは 「使う」。`UserId` が ‘user_123’ という非NULLの値を持つため、インデックスにエントリが存在するからだ。

では、次のクエリはどうなるか?

— ❌ このクエリはインデックスを使わない(フルスキャンになる)
SELECT DeviceId, FCMToken
FROM Devices
WHERE UserId IS NULL;

答えは 「インデックスは使われない(使えない)」。
なぜなら、`UserId` が `NULL` の行は、`DevicesByUserId` インデックスの中にそもそも存在しないからだ。Spannerは `IS NULL` の条件を満たすために、泣く泣く実テーブルのフルスキャンを実行する。

対策:NULLをインデックスに含める魔術(ダミー値の活用)

もし「NULLのレコードも含めて、高速にインデックス検索したい」という要件がある場合、どう設計すべきか。
実務では、「NULLの代わりに意味を持たないダミー値を埋め込む」という設計パターンをよく使う。

— 対策例:NULLの代わりにプレースホルダー文字列を使う
— (アプリ側でNULLの代わりに ‘UNASSIGNED’ などを入れる、あるいはGenerated Columnを使う)

— 例:Google Cloud SpannerのGenerated Column(生成列)機能を使う場合
CREATE TABLE Devices (
DeviceId STRING(36) NOT NULL,
UserId STRING(36),
FCMToken STRING(255) NOT NULL,

— NULLを特定のダミー値に置換する生成列
SafeUserId STRING(36) AS (IFNULL(UserId, ‘DUMMY_USER_ID’)) STORED,
) PRIMARY KEY(DeviceId);

— この生成列に対してインデックスを貼る
CREATE INDEX DevicesBySafeUserId ON Devices(SafeUserId) STORING (FCMToken);

これで、`WHERE UserId IS NULL` の代わりに `WHERE SafeUserId = ‘DUMMY_USER_ID’` と書けば、インデックスが火を噴くようになる。このテクニックは大規模システムでは定石だ。

—

4. 実戦で使える設計チェックリスト

最後に、明日からの設計レビューでそのまま使える私からのチェックリストを贈る。

1. そのインデックス、本当に必要か?

  • 単なるアドホックな集計や、月1回しか走らないバッチのためにインデックスを追加していないか?(Spannerのライト性能を殺す原因になる)

2. クエリの実行計画(Explain)を見たか?

  • `EXPLAIN` ステートメントを叩き、意図したインデックスが使われているか(`IndexScan` になっているか)、フェッチが発生していないかを確認したか?

3. カバリング(`STORING`)の恩恵とコストのバランスは取れているか?

  • 頻繁に叩かれる高頻度クエリ(OLTPのクリティカルパス)に対してのみ `STORING` を適用しているか?

4. NULL検索の罠にハマっていないか?

  • 検索条件に指定するカラムが `NULL` を許容する場合、インデックスが効かなくなる仕様を考慮して設計しているか?

結び

Cloud Spannerは、クラウドネイティブな分散RDBの最高峰だ。しかし、その圧倒的なスケーラビリティは、物理レイヤの仕組みを理解した者へのみ微笑む。

セカンダリインデックスは「魔法の杖」ではなく「諸刃の剣」だ。
構造を理解し、無駄なフェッチを断ち、NULLの挙動をコントロールする。その泥臭いまでのこだわりこそが、あなたのシステムを数億トラフィックの荒波から守り抜く唯一の盾となる。

次のレビューでは、君たちの洗練されたインデックス設計が見られることを期待している。

コメント

タイトルとURLをコピーしました