スキーマ変更運用(マイグレーション)

DB設計は作って終わりではありません。本番運用では「安全に変える技術」が必須です。このページでは、スキーマ変更を安全に本番適用するための原則・パターン・ツール・チェックリストを体系的に解説します。

1. なぜ運用設計が必要か

開発環境なら DROP TABLE して作り直せます。しかし本番環境には既存データ・稼働中のアプリ・他チームの依存クエリがあります。「変更を入れる」こと自体がリスクを伴う行為です。

本番変更で起こりうる事故の具体例

操作起こりうる事故影響
列削除旧バージョンのアプリがその列を参照し SELECT が全件エラーサービス全停止
列型変更 (VARCHAR→INT)既存データに数値変換できない文字列が含まれ ALTER 失敗DDL がロールバックされ長時間ロック発生
NOT NULL 制約追加既存行に NULL が存在し ALTER 失敗。または成功しても INSERT が止まるデータ投入バッチ停止
インデックス追加1億行テーブルで数十分のロック発生書き込み全ブロック、タイムアウト多発
テーブル名変更バッチ・BI ツール・他サービスの参照先が全て壊れる複数システムに波及

本番 DDL は「コードデプロイ」と同等以上のリスクを持ちます。「ALTER 1行だから簡単」という認識は危険です。データ量・依存関係・ロック影響を必ず事前評価してください。

運用設計が必要な3つの理由

  1. データがある状態で DDL を実行する -- 空テーブルなら一瞬の操作でも、数億行あれば数時間かかることがある
  2. アプリとDBのバージョン差分が一時的に発生する -- デプロイはローリングで行われるため、新旧アプリが同時にDBにアクセスする時間帯が必ずある
  3. 障害時のロールバック方針が必要 -- 「戻せない変更」を入れてしまうと、障害時の選択肢がゼロになる

2. 安全な変更の原則

原則 1: 破壊的変更をいきなり入れない

「破壊的変更」とは、既存のアプリ・クエリ・バッチが動かなくなる変更です。以下に代表的な分類を示します。

安全(加算的変更)危険(破壊的変更)
NULL 許容の列追加列の削除
新テーブルの追加列名・テーブル名の変更
インデックスの追加(オンラインDDL対応時)列の型変更(縮小方向)
デフォルト値の設定NOT NULL 制約の追加(既存 NULL 行あり)
CHECK 制約の追加(既存データ適合時)外部キーの追加(不整合データあり時)

判断基準: その変更を適用した瞬間、旧バージョンのアプリケーションがエラーなく動き続けるか? 答えが No なら破壊的変更です。

原則 2: 後方互換期間を設ける

列名の変更や列の削除は、一発で行うのではなく「互換期間」を挟みます。互換期間の長さはデプロイ頻度に依存しますが、最低でも1リリースサイクル以上を確保します。

-- 悪い例: 一発で列名変更
ALTER TABLE customers RENAME COLUMN name TO full_name;
-- → 旧アプリが SELECT name FROM customers でエラー

-- 良い例: 段階的に移行(後述の Expand/Contract)
-- Step 1: 新列追加
ALTER TABLE customers ADD COLUMN full_name VARCHAR(100);
-- Step 2: データコピー + アプリ移行(互換期間)
-- Step 3: 旧列削除(全アプリが新列を参照していることを確認後)

原則 3: DDL とデータ移行を分離する

1つのマイグレーションスクリプトに「テーブル構造変更」と「大量データ更新」を混ぜると、トランザクションが肥大化し、ロック時間が増大します。

-- 悪い例: 1トランザクションに全部入れる
BEGIN;
  ALTER TABLE orders ADD COLUMN tax_rate DECIMAL(5,2);
  UPDATE orders SET tax_rate = 0.10;            -- 1億行更新
  ALTER TABLE orders ALTER COLUMN tax_rate SET NOT NULL;
COMMIT;
-- → 長時間のテーブルロック + トランザクションログ肥大

-- 良い例: 3段階に分離
-- Migration 001: ALTER TABLE orders ADD COLUMN tax_rate DECIMAL(5,2);
-- Backfill script: バッチで分割 UPDATE(1万行ずつ COMMIT)
-- Migration 002: ALTER TABLE orders ALTER COLUMN tax_rate SET NOT NULL;

原則 4: 必ず本番相当データ量で検証する

開発環境の100行テーブルでは一瞬で終わる ALTER が、本番の1億行テーブルでは30分かかることがあります。以下を事前に確認してください。

3. Expand/Contract パターン詳解

スキーマ変更を安全に行うための最も基本的なパターンが Expand/Contract(拡張/収縮)です。破壊的変更を「加算的変更の積み重ね」に分解します。

全体像:3つのフェーズ

時間軸 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→

Phase 1: Expand(拡張)
├─ 新しい列/テーブルを追加(既存構造には触れない)
├─ トリガーまたはアプリで新旧両方に書き込み(Dual Write)
└─ 既存データを新構造へバックフィル

Phase 2: Migrate(移行)
├─ アプリの読み取りを新構造へ切り替え
├─ 新構造のみに書き込むよう変更
└─ 旧構造への書き込みを停止

Phase 3: Contract(収縮)
├─ 旧列/テーブルへの参照がゼロであることを確認
├─ 旧構造を削除
└─ 不要なトリガー・ビューを削除

━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
 DB状態:  [旧のみ] → [旧+新 共存] → [新のみ]
 アプリ:  [旧参照] → [新旧両対応] → [新参照]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

各フェーズの詳細

Phase 1: Expand(拡張)

-- 例: 氏名列 → 姓・名に分割
-- Phase 1-1: 新列追加
ALTER TABLE customers ADD COLUMN last_name VARCHAR(50);
ALTER TABLE customers ADD COLUMN first_name VARCHAR(50);

-- Phase 1-2: バックフィル(1万行ずつ)
UPDATE customers
   SET last_name  = SPLIT_PART(full_name, ' ', 1),
       first_name = SPLIT_PART(full_name, ' ', 2)
 WHERE last_name IS NULL
 LIMIT 10000;

Phase 2: Migrate(移行)

Dual Write 期間の注意: 旧列と新列のデータ整合性を定期的に検証するクエリを用意し、乖離を早期検知できるようにしてください。

Phase 3: Contract(収縮)

-- Phase 3: 旧列削除(全アプリが移行済みを確認後)
ALTER TABLE customers DROP COLUMN full_name;

Contract フェーズを省略して旧列を放置すると、「使われていない列」が蓄積し、将来の開発者を混乱させます。技術的負債として必ずチケット管理してください。

4. よくある変更シナリオ集

実務で頻出する6つのシナリオについて、それぞれ安全な手順を示します。

4-1. 列追加(NULL 許容)

最も安全な変更です。ほとんどの RDBMS で瞬時に完了します。

-- 手順: 1ステップで完了
ALTER TABLE orders ADD COLUMN memo TEXT;

-- 注意点:
-- ・デフォルト値付きの場合、RDBMS によっては全行書き換えが発生する
-- ・PostgreSQL 11+ / MySQL 8.0.12+ はメタデータ変更のみで瞬時完了

4-2. 列追加(NOT NULL + デフォルト値)

-- PostgreSQL 11+ : メタデータ変更のみで瞬時
ALTER TABLE orders ADD COLUMN priority INT NOT NULL DEFAULT 0;

-- MySQL 5.7 以前: 全行コピーが発生するため大規模テーブルは要注意
-- → 安全策: NULL 許容で追加 → バックフィル → NOT NULL に変更

4-3. 列削除

破壊的変更の代表。Expand/Contract の典型パターンです。

-- Step 1: アプリから該当列への参照を全て除去しデプロイ
-- Step 2: 1リリースサイクル以上経過を確認
-- Step 3: クエリログで該当列へのアクセスがないことを確認
-- Step 4: 列を削除
ALTER TABLE customers DROP COLUMN fax_number;

-- PostgreSQL: 即座にメタデータから消える(物理領域は VACUUM で回収)
-- MySQL (InnoDB): テーブルコピーが発生する(大規模テーブル注意)

4-4. 列名変更

直接の RENAME は危険です。Expand/Contract で段階的に行います。

-- 悪い例(一発変更)
ALTER TABLE products RENAME COLUMN price TO unit_price;

-- 安全な手順
-- Step 1: Expand
ALTER TABLE products ADD COLUMN unit_price DECIMAL(10,2);
UPDATE products SET unit_price = price WHERE unit_price IS NULL;
-- Step 2: アプリを unit_price に移行(Dual Write → 新列のみ)
-- Step 3: Contract
ALTER TABLE products DROP COLUMN price;

ビュー (VIEW) を活用すると、移行期間中に旧名でのアクセスを維持できます: CREATE VIEW v_products AS SELECT *, price AS unit_price FROM products;

4-5. 列の型変更

型変更は暗黙の全行スキャン・書き換えを伴う場合があり、最も注意が必要です。

-- 拡大方向(VARCHAR(50) → VARCHAR(100))
-- PostgreSQL: メタデータ変更のみで瞬時
ALTER TABLE customers ALTER COLUMN email TYPE VARCHAR(100);

-- 縮小方向や型変換(VARCHAR → INT)は危険
-- → Expand/Contract で新列を追加し段階移行

-- 安全な型変更手順
-- Step 1: 新列追加
ALTER TABLE products ADD COLUMN price_v2 BIGINT;
-- Step 2: バックフィル(変換不能行がないか事前検証)
SELECT id, price FROM products WHERE price !~ '^\d+$';  -- 不正データ確認
UPDATE products SET price_v2 = CAST(price AS BIGINT);
-- Step 3: アプリ移行
-- Step 4: 旧列削除

4-6. テーブル分割

1つのテーブルを正規化して複数に分割するケースです。

-- 変更前: orders(id, customer_name, customer_email, product, qty)
-- 変更後: customers(id, name, email) + orders(id, customer_id, product, qty)

-- Step 1: Expand - 新テーブル作成
CREATE TABLE customers (
  id         SERIAL PRIMARY KEY,
  name       VARCHAR(100) NOT NULL,
  email      VARCHAR(200)
);

-- Step 2: データ移行
INSERT INTO customers (name, email)
  SELECT DISTINCT customer_name, customer_email FROM orders;

-- Step 3: orders に外部キー列を追加
ALTER TABLE orders ADD COLUMN customer_id INT REFERENCES customers(id);

-- Step 4: バックフィル
UPDATE orders o
   SET customer_id = c.id
  FROM customers c
 WHERE o.customer_name = c.name;

-- Step 5: アプリを新構造に移行
-- Step 6: Contract - orders から旧列削除
ALTER TABLE orders DROP COLUMN customer_name;
ALTER TABLE orders DROP COLUMN customer_email;

4-7. テーブル統合

逆に、複数テーブルを1つに統合するケースです。非正規化によるパフォーマンス改善が目的の場合があります。

-- Step 1: 統合先テーブルに列を追加(NULL許容)
ALTER TABLE orders ADD COLUMN customer_name VARCHAR(100);
ALTER TABLE orders ADD COLUMN customer_email VARCHAR(200);

-- Step 2: バックフィル
UPDATE orders o
   SET customer_name  = c.name,
       customer_email = c.email
  FROM customers c
 WHERE o.customer_id = c.id;

-- Step 3: アプリを新構造に移行(JOIN なしで直接取得)
-- Step 4: 旧テーブルの参照がなくなったことを確認後に削除

5. マイグレーションツール

マイグレーションをSQLファイルの手作業実行で管理するとミスが起きます。専用ツールで「どの変更が適用済みか」をバージョン管理します。

代表的なツール比較

ツール言語/フレームワーク方式特徴
Flyway Java / JVM 全般 バージョン番号ベース SQL ファイルを V1__create_users.sql の命名規則で管理。シンプルで導入しやすい。Community 版は無料
Liquibase Java / JVM 全般 変更ログ(XML/YAML/JSON/SQL) 変更セット単位で管理。ロールバック SQL 自動生成。DB 間のポータビリティに強い
Rails Migrations Ruby on Rails タイムスタンプベース Ruby DSL で記述。up/down メソッドでロールバック定義。Rails 標準機能
Alembic Python (SQLAlchemy) リビジョンチェーン SQLAlchemy モデルとの差分自動検出 (--autogenerate)。ブランチ・マージ対応
Prisma Migrate TypeScript / Node.js スキーマ駆動 Prisma Schema から SQL を自動生成。Shadow Database で差分検出。型安全なクライアントと連携
golang-migrate Go 番号ベース(up/down) CLI とライブラリ両方で使える。Docker・CI 統合が容易
Django Migrations Python (Django) 依存グラフベース モデル変更から自動生成。複数アプリの依存関係を解決

バージョン管理との連携

マイグレーションファイルは必ず Git 等のバージョン管理に含めます。重要なルールは以下の通りです。

# Flyway の例: ディレクトリ構成
db/migration/
├── V001__create_customers.sql
├── V002__create_orders.sql
├── V003__add_email_to_customers.sql
└── V004__add_index_orders_customer_id.sql

# Alembic の例: マイグレーション作成
$ alembic revision --autogenerate -m "add email column to customers"
# → versions/xxxx_add_email_column_to_customers.py が生成される

# Prisma の例: マイグレーション作成
$ npx prisma migrate dev --name add_email_to_customers
# → prisma/migrations/xxxx_add_email_to_customers/ が生成される

チーム開発では、マイグレーションのコンフリクトに注意してください。同じテーブルに対する変更が2つのブランチで同時に作成された場合、マージ後に実行順序の問題が発生することがあります。CI で migrate --dry-run を実行して早期検出しましょう。

6. 大規模テーブルの変更

数千万〜数億行のテーブルに対する DDL は、標準の ALTER TABLE だけでは現実的でないことがあります。ロック時間を最小化するための手法を解説します。

6-1. オンライン DDL

MySQL 5.6+ (InnoDB) と PostgreSQL は、特定の操作でオンライン DDL をサポートしています。

操作MySQL (InnoDB)PostgreSQL
NULL 許容列の追加INPLACE(瞬時〜コピー)瞬時(メタデータのみ)
デフォルト値付き列追加テーブルコピー(8.0.12+ で改善)11+ で瞬時
インデックス追加ALGORITHM=INPLACE, LOCK=NONECREATE INDEX CONCURRENTLY
列の型変更多くの場合テーブルコピー拡大方向は瞬時、それ以外は全行書換
列の削除テーブルコピー(8.0+ INSTANT 一部対応)瞬時(VACUUM で領域回収)
-- MySQL: オンラインでインデックスを追加
ALTER TABLE orders ADD INDEX idx_created_at (created_at),
  ALGORITHM=INPLACE, LOCK=NONE;

-- PostgreSQL: CONCURRENTLY でロックを最小化
CREATE INDEX CONCURRENTLY idx_orders_created_at ON orders (created_at);
-- ※ トランザクション内では使用不可。失敗時は INVALID 状態になるので要確認

6-2. pt-online-schema-change (Percona Toolkit)

MySQL 向けのオンラインスキーマ変更ツール。内部的にはシャドウテーブルを作成し、トリガーで差分を同期します。

# 実行例: orders テーブルに memo 列を追加
pt-online-schema-change \
  --alter "ADD COLUMN memo TEXT" \
  --execute \
  --max-lag=1 \
  --chunk-size=1000 \
  D=mydb,t=orders

# 動作の流れ:
# 1. _orders_new テーブルを新構造で作成
# 2. 既存データをチャンク単位でコピー
# 3. トリガーで変更差分を同期
# 4. RENAME TABLE でアトミックに切り替え
# 5. 旧テーブルを削除

6-3. gh-ost (GitHub Online Schema Transmogrifier)

GitHub が開発したツール。pt-osc と異なりトリガーを使わず、バイナリログを監視して差分を同期します。

# 実行例
gh-ost \
  --database=mydb \
  --table=orders \
  --alter="ADD COLUMN memo TEXT" \
  --execute \
  --allow-on-master \
  --max-lag-millis=1500 \
  --chunk-size=1000

# gh-ost の利点:
# ・トリガー不要(トリガー競合なし)
# ・実行中の一時停止・再開が可能
# ・負荷の動的調整が可能

6-4. PostgreSQL 向け: pg_repack

# テーブルの再編成(VACUUM FULL の代替、ロック時間を最小化)
pg_repack --table orders --jobs 4 mydb

これらのツールは便利ですが、ディスク容量がテーブルサイズの2倍以上必要です。事前に空き容量を確認してください。また、外部キー制約がある場合は追加の注意が必要です。

バックフィルの分割実行

大量行の UPDATE はバッチ分割で実行し、レプリケーション遅延やロック競合を防ぎます。

-- PostgreSQL: ctid を使ったバッチ更新例
DO $$
DECLARE
  batch_size INT := 10000;
  updated    INT;
BEGIN
  LOOP
    UPDATE orders
       SET tax_rate = 0.10
     WHERE tax_rate IS NULL
       AND ctid IN (
         SELECT ctid FROM orders
          WHERE tax_rate IS NULL
          LIMIT batch_size
          FOR UPDATE SKIP LOCKED
       );
    GET DIAGNOSTICS updated = ROW_COUNT;
    RAISE NOTICE 'Updated % rows', updated;
    IF updated = 0 THEN EXIT; END IF;
    COMMIT;
    PERFORM pg_sleep(0.5);  -- レプリカへの同期待ち
  END LOOP;
END $$;

7. ロールバック戦略

マイグレーションが失敗した場合、あるいは適用後に問題が発覚した場合にどう対処するか。事前に戦略を決めておくことが重要です。

前方修正 (Forward-Fix) vs ロールバック

戦略概要適するケース
ロールバック 変更を逆方向に巻き戻す 加算的変更(列追加など容易に元に戻せるもの)。データ移行がまだ始まっていない段階
前方修正 (Forward-Fix) 問題を修正する新しいマイグレーションを追加適用する データ移行済みで巻き戻すとデータ損失が起こるケース。ロールバックの方がリスクが高い場合

ロールバック可否の判断フロー

マイグレーション失敗 / 問題発覚
  │
  ├─ データ変更を伴わない DDL のみか?
  │    ├─ Yes → ロールバック(逆 DDL 実行)
  │    └─ No ──┐
  │            │
  ├─ データの損失なく元に戻せるか?
  │    ├─ Yes → ロールバック(逆 DDL + データ復元)
  │    └─ No ──┐
  │            │
  └─ 前方修正の方が安全か?
       ├─ Yes → Forward-Fix(新マイグレーション作成)
       └─ No  → バックアップからのリストアを検討

ロールバック可能な設計のポイント

# Rails Migrations のロールバック例
class AddEmailToCustomers < ActiveRecord::Migration[7.0]
  def up
    add_column :customers, :email, :string
  end

  def down
    remove_column :customers, :email
  end
end

# ロールバック実行
$ rails db:rollback STEP=1
# Alembic のロールバック例
def upgrade():
    op.add_column('customers', sa.Column('email', sa.String(200)))

def downgrade():
    op.drop_column('customers', 'email')

# ロールバック実行
$ alembic downgrade -1

実務のコツ: 破壊的変更(列削除など)のロールバックスクリプトには「バックアップからリストアする手順書へのリンク」を down メソッドのコメントに記載しておくと、障害対応時に役立ちます。

8. 本番適用のチェックリスト

本番マイグレーションの前に以下を確認してください。チーム内でこのリストをテンプレート化し、毎回確認することを推奨します。

実行前確認

#チェック項目確認方法
1ステージング環境で本番同等データ量のテストを完了したかテスト結果のログ・スクリーンショット
2実行予想時間を計測したかステージングでの実測値
3ロック影響を確認したか(どのクエリがブロックされるか)SHOW PROCESSLIST / pg_stat_activity
4ディスク空き容量は十分か(テーブルサイズの2倍以上)df -h + テーブルサイズ確認
5ロールバック手順を文書化し、レビュー済みか手順書の存在確認
6影響を受けるアプリ・バッチの一覧を把握しているか依存関係の調査結果
7メンテナンスウィンドウは適切か(低トラフィック帯)トラフィックグラフの確認

バックアップ

#チェック項目確認方法
8実行直前のバックアップを取得したかバックアップ完了ログ
9バックアップからのリストア手順をテスト済みかリストアテストの実施記録
10バイナリログ / WAL のポイントインタイムリカバリ設定は有効か設定値の確認

実行時

#チェック項目確認方法
11実行中のロック状態を監視しているかSHOW ENGINE INNODB STATUS / pg_locks
12レプリケーション遅延を監視しているかSHOW SLAVE STATUS / pg_stat_replication
13アプリのエラーレートを監視しているかAPM ツール / エラーログ
14中断基準を事前に決めているか(例: 遅延5秒超で中断)中断基準の文書化

実行後

#チェック項目確認方法
15マイグレーションが正常完了したことを確認したかテーブル定義の確認 (DESCRIBE / \d)
16アプリの主要機能が正常動作しているかスモークテスト / ヘルスチェック
17クエリパフォーマンスに劣化がないかスロークエリログ / APM
18レプリカへの反映が完了したかレプリケーション状態の確認
19関係者への完了報告を行ったかSlack / メール通知

このチェックリストは最小限の項目です。組織の規模やコンプライアンス要件に応じて、承認フロー・変更管理チケット・監査ログなどの項目を追加してください。

まとめ: スキーマ変更運用の要点は「一発で変えない・互換期間を設ける・戻し方を先に決める」です。Expand/Contract を標準パターンとして定着させ、チェックリストで確認を習慣化すれば、本番変更の事故リスクを大幅に低減できます。