【Spannerの深層】インデックス更新メカニズムの裏側と、大規模DBを沈めないための設計戦略
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんな質問を受けたことはないだろうか。
- 「Cloud Spannerって、インデックスを追加したら裏で勝手に同期してくれるんですよね?」
- 「トランザクションの中でセカンダリインデックスも即座に更新されるなら、書き込み性能って落ちませんか?」
- 「非同期インデックス構築って、本番トラフィックに影響を与えないんですか?」
もし、これらの問いに「はい、全部よしなにやってくれます」としか答えられないとしたら、あなたのシステムはいずれ高負荷時にシリアライゼーション・コンフリクト(直列化競合)や、予期せぬレイテンシの跳ね上がりによって足元をすくわれることになる。
Cloud Spannerは、リレーショナルデータベースの利便性と、ペタバイト級の水平分散スケールを奇跡的なバランスで両立させた怪物だ。その魔法の裏側では、分散トランザクション、Paxosグループ間の協調、そして巧妙な非同期パイプラインが複雑に絡み合っている。
今回は、Spannerの「インデックス更新メカニズム」というコア中のコアを丸裸にし、我々エンジニアが実務でどう設計し、どう立ち回るべきかをロジカルに伝授しよう。
—
1. 概念の破壊:Spannerにおけるセカンダリインデックスの正体
まず、前提を合わせよう。伝統的なRDB(PostgreSQLやMySQLなど)におけるインデックスの感覚を、今すぐ脳内からデリートしてほしい。
Spannerにおいて、セカンダリインデックスは「単なる補助データ構造」ではない。
それ自体が、独立したテーブル(分散B-tree)として物理的に存在し、ベーステーブルとは別のスプリット(Split)に分割され、全く異なるPaxosグループに配置される可能性がある。
つまり、ベーステーブルの行を1行 `INSERT` または `UPDATE` するということは、論理的には「ベーステーブルの行が存在するPaxosグループ」から、「インデックスのキーが属する別のPaxosグループ」へ、ネットワークを跨いだ分散書き込みを行っていると同義なのだ。
[クライアント]
│ (Read/Write トランザクション)
▼
[Paxosグループ A (ベーステーブル)] ──(分散コミット/2PC)──► [Paxosグループ B (セカンダリインデックス)]
このアーキテクチャを理解していれば、「インデックスを張れば張るほど、書き込み時のトランザクションが巻き込むPaxosグループの数が増え、分散コミットのオーバヘッド(2PC: 2Phase Commit)が増大する」という事実が直感的に理解できるはずだ。
—
2. トランザクション実行時のインデックス整合性維持
Spannerは、ACID特性を完全に担保する。つまり、トランザクションがコミットされた瞬間、ベーステーブルとすべてのセカンダリインデックスの間で、データは完全に同期していなければならない。
これを実現するために、Spannerのトランザクションエンジンは内部で以下のように動いている。
1. ロックの獲得と変更の蓄積:
トランザクション内での書き込み(`DML` または `Mutation`)は、まずローカルのバッファに蓄積される。この時点ではまだコミットされていない。
2. 分散コミットプロトコル(2PC)の始動:
`Commit` が呼ばれると、変更に関係するすべてのPaxosグループ(ベーステーブル側 + 影響を受けるすべてのインデックス側)の間で、コーディネーターを介した2PCが実行される。
3. TrueTimeによる原子性:
Spannerの真骨頂である `TrueTime` APIにより、コミットタイムスタンプ $S$ がグローバルに一意に決定される。インデックスを含むすべてのレコードは、時刻 $S$ において不可分に更新されたものとして可視化される。
実務上の注意:ホットスポットとロック競合
ここでエンジニアが最も警戒すべきは、「インデックスのキー設計のミスによるホットスポット」だ。
例えば、以下のようなテーブルとインデックスを考えてほしい。
— ユーザーのアクティビティログ
CREATE TABLE UserActivities (
UserId INT64,
ActivityTime TIMESTAMP,
ActivityType STRING(64),
Payload STRING(MAX),
) PRIMARY KEY(UserId, ActivityTime);
— 時系列のインデックス(最悪のアンチパターン例)
CREATE INDEX ActivitiesByTime ON UserActivities(ActivityTime);
`ActivityTime` は常に「現在時刻」付近に新しい値が集中する。これはSpannerのスプリットにおいて最悪のアンチパターン(モノトニック増加キー)だ。
結果として、`ActivitiesByTime` インデックスの特定の範囲(今この瞬間のタイムスタンプを持つ領域)を維持する単一のPaxosリーダーに、全トランザクションの書き込みが集中し、CPU枯渇やレイテンシの悪化(さらにはアボートの頻発)を引き起こす。
【設計の鉄則】
インデックスの先頭キーには、十分に分散するカラム(UUIDやハッシュ化されたIDなど)を配置するか、インターリーブ構造を活用してベーステーブルとインデックスの物理的局所性を一致させよ。
—
3. 非同期インデックス構築プロセス:巨大テーブルを止めるな
数億〜数千億行ある既存の巨大テーブルに対して、後からセカンダリインデックスを追加する場合を想像してほしい。
もし同期的に全行をスキャンしてインデックスを作ろうものなら、データベース全体がロックされ、本番サービスは確実に死に至る。
Spannerはこれを防ぐため、非同期インデックス構築(Asynchronous Index Backfill)という洗練されたメカニズムを採用している。
インデックスのライフサイクル
Spannerでインデックスを追加する際、DDLを発行するとインデックスは即座に作成されるが、最初は `STATE_BUILDING` という状態になる。
— DDLの発行(即座に応答が返る)
CREATE INDEX UsersByEmail ON Users(Email);
内部で何が起きているか?
1. メタデータの登録:
まずはスキーマ変更としてインデックスの定義が登録されるが、この時点ではデータは空、あるいは構築中ステータスである。
2. バックグラウンドスキャン (Backfill):
Spannerの分散ストレージエンジンは、バックグラウンドで静かに、かつスロットリング(リソース制御)を効かせながら、ベーステーブルの過去データをスキャンし、インデックス用のキーを生成・書き込みしていく。
この間も、ベーステーブルへの通常の読み書きトランザクションは1ミリ秒たりともブロックされない。
3. キャッチアップとコミット:
過去データのバックグラウンドスキャンが進むのと並行して、リアルタイムのトランザクションによって更新された差分データも適切にインデックスに反映される。
4. アクティブ化 (`STATE_ONLINE`):
バックグラウンド処理が完了すると、自動的にインデックスの状態が `ONLINE` に遷移し、オプティマイザがそのインデックスをクエリプランで使用し始める。
実務でのオペレーション知見
非同期バックグラウンド処理とはいえ、大規模テーブル(数TB以上)に対するインデックス作成は、ストレージI/OやCPUリソースを消費する。
- 本番ピークタイムを避ける: バックグラウンド処理はスロットリングされるとはいえ、極限まで負荷を抑えるわけではない。バッチ処理やトラフィックが落ち着いた時間帯にDDLを実行する配慮がプロのエンジニアだ。
- 進捗の確認: インデックスのビルド状態は `INFORMATION_SCHEMA.INDEXES` から確認できる。CI/CDパイプラインやデプロイ自動化スクリプトに組み込む際は、必ず `STATE` が `ONLINE` になるのをポーリングで確認する堅牢なデプロイメントを組むこと。
— インデックスのビルド状態を確認するクエリ
SELECT
TABLE_NAME,
INDEX_NAME,
PARENT_TABLE_NAME,
STATE
FROM INFORMATION_SCHEMA.INDEXES
WHERE INDEX_NAME = ‘UsersByEmail’;
—
4. ストリーミング読み取りとインデックスの裏側
オプティマイザがどのようにインデックスを選ぶかについても、アーキテクチャの観点から触れておこう。
Spannerはコストベースオプティマイザ(CBO)を搭載している。クエリが発行された際、オプティマイザは「ベーステーブルをスキャンするコスト」と「セカンダリインデックスをスキャンしてベーステーブルをフェッチするコスト(Index Lookup)」を比較する。
ここで重要なのが、カバリングインデックス(Covering Index)の概念だ。
— カバリングインデックスの定義例
CREATE INDEX UsersByCountryAndName ON Users(Country, Name) STORING (Email);
`STORING` 句を使うことで、インデックスのB-treeリーフノードに非キーカラム(上記の例では `Email`)を同居させることができる。
これにより、オプティマイザは `Country` と `Name` で絞り込みを行った際、ベーステーブルへ再アクセス(ランダムアクセス)することなく、インデックスだけでクエリを完結させることができる。
【パフォーマンス上の注意】
何でもかんでも `STORING` でカラムを持たせると、インデックス自体のサイズが肥大化し、メモリ(バッファキャッシュ)効率が落ちる。
「読み取りの頻度」「ペイロードの大きさ」「レイテンシの要件」を天秤にかけ、本当にカバリングすべき最小限の列だけを `STORING` に指定する勇気を持て。
—
5. まとめ:チーフアーキテクトからの提言
Cloud Spannerのインデックス更新メカニズムは、単なる「便利な機能」ではない。それは「分散システムにおける一貫性とスケーラビリティの妥協なきトレードオフの結晶」だ。
今日から設計レビューを行う際、以下のチェックリストを心に刻んでほしい。
1. 書き込み性能の算定: 追加しようとしているセカンダリインデックスは、書き込みトランザクションのPaxosグループ数を増やしていないか?本当にそのインデックスは必要か?
2. キーの分散性: インデックスの先頭キーがモノトニックに増加し、ホットスポットを生む設計になっていないか?
3. バックグラウンド運用の考慮: 巨大テーブルへのインデックス追加は、非同期で行われる特性を理解し、デプロイメントフローに組み込んでいるか?
4. カバリングの最適化: 頻繁に実行されるクエリに対して、`STORING` を適切に使い分けてネットワークのラウンドトリップを削減できているか?
仕組みの本質を知る者だけが、Spannerの真のパフォーマンスを引き出すことができる。
あなたの手掛けるシステムが、圧倒的なスケールと堅牢性を手に入れることを期待している。
コメント