スキーマ変更運用(マイグレーション)
DB設計は作って終わりではありません。本番運用では「安全に変える技術」が必須です。このページでは、スキーマ変更を安全に本番適用するための原則・パターン・ツール・チェックリストを体系的に解説します。
1. なぜ運用設計が必要か
開発環境なら DROP TABLE して作り直せます。しかし本番環境には既存データ・稼働中のアプリ・他チームの依存クエリがあります。「変更を入れる」こと自体がリスクを伴う行為です。
本番変更で起こりうる事故の具体例
| 操作 | 起こりうる事故 | 影響 |
|---|---|---|
| 列削除 | 旧バージョンのアプリがその列を参照し SELECT が全件エラー | サービス全停止 |
| 列型変更 (VARCHAR→INT) | 既存データに数値変換できない文字列が含まれ ALTER 失敗 | DDL がロールバックされ長時間ロック発生 |
| NOT NULL 制約追加 | 既存行に NULL が存在し ALTER 失敗。または成功しても INSERT が止まる | データ投入バッチ停止 |
| インデックス追加 | 1億行テーブルで数十分のロック発生 | 書き込み全ブロック、タイムアウト多発 |
| テーブル名変更 | バッチ・BI ツール・他サービスの参照先が全て壊れる | 複数システムに波及 |
本番 DDL は「コードデプロイ」と同等以上のリスクを持ちます。「ALTER 1行だから簡単」という認識は危険です。データ量・依存関係・ロック影響を必ず事前評価してください。
運用設計が必要な3つの理由
- データがある状態で DDL を実行する -- 空テーブルなら一瞬の操作でも、数億行あれば数時間かかることがある
- アプリとDBのバージョン差分が一時的に発生する -- デプロイはローリングで行われるため、新旧アプリが同時にDBにアクセスする時間帯が必ずある
- 障害時のロールバック方針が必要 -- 「戻せない変更」を入れてしまうと、障害時の選択肢がゼロになる
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分かかることがあります。以下を事前に確認してください。
- 対象テーブルの行数とデータサイズ(
pg_total_relation_size()やSHOW TABLE STATUS) - ステージング環境で本番同等のデータ量を用意して実行時間を計測
- ロック影響の確認(他のクエリがブロックされないか)
3. Expand/Contract パターン詳解
スキーマ変更を安全に行うための最も基本的なパターンが Expand/Contract(拡張/収縮)です。破壊的変更を「加算的変更の積み重ね」に分解します。
全体像:3つのフェーズ
時間軸 →→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→→
Phase 1: Expand(拡張)
├─ 新しい列/テーブルを追加(既存構造には触れない)
├─ トリガーまたはアプリで新旧両方に書き込み(Dual Write)
└─ 既存データを新構造へバックフィル
Phase 2: Migrate(移行)
├─ アプリの読み取りを新構造へ切り替え
├─ 新構造のみに書き込むよう変更
└─ 旧構造への書き込みを停止
Phase 3: Contract(収縮)
├─ 旧列/テーブルへの参照がゼロであることを確認
├─ 旧構造を削除
└─ 不要なトリガー・ビューを削除
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DB状態: [旧のみ] → [旧+新 共存] → [新のみ]
アプリ: [旧参照] → [新旧両対応] → [新参照]
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
各フェーズの詳細
Phase 1: Expand(拡張)
- 新列の追加は
NULL 許容かつデフォルト値なしで行う(瞬時完了する RDBMS が多い) - Dual Write: アプリ側で新旧両方の列に値を書く。または DB トリガーで自動同期する
- バックフィル: 既存データを新列へコピー。大量行はバッチ分割で行う
-- 例: 氏名列 → 姓・名に分割
-- 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 等のバージョン管理に含めます。重要なルールは以下の通りです。
- 一度適用したマイグレーションは編集しない -- 新しいマイグレーションで修正する
- 本番適用済みマイグレーションの順序を変更しない
- マイグレーションファイルに破壊的変更を1つだけ含める -- レビュー・ロールバックしやすくするため
- CI でマイグレーションの整合性チェックを自動化する
# 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=NONE | CREATE 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 → バックアップからのリストアを検討
ロールバック可能な設計のポイント
- 各マイグレーションに down(逆操作)を定義する -- ツールが対応していれば必ず書く
- 列追加の down は列削除だが、データが入った列の削除はデータ損失になることを認識する
- 列削除は「本当のロールバック」ができない -- 削除前のバックアップが唯一の復元手段
- Expand/Contract パターンで互換期間を設けること自体がロールバック戦略 -- 旧構造が残っている間は、アプリを旧バージョンに戻すだけで済む
# 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 を標準パターンとして定着させ、チェックリストで確認を習慣化すれば、本番変更の事故リスクを大幅に低減できます。