オンラインスキーマ更新の真実:数千万QPSを止めずに進化し続けるデータベースの設計思想
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんな質問を受けたことはないか?
「プロダクトの成長に伴ってカラムを追加したいのですが、メンテナンスウィンドウを何時間確保すればいいですか? 止める時間は深夜の3時がいいでしょうか?」
……ふっ。Cloud Spannerを採用しておきながら「停止時間を確保する」などという言葉が出るうちは、まだSpannerの魂を理解していないと言わざるを得ない。
リレーショナルデータベースの歴史において、大規模なスキーマ変更(DDL)は常にエンジニアの頭痛の種だった。数億行を誇る巨大なテーブルに対する `ALTER TABLE` は、数時間のロック競合、レプリケーションの遅延、そして最悪の場合はサービス全体のエンドユーザー向け停止(アウトテージ)を引き起こす。
しかし、Cloud Spannerの世界ではそれは過去の遺物だ。
今日は、Cloud Spannerがどのようにして「無停止でのオンラインスキーマ更新」を実現しているのか、その内部アーキテクチャから、実務で絶対に踏んづけてはならない罠、そして堅牢な設計パターンまで、私の知見のすべてを叩き込む。
—
1. なぜSpannerはスキーマ変更で止まらないのか?(内部アーキテクチャの核心)
従来のRDBMSにおけるスキーマ変更がなぜ重いかといえば、それは「テーブルをロックし、インデックスやデータファイルを物理的に書き換えるから」に他ならない。
一方、Cloud Spannerのオンラインスキーマ更新は、単なる「SQLの構文」ではなく、分散システムにおける「メタデータの非同期伝播とマルチバージョン同時実行制御(MVCC)の極限の応用」だ。
スキーマも「データ」である
Spannerにおいて、スキーマ定義もシステム内部の特別なメタデータテーブルとして管理されている。
DDL(Data Definition Language)を発行するということは、このメタデータに新しい「スキーマの世代(Schema Generation)」を書き込む行為に過ぎない。
1. 非同期伝播: DDLを実行すると、その変更はPaxosグループを通じて世界中の各スプリット(データシャード)へと非同期に伝播する。
2. MVCCとの融合: データ自体は物理的に一斉書き換えされるわけではない。古いスキーマ世代で書き込まれたデータと、新しいスキーマ世代で書き込まれたデータが、MVCCの仕組みによって破綻なく共存する。
3. バックグラウンド処理: 例えばインデックスの追加や、NOT NULL制約の付与といった重い処理は、リーダーレプリカの指揮の下、バックグラウンドで静かに、かつリソースをスロットリングされながら実行される。
これが、数千万QPSを処理するトラフィックの真っ最中でも、レイテンシをミリ秒単位で維持したままスキーマが進化し続ける理由だ。
—
2. 実践:非同期処理のライフサイクルと進捗確認
SpannerのDDLは非同期で動く。つまり、`ALTER TABLE` のコマンドが `OK` を返した瞬間は、「スキーマ変更のリクエストが受け付けられ、伝播が開始された」ことを意味し、「全ノードへの適用が完了した」わけではない。ここを勘違いすると痛い目を見る。
DDL実行と進捗確認のプラクティス(gcloud / API)
実務では、大規模なインデックス作成などのDDLを発行した後、その進捗をプログラムやオペレーションスクリプトで監視する必要がある。
例: 巨大なテーブルに対するセカンダリーインデックスの追加
gcloud spanner databases ddl update my-database \
–instance=my-instance \
–statements=”CREATE INDEX UsersByEmail ON Users(Email)”
このコマンドは比較的すぐに応答を返す。では、裏で走っている非同期処理の進捗はどう確認するか?
Google Cloud Consoleを見るのもいいが、プロフェッショナルならAPIやクライアントライブラリ経由でオペレーションのステータスをポーリングする設計を組み込むべきだ。
from google.cloud import spanner_admin_database_v1
def check_ddl_progress(project_id, instance_id, database_id):
client = spanner_admin_database_v1.DatabaseAdminClient()
database_path = client.database_path(project_id, instance_id, database_id)
# 実行中のオペレーション一覧を取得
operations = client.list_database_operations(parent=database_path)
for op in operations:
print(f”Operation: {op.name}”)
print(f”Metadata: {op.metadata}”)
# 進捗率(progress_percent)がメタデータに含まれる
if hasattr(op.metadata, ‘progress_percent’):
print(f”Progress: {op.metadata.progress_percent}%”)
プロダクション環境のデプロイパイプラインに組み込むスニペット
この非同期性こそが、CI/CDパイプラインにDBマイグレーションを組み込む際の最大の武器となる。
—
3. 実務で絶対に避けるべき「制限事項」とアンチパターン
「止まらない」からといって、何をしてもいいわけではない。Cloud Spannerの物理的な限界と分散トランザクションの特性を無視したDDLは、システムを静かに死に至らしめる。
アンチパターン1: 短期間での過度なDDLの連続実行
Spannerのスキーマ変更は、グローバルなコンセンサスを伴う。数秒おきに何十もの `ALTER TABLE` を連続して発行すると、メタデータのバージョン管理が破綻するか、内部のオペレーションキューが溢れ、DDL自体がブロックされるようになる。
対策: マイグレーションスクリプトは、複数の変更を1つのトランザクション(あるいはバッチ)にまとめるか、少なくとも前のDDLの完了を確認してから次のDDLを実行せよ。
アンチパターン2: 巨大テーブルへの `NOT NULL` 制約の安易な追加
既存データが数億行あるテーブルに対して、デフォルト値なしで `NOT NULL` 制約を追加しようとするとどうなるか?
Spannerはバックグラウンドで全行スキャンし、NULLが存在しないか検証しなければならない。もしNULLが存在すればDDLはエラーになるし、存在しなくても検証コストはバカにならない。
対策:
1. アプリケーション側で新カラムへの書き込みを必須化する。
2. 一定期間(すべての古いデータが上書きされるか、バッチでNULLが埋まるまで)待つ。
3. その後に `NOT NULL` 制約を追加する。
この「段階的マイグレーション(Expand and Contractパターン)」が、大規模SaaSの鉄則だ。
—
4. 堅牢な設計パターン:ゼロダウンタイム・マイグレーションの全貌
最後に、我々のチームが大規模プロダクトの本番環境で実践している、データベースのスキーマ変更を含むデプロイの標準フロー(設計パターン)を授けよう。
[Phase 1: 拡張 (Expand)]
- 新しいカラムやテーブルを「NULL許容」で追加する (DDL)
- アプリは新カラムに書き込みつつ、旧カラムも読み書き可能にしておく (App V1.1)
[Phase 2: データ移行 (Migrate)]
- 必要に応じてバックグラウンドワーカーで過去データのバックfillを実行する
- すべてのレコードが新しいスキーマの形を満たすようにする
[Phase 3: 切り替え (Switch)]
- アプリの読み込み先を新カラム/新テーブルに完全移行する (App V1.2)
[Phase 4: 縮小 (Contract)]
- 古いカラムや不要になったインデックスを削除する DDL を発行する (DDL)
この4段階を踏むことで、どれほど巨大なシステムであっても、エンドユーザーに1ミリ秒のダウンタイムも感じさせることなく、データベースの構造を美しく保ち続けることができる。
—
最後に:チーフアーキテクトからのメッセージ
Cloud Spannerのオンラインスキーマ更新は、魔法の杖ではない。しかし、その背後にある分散システムの思想を正しく理解し、適切な手順(Expand & Contract)を踏みさえすれば、インフラストラクチャの制約から開発チームを完全に解放してくれる最強の武器となる。
「DBの変更=夜間・休日の大掛かりなメンテナンス」という昭和の呪縛は、今日この瞬間に頭から捨て去ってほしい。
君たちが設計するシステムは、止まることなく、世界中のトラフィックを受け止めながら、永遠に進化し続けなければならないのだから。
コメント