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からの高優先度アラートが鳴り響きます。

慌ててターミナルを開き 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 カラムを削除したいとします。

  1. DBマイグレーションを実行:ALTER TABLE users DROP COLUMN legacy_token;
  2. 新バージョンのコンテナをデプロイ開始。
  3. 💥 地雷爆発:まだ稼働中の旧バージョンコンテナが SELECT id, name, legacy_token FROM users を発行(あるいはORMが全カラムを展開してSELECT)。MySQLが Unknown column 'legacy_token' エラーを返し、デプロイ完了までの数分間、大量のユーザーに500エラーが返り続ける。

ケース2:カラム削除でアプリデプロイを先に流した場合

「ならアプリを先にデプロイして、参照を消してからマイグレーションを流せばいいのでは?」と考えます。

  1. 新バージョン(legacy_token を参照しないコード)をデプロイ完了。
  2. DBマイグレーションを実行:ALTER TABLE users DROP COLUMN legacy_token;
  3. 💥 地雷爆発:新バージョンに致命的なバグが発覚し、以前のバージョンへ緊急ロールバックを実施。しかし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) を採用するのがデファクトスタンダードです。

6. まとめ:スキーママイグレーションは「一発のSQL」ではなく「運用のプロセス」である

「たかがカラムを1個足すだけ、リネームするだけ」——その油断が、これまで世界中の名だたるテック企業で無数のサービスダウンを引き起こしてきました。

現代のウェブ開発において、ダウンタイムなしで迅速に機能をリリースし続けるために肝に銘じるべき鉄則は以下の3点です。

  1. MDLの死のキューイングを忘れるな: 先行クエリがいる状態で排他ロックを要求すると、後続のSELECTを含めた全クエリが巻き添えで死ぬ。lock_wait_timeout を極小にして自決させること。
  2. デプロイ順序で悩むな、Expand / Contract を使え: アプリとDBの変更を1回で同時にやろうとするから事故になる。常に後方互換性を保ち、拡張(追加)→ 二重書き込み・データ補完 → 読み取り切り替え → 縮小(削除)のステップを踏むこと。
  3. 深夜メンテナンスに逃げるな: 「メンテ画面を入れてサービスを止める」のは技術的敗北の対症療法に過ぎない。適切なプロセスとツール(gh-ost等)を導入すれば、平日真昼間のピークタイムであっても完全に無停止でスキーマを進化させることができる。

データベースのスキーママイグレーションは、単なるDDLの実行ではありません。それはアプリケーション、インフラ、レプリケーション、そしてユーザーのトラフィックを協調させる「高度な分散オーケストレーション」なのです。