1. 現場の病理:「ステージングで0.1秒だった」という甘い罠
金曜日の夕方、あるいは平日のリリース作業中、多くの開発チームで次のような会話が交わされています。
「ユーザーテーブルに通知設定のフラグを追加したいので、マイグレーション流しますね。
ALTER TABLE users ADD COLUMN is_subscribed BOOLEAN NOT NULL DEFAULT TRUE;
ステージング環境でも0.1秒で即座に完了したし、MySQLの公式ドキュメントにもADD COLUMNはALGORITHM=INPLACEで行コピーなし(Instant / Inplace)って書いてあるので、無停止で大丈夫です!」
そしてマイグレーションスクリプトが本番DBに向けて実行された数秒後、オフィスのSlackやDiscordにDatadog / CloudWatchからの高優先度アラートが鳴り響きます。
- 「DBのコネクション数が急激に上限値(max_connections)に到達しました!」
- 「APIサーバーの全スレッドがDB待ちでスタックしています!」
- 「ALB(ロードバランサー)から504 Gateway Timeoutが大量発生!」
- 「ヘルスチェックがタイムアウトし、ターゲットグループからインスタンスが次々に切り離されています!」
慌ててターミナルを開き SHOW FULL PROCESSLIST; を叩くと、そこには背筋の凍る光景が広がっています。数千件の単純な主キー引きSELECTクエリ(SELECT * FROM users WHERE id = ?)がすべて 「Waiting for table metadata lock」 というステータスで延々と停止し、DB全体が完全に呼吸を止めているのです。
「たかが1つのカラムを追加しただけなのに、なぜ無関係なSELECTクエリまで巻き込まれて全滅したのか?」——この悲劇の原因を正しく理解していないエンジニアは、次回から「ALTER TABLEを流すときは必ず深夜2時にメンテナンス画面を出してサービスを全停止しよう」という消極的かつ開発速度を激殺する運用へと退行していきます。
2. メタデータロック(MDL)の真実:なぜ無関係なクエリまで窒息するのか?
MySQL(5.5.3以降)やPostgreSQLには、トランザクションの実行中に別セッションからテーブル定義(スキーマ)が勝手に書き換えられてデータの一貫性が崩れるのを防ぐため、メタデータロック(Metadata Lock: MDL) という保護機構が備わっています。
問題は、このMDLが取得・解放されるルールと、ロック待ち行列(キュー)の優先順位にあります。
Shared MDL と Exclusive MDL
通常のクエリ(SELECT, INSERT, UPDATE, DELETE)を実行すると、データベースはそのテーブルに対して Shared MDL(共有メタデータロック) を取得します。共有ロック同士は互いにブロックしないため、1万人のユーザーが同時にSELECTを発行しても何の問題も起きません。
一方、ALTER TABLE や CREATE INDEX などのDDL(データ定義言語)を実行する際、データベースはテーブル定義を確定させるために一瞬であっても Exclusive MDL(排他メタデータロック) を要求します。排他ロックは、他のあらゆるMDL(Sharedを含む)と両立できません。
本番即死のタイムライン:死のキューイング現象
秒間数百リクエストが流れる本番環境で何が起きるのか、時系列で追ってみましょう。
[時刻 T1] セッションA: 重い集計SELECTが実行開始(Shared MDL取得中)
SELECT COUNT(*) FROM orders JOIN users ... (実行に5秒かかる)
[時刻 T2] セッションB: マイグレーション実行!
ALTER TABLE users ADD COLUMN is_subscribed ...
→ Exclusive MDL を要求するが、セッションAが完了するまで待機(Lock Wait)
[時刻 T3] セッションC, D, E...: 通常のWebトラフィック(1msで終わるはずの単純SELECT)
SELECT * FROM users WHERE id = 123;
→ 【地雷発動!】セッションB(Exclusive)が待機列の先頭にいるため、
後続のShared MDL要求もすべてセッションBの後ろにキューイングされ強制ブロック!
[時刻 T4] 数秒後:
後続の全Webリクエストがコネクションを掴んだままブロック。
DBの最大コネクション数(max_connections)があっという間に枯渇し、システム全停止。
ここでの決定的な落とし穴は、「MySQLのMDLキューでは、排他ロック(Exclusive)要求が共有ロック(Shared)要求よりも優先される」という点です。
もし待機中のALTER TABLEの横をすり抜けて後続のSELECTを次々に通してしまうと、トラフィックが絶えない本番環境ではALTER TABLEが永遠にロックを取得できず飢餓状態(Starvation)に陥ってしまいます。そのためRDBMSは「後続の全クエリをストップさせてALTER TABLEを優先させる」仕様になっているのです。
つまり、「たった1つの遅いSELECT」あるいは「トランザクションを開いたまま外部APIを叩いて数秒待っている処理」が存在するテーブルに ALTER TABLE を投げた瞬間、後続の全クエリが人質に取られてDBが即座に窒息死します。
3. デプロイ順序の地雷原:アプリ先行か? DB先行か?
MDLの恐怖を何とか潜り抜けたとしても、次に待ち受けているのが「アプリケーションのローリングアップデートとDBスキーマの依存関係」による本番エラーです。
現代のクラウド環境(ECS, Kubernetes, Cloud Runなど)では、ゼロダウンタイムデプロイのために新旧バージョンのコンテナが一時的に並行稼働します。このとき、「カラムの削除」や「カラム名のリネーム」を単純に実行すると、どちらの順序でデプロイしても必ず破綻します。
ケース1:カラム削除でDBマイグレーションを先に流した場合
不要になった users.legacy_token カラムを削除したいとします。
- DBマイグレーションを実行:
ALTER TABLE users DROP COLUMN legacy_token; - 新バージョンのコンテナをデプロイ開始。
- 💥 地雷爆発:まだ稼働中の旧バージョンコンテナが
SELECT id, name, legacy_token FROM usersを発行(あるいはORMが全カラムを展開してSELECT)。MySQLがUnknown column 'legacy_token'エラーを返し、デプロイ完了までの数分間、大量のユーザーに500エラーが返り続ける。
ケース2:カラム削除でアプリデプロイを先に流した場合
「ならアプリを先にデプロイして、参照を消してからマイグレーションを流せばいいのでは?」と考えます。
- 新バージョン(
legacy_tokenを参照しないコード)をデプロイ完了。 - DBマイグレーションを実行:
ALTER TABLE users DROP COLUMN legacy_token; - 💥 地雷爆発:新バージョンに致命的なバグが発覚し、以前のバージョンへ緊急ロールバックを実施。しかしDB上にはすでに
legacy_tokenカラムが存在しないため、ロールバックした旧アプリが即死。元に戻せない「不可逆的な障害」に陥る。
「1回のデプロイでスキーマとコードを同時に変えようとする行為」は、時速100kmで走行中の車のタイヤを4本同時に外して新品に付け替えようとするような暴挙です。
4. ゼロダウンタイムの鉄則:「Expand / Contract パターン」
この二重の地雷を回避し、サービスを1秒も止めずにスキーマを変更するための業界標準のデザインパターンが、Expand / Contract(拡張・縮小)パターン(別名:Parallel Run / Parallel Change)です。
思想は極めてシンプルです。「すべてのスキーマ変更は常に後方互換性(Backward Compatibility)を維持し、段階的に移行する」。決して1回のリリースで完結させてはなりません。
例として、最も事故が起きやすい「カラム名のリネーム(phone → phone_number)」をゼロダウンタイムで行う4つのフェーズを見ていきましょう。
| フェーズ | DBスキーマの状態 | アプリの書き込み(Write) | アプリの読み取り(Read) | ロールバック可能性 |
|---|---|---|---|---|
| Phase 1: Expand | 新旧両方のカラムが存在(新カラムはNULL許容) | 旧カラムのみ | 旧カラムのみ | ◎ 即時ロールバック可能 |
| Phase 2: Dual Write & Backfill | 新旧両方のカラムが存在 | 新旧両方に二重書き込み | 旧カラム(フォールバック) | ◎ 即時ロールバック可能 |
| Phase 3: Switch Reads | 新旧両方のカラムが存在(データ完全同期) | 新旧両方に二重書き込み | 新カラムから読み取り | ◎ アプリ切り戻しのみで安全 |
| Phase 4: Contract | 旧カラムを安全に削除(または非推奨化) | 新カラムのみ | 新カラムのみ | ◯ 移行完了 |
Phase 1: Expand(拡張フェーズ)
まずはDBに新しいカラムを追加します。このときの絶対原則は、「必ずNULL許容にするか、安全なデフォルト値を設定すること」です。既存の旧アプリは新カラムの存在を知らないため、INSERT文に新カラムを含めません。NOT NULL制約(デフォルト値なし)を付けてしまうと、旧アプリの書き込みが即死します。
-- Phase 1: 新カラムをNULL許容で追加(旧アプリには一切影響を与えない)
ALTER TABLE users ADD COLUMN phone_number VARCHAR(32) NULL;
Phase 2: Dual Write(二重書き込み)と Backfill(データ補完)
次に、アプリケーションの新バージョンをデプロイします。このバージョンの責務は、「新規・更新データを新旧両方のカラムに書き込む(Dual Write)」ことです。
// Phase 2 アプリケーションコード(Goでの二重書き込みの実装例)
func (r *UserRepository) UpdatePhone(ctx context.Context, userID int64, phone string) error {
query := `
UPDATE users
SET phone = ?, -- 旧カラムへの書き込み(互換性維持)
phone_number = ? -- 新カラムへの書き込み(Dual Write)
WHERE id = ?
`
_, err := r.db.ExecContext(ctx, query, phone, phone, userID)
return err
}
二重書き込みが稼働したら、過去の既存データを旧カラムから新カラムへとコピーする「Backfill(データ補完スクリプト)」を実行します。
ここで初心者がやりがちな最悪のミスが、UPDATE users SET phone_number = phone WHERE phone_number IS NULL; という全件一括UPDATEです。数百万行のテーブルでこれを流すと、巨大なトランザクションログ(Undo Log)が爆発し、行ロックがテーブル全体に波及してDBが即死します。
Backfillは必ず「主キーによるキーセット(シーク法)で小分け(チャンク分割)にして、Sleepを挟みながら実行」しなければなりません。
-- チャンク分割による安全なBackfill(1000件ずつ実行)
UPDATE users
SET phone_number = phone
WHERE id > ? AND id <= ? AND phone_number IS NULL
LIMIT 1000;
Phase 3: Switch Reads(読み取りの切り替え)
Backfillが完了し、新旧カラムのデータが100%一致したことを確認したら、アプリケーションの読み取り先を新カラム(phone_number)に切り替えるバージョンをデプロイします。書き込みはまだ念のため両方に行い続けます。
もし新カラムのフォーマットや型変換に予期せぬバグが見つかっても、旧カラムにデータが書き込まれ続けているため、いつでも前バージョンのアプリにノーリスクでロールバックできます。
Phase 4: Contract(収縮フェーズ)
新カラムでの稼働が数日間安定し、旧カラムへの書き込み停止デプロイも無事に通過したら、最後に不要になった旧カラムを削除します。
-- Phase 4: すべてのアプリ参照が消えたことを確認してから旧カラムを削除
ALTER TABLE users DROP COLUMN phone;
5. 本番DDLを安全に執行するための防衛策
Expand / Contractパターンによってアプリとの不整合を防ぐ手順が確立できても、ALTER TABLE そのものがMDL待ちでシステムを巻き添えにするリスクは依然として残ります。本番環境でDDLを実行する際は、以下の防衛策を必ずセットで適用してください。
1. lock_wait_timeout の極小化(Fail-Fastの徹底)
MySQLのデフォルト設定では、MDLのロック待ちタイムアウト(lock_wait_timeout)は 31,536,000秒(なんと1年!) に設定されています。先行クエリがいると、ALTER TABLEは1年間諦めずに後続クエリをブロックし続けます。
マイグレーションを実行するセッションでは、必ず開始直前にタイムアウトを極小(1〜3秒)に設定してください。
-- マイグレーション実行スクリプトの冒頭で必ず設定する
SET SESSION lock_wait_timeout = 2;
-- もし2秒以内に排他MDLが取れなければ、後続を巻き込まずにALTER側が即座にエラーで自決する
ALTER TABLE users ADD COLUMN phone_number VARCHAR(32) NULL;
もし先行する重いクエリに阻まれた場合でも、わずか2秒でALTER TABLEが自ら失敗(Fail-Fast)してくれるため、後続のWebトラフィックが詰まってサービス全体が落ちる大惨事を確実に防ぐことができます。
2. オンラインスキーマ変更ツール(gh-ost / pt-online-schema-change)の活用
数千万〜数億行を超える大規模テーブルの場合、MySQL公式のOnline DDLであってもI/O負荷やバッファプールの圧迫によりレプリケーション遅延(Replication Lag)が発生し、参照リードレプリカが死ぬことがあります。
このような環境では、GitHub社が開発したオープンソースツール gh-ost(GitHub's Online Schema Transmogrifier) を採用するのがデファクトスタンダードです。
- トリガーを使わない安全性: 従来の
pt-online-schema-changeはDBトリガーを使用して新旧テーブルを同期していたため、書き込みピーク時にトリガーのオーバーヘッドでマスターDBが過負荷になる弱点がありました。 - Binlogストリーミング:
gh-ostはレプリカとして振る舞い、MySQLのバイナリログ(Binlog)を非同期に読み取ってゴーストテーブルに変更を適用します。 - 動的なスロットリング: レプリケーション遅延やCPU負荷をミリ秒単位で監視し、閾値を超えると自動的にマイグレーション速度を緩めたり一時停止したりするため、本番トラフィックに絶対に悪影響を与えません。
6. まとめ:スキーママイグレーションは「一発のSQL」ではなく「運用のプロセス」である
「たかがカラムを1個足すだけ、リネームするだけ」——その油断が、これまで世界中の名だたるテック企業で無数のサービスダウンを引き起こしてきました。
現代のウェブ開発において、ダウンタイムなしで迅速に機能をリリースし続けるために肝に銘じるべき鉄則は以下の3点です。
- MDLの死のキューイングを忘れるな: 先行クエリがいる状態で排他ロックを要求すると、後続のSELECTを含めた全クエリが巻き添えで死ぬ。
lock_wait_timeoutを極小にして自決させること。 - デプロイ順序で悩むな、Expand / Contract を使え: アプリとDBの変更を1回で同時にやろうとするから事故になる。常に後方互換性を保ち、拡張(追加)→ 二重書き込み・データ補完 → 読み取り切り替え → 縮小(削除)のステップを踏むこと。
- 深夜メンテナンスに逃げるな: 「メンテ画面を入れてサービスを止める」のは技術的敗北の対症療法に過ぎない。適切なプロセスとツール(gh-ost等)を導入すれば、平日真昼間のピークタイムであっても完全に無停止でスキーマを進化させることができる。
データベースのスキーママイグレーションは、単なるDDLの実行ではありません。それはアプリケーション、インフラ、レプリケーション、そしてユーザーのトラフィックを協調させる「高度な分散オーケストレーション」なのです。