ほんの小さなスキーマ変更でも本番データベースが壊れる理由
あなたは本番環境で動作するアプリケーションを運用している。毎分数千人のユーザーにサービスを提供している。ある朝、データベーステーブルにたった1つのカラムを追加することに決めた。たった1つのカラムだ。その変更は紙の上では無害に見える。しかしマイグレーションが始まって数分後、ユーザーはエラーを目にし始める。リクエストがタイムアウトする。新規登録が失敗する。チームは慌ててロールバックを試みる。
このシナリオは、ほとんどのエンジニアが予想するよりも頻繁に発生する。開発者のノートパソコンでは些細に見えるスキーマ変更が、本番システムをダウンさせることがある。なぜこれが起こるのかを理解することは、アプリケーションコードと一緒にデータベース変更をデプロイするすべての人にとって不可欠である。
コードとスキーマの本質的な違い
アプリケーションコードを変更する場合、その影響は比較的限定される。新しいバージョンが古いバージョンと置き換わる。何か問題が発生した場合、以前のバージョンをデプロイして正常な状態に戻せる。リスクは確かにあるが、復旧手順は明確である。
データベーススキーマの変更はそうはいかない。テーブルの構造を変更するということは、実行中のすべてのアプリケーションインスタンスが依存する基盤そのものを修正することになる。古いスキーマと新しいスキーマの間で「きれいな切り替え」は存在しない。マイグレーションが完了した瞬間、古いスキーマは消え去る。何かが壊れた場合、スキーマ変更をロールバックすることは、元の変更自体よりも複雑でリスクが高くなることが多い。
この非対称性こそが、一見些細なデータベース変更に起因する多くの本番インシデントの根本原因である。
小さなカラム追加が大きな問題を引き起こす
具体的な例を考えてみよう。email カラムが varchar(255) で定義された users テーブルがある。この制限を varchar(500) に拡大することにした。たった1つのカラムの型変更だ。どれほど悪影響があるだろうか?
マイグレーション中、データベースはカラムを再構成するためにテーブルをロックする必要があるかもしれない。そのロックが保持されている間、どのアプリケーションも users テーブルからの読み取りや書き込みができない。アプリケーションが毎秒数百のリクエストを処理している場合、数秒のテーブルロックでもタイムアウトとリクエスト失敗の連鎖を引き起こす可能性がある。ユーザーはエラーを経験する。監視アラートが発報する。チームはパニックに陥る。
次に、同じテーブルに新しいカラム phone_number を追加する場合を考えよう。マイグレーションは NOT NULL 制約とデフォルト値なしでカラムを追加する。古いコードを実行しているアプリケーションインスタンスは、このカラムの存在を知らない。新しいカラムを省略した INSERT 文を実行すると、データベースはクエリを拒否する。突然、古いコードを実行しているすべてのインスタンスで新規ユーザー登録が機能しなくなる。変更は1つのカラムを追加しただけだった。影響は完全な登録障害だった。
以下は、上記の障害を引き起こすSQLである:
-- 危険:usersテーブル全体をロックし、すべての読み取りと書き込みをブロックする
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20) NOT NULL;
-- より安全な代替案:最初にNOT NULLなしでカラムを追加し、
-- その後バックフィルし、ロックタイムアウトを設定して制約を追加する
ALTER TABLE users ADD COLUMN phone_number VARCHAR(20);
-- バッチでバックフィル(アプリケーションコードが欠損値を処理)
UPDATE users SET phone_number = 'unknown' WHERE phone_number IS NULL;
-- 無期限のブロッキングを避けるためにロックタイムアウトを設定してNOT NULLを追加
SET lock_timeout = '5s';
ALTER TABLE users ALTER COLUMN phone_number SET NOT NULL;
最初のステートメントは、操作の全期間にわたってテーブルをロックする。大規模なテーブルでは、これに数分かかる可能性があり、すべてのアプリケーションインスタンスで連鎖的なタイムアウトを引き起こす。
クエリを静かに壊す型変更
一部のスキーマ変更は安全に見えるが、微妙な方法でクエリの動作を変える。主キーカラムを INT から BIGINT に変更するのはよくある例だ。アプリケーションが整数の限界に近づいているため、この変更は必要である。しかし変換処理中、そのカラムのインデックスに依存するクエリが遅くなったり、インデックスをまったく使用しなくなったりする可能性がある。データベースはテーブル全体とそのすべてのインデックスを書き換える必要があるかもしれない。大規模なテーブルの場合、これには数分から数時間かかる可能性がある。
変換が完了した後でも、アプリケーションコードにはデータ型に関する前提があるかもしれない。IDを表示用にフォーマットしたり、外部APIに渡したり、算術演算で使用したりするコードが、静かに壊れる可能性がある。スキーマ変更自体は正しかったが、アプリケーションコードに組み込まれた前提が正しくなかったのだ。
未使用カラムの削除もリスクが高い
メインアプリケーションで使われていないように見えるカラムを削除することは、安全なクリーンアップのように思える。しかし、データベースに単一のコンシューマしかいないことはほとんどない。毎晩実行されるバッチジョブがレポート作成のためにそのカラムを読み取っているかもしれない。誰も覚えていないレガシーサービスがそれをクエリしているかもしれない。データサイエンスチームが分析のためにそれを取得するスクリプトを持っているかもしれない。
カラムを削除した瞬間、これらすべてのコンシューマが壊れる。夜間レポートは失敗する。レガシーサービスはエラーを吐き始める。データサイエンスパイプラインは結果を生成しなくなる。クリーンアップ作業に見えたものが、複数チームにまたがるインシデントに変わった。
なぜスキーマ変更が破壊的変更になるのか
アプリケーションコードでは、破壊的変更は通常明白である:関数を削除する、メソッドシグネチャを変更する、APIレスポンス形式を変更する。データベースでは、データベースが多くの目に見えないコンシューマを持つ共有リソースであるため、破壊的変更を検出するのが難しい。
1つのデータベーステーブルには、以下のようなものがアクセスする可能性がある:
- メインアプリケーション
- バックグラウンドジョブプロセッサ
- レポートツール
- データ分析パイプライン
- レガシーサービス
- 運用チームからのアドホッククエリ
- サードパーティ統合
各コンシューマはスキーマについて独自の前提を持っている。メインアプリケーションにとって安全な変更が、月に1回実行されるレポートスクリプトを壊すかもしれない。そのスクリプトは実行頻度が低いため、障害が数週間気づかれない可能性がある。
核心的な原則
本当に小さなスキーマ変更など存在しない。データベース構造へのすべての変更は、計画、テスト、慎重な実行を必要とする協調操作である。マイグレーションコードの行数で測った変更のサイズは、潜在的な影響の大きさとは相関しない。
スキーマ変更前の実践的チェックリスト
本番環境でマイグレーションを実行する前に、以下の点を確認すること:
- このテーブルにアクセスするすべてのアプリケーション、サービス、スクリプトを把握しているか?
- 書き込みのためにテーブルをロックせずにマイグレーションを実行できるか?
- 変更によって既存のクエリやアプリケーションの前提が壊れないか?
- 古いアプリケーションコードと新しいアプリケーションコードが新しいスキーマと共存できるか?
- データ損失を伴わない、テスト済みのロールバック計画があるか?
- マイグレーションと競合する可能性のある長時間実行トランザクションを確認したか?
- マイグレーション直後にクエリエラーを表示する監視ダッシュボードはあるか?
これがデプロイプロセスに意味すること
データベーススキーマの変更には、アプリケーションコードの変更とは異なるデプロイ戦略が必要である。それらは、可能な限り可逆的で、後方互換性があり、現実的なデータ量に対してテストされている必要がある。また、データベースに依存するすべてのチームと調整する必要がある。
すべてのスキーマ変更を、どれほど小さく見えても、高リスクの操作として扱うこと。今日追加するカラムが、明日の障害を引き起こすかもしれない。変更する型が、来週のレポートを壊すかもしれない。削除するテーブルが、同僚のスクリプトが依存しているものかもしれない。
データベースのデプロイを、重要なインフラストラクチャ変更と同じ注意を払って計画すること。なぜなら、まさにそれがデータベースデプロイだからである。