DB マイグレーションは、平常時は地味な作業ですが、本番で失敗すると一瞬でサービス全停止に直結します。私自身、深夜のデプロイで ALTER TABLE ... ADD COLUMN ... NOT NULL を流して、PostgreSQL のテーブルロックで API がタイムアウトを連発したことがあります。アプリ側のリトライがロック待ちを増幅させて、復旧まで30分。あの夜の冷や汗は今でも忘れられません。
具体例として、users テーブルの email カラムを email_address にリネームしたい場合を考えます。素朴に ALTER TABLE users RENAME COLUMN email TO email_address を流すと、その瞬間から古いコードが動かなくなります。Expand-Contract では次のように分解します。
-- 1. Expand: 新カラムを追加(NULL 許容)ALTER TABLE users ADD COLUMN email_address VARCHAR(255);CREATE INDEX CONCURRENTLY idx_users_email_address ON users(email_address);-- 2. Backfill: 既存データをコピー(バッチで分割実行)-- アプリは旧カラムを読み書きしながら、新カラムへの二重書き込みを開始UPDATE users SET email_address = email WHERE email_address IS NULL AND id BETWEEN 1 AND 10000;-- 3. Switch: アプリを新カラム読み込みに切り替え-- 旧カラムへの書き込みは継続(ロールバック対応)-- 4. Contract: 旧カラムを削除ALTER TABLE users DROP COLUMN email;
Claude Code に「email カラムを email_address にリネームしたい。Expand-Contract で安全に進めて」と依頼すると、上記のような分解されたマイグレーションファイル群を生成してくれます。重要なのは、各ステップが独立してデプロイ可能で、途中で問題が起きてもロールバックできることです。
-- バックフィル(10,000行ずつ、間に100ms sleep)DO $$DECLARE batch_size INT := 10000; rows_updated INT;BEGIN LOOP UPDATE orders SET status = 'active' WHERE status IS NULL AND id IN ( SELECT id FROM orders WHERE status IS NULL LIMIT batch_size ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; PERFORM pg_sleep(0.1); END LOOP;END $$;
第4段階で、CHECK 制約として NOT NULL 相当の制約を追加します。これは NOT VALID で先に追加すると、テーブルロックを取らずに済みます。
-- NOT VALID で制約を即時追加(既存行はチェックしない)ALTER TABLE orders ADD CONSTRAINT orders_status_not_null CHECK (status IS NOT NULL) NOT VALID;-- 既存行を後から検証(ロックなし、SHARE UPDATE EXCLUSIVE)ALTER TABLE orders VALIDATE CONSTRAINT orders_status_not_null;
第5段階で、ようやく NOT NULL を本来の形にします。CHECK 制約があるため、この操作は瞬時に終わります。
ALTER TABLE orders ALTER COLUMN status SET NOT NULL;ALTER TABLE orders DROP CONSTRAINT orders_status_not_null;
この5段階の意図を Claude Code に伝えるには、CLAUDE.md に「NOT NULL カラムの追加は5段階デプロイで進める」と明記し、最初の依頼時に「このルールに従って」と一言添えるだけで十分です。Claude Code は各段階のマイグレーションファイルを正しい順序で生成してくれます。
-- 二重書き込みトリガー(旧カラムへの書き込みを新カラムにも反映)CREATE OR REPLACE FUNCTION sync_email_to_email_address()RETURNS TRIGGER AS $$BEGIN IF NEW.email IS DISTINCT FROM OLD.email OR OLD IS NULL THEN NEW.email_address := NEW.email; END IF; RETURN NEW;END;$$ LANGUAGE plpgsql;CREATE TRIGGER trg_sync_emailBEFORE INSERT OR UPDATE ON usersFOR EACH ROW EXECUTE FUNCTION sync_email_to_email_address();
// backfill.ts — レプリカラグ適応型バックフィルimport { db } from './db';async function backfillEmailAddress() { let sleepMs = 100; let lastId = 0; const batchSize = 5000; while (true) { const start = Date.now(); const result = await db.execute(sql` UPDATE users SET email_address = email WHERE email_address IS NULL AND id > ${lastId} ORDER BY id LIMIT ${batchSize} RETURNING id `); if (result.rows.length === 0) break; lastId = Math.max(...result.rows.map(r => r.id)); // レプリカラグを取得 const lag = await getReplicaLagMs(); if (lag > 100) { sleepMs = Math.min(sleepMs * 2, 5000); console.log(`Lag ${lag}ms detected, increasing sleep to ${sleepMs}ms`); } else if (lag < 50 && sleepMs > 100) { sleepMs = Math.max(sleepMs / 2, 100); } const elapsed = Date.now() - start; console.log(`Batch updated ${result.rows.length} rows in ${elapsed}ms, sleeping ${sleepMs}ms`); await new Promise(r => setTimeout(r, sleepMs)); }}async function getReplicaLagMs(): Promise<number> { const result = await db.execute(sql` SELECT EXTRACT(EPOCH FROM (now() - pg_last_xact_replay_timestamp())) * 1000 AS lag_ms `); return result.rows[0]?.lag_ms ?? 0;}backfillEmailAddress().catch(console.error);
第二の戦略は「インデックスの後付け」です。バックフィル中はインデックスが断片化しやすいため、CREATE INDEX CONCURRENTLY でバックフィル後に作成します。CONCURRENTLY は通常の CREATE INDEX より遅いものの、書き込みをブロックしません。
第三の戦略は「冪等性の確保」です。バックフィルが途中で失敗してもやり直せるように、WHERE email_address IS NULL のような条件を必ず付けます。私は過去に、冪等性のないスクリプトでバックフィルが途中停止し、どこから再開すべきか分からなくなった経験があります。
リバースマイグレーション(ロールバック)の設計
ロールバックは「いざという時の保険」ではなく、「マイグレーションの設計自体に組み込むもの」です。私が down を書く際に必ず守るルールは次の3つです。
第一に、データを失う down は書かないことです。DROP COLUMN を down で書くと、up で挿入したデータが全て消えます。代わりに、本番投入から十分な期間(私の場合は1週間)が経つまで、down には「カラムを残しつつアプリの参照だけ戻す」操作を書きます。
第二に、down のテストを必ず書くことです。マイグレーション CI で up → down → up を繰り返してエラーが出ないことを確認します。Claude Code に「このマイグレーションのテストを書いて。up→down→up を3回繰り返してデータが保持されることを確認」と依頼すると、Vitest のテストコードが出てきます。
第二の落とし穴は、CREATE INDEX CONCURRENTLY が途中失敗したときに残る INVALID インデックスです。CONCURRENTLY はトランザクション内で実行できないため、失敗するとディスクを消費したまま残り、プランナを混乱させます。マイグレーション後に検出してクリーンアップする運用が必要です。
-- 失敗した CONCURRENTLY が残した INVALID インデックスを検出SELECT indexrelid::regclass AS invalid_indexFROM pg_index WHERE NOT indisvalid;-- 明示的に DROP INDEX CONCURRENTLY で削除DROP INDEX CONCURRENTLY IF EXISTS idx_users_email_address;
私は Claude Code に小さな Skill を持たせて、マイグレーション後に INVALID インデックスをスキャンしてログに出させています。
第三の落とし穴は、コネクションプールとスキーマ変更の相互作用です。PgBouncer のトランザクションプーリングモードでは、プリペアドステートメントが古いスキーマに紐付いたまま残ります。カラムリネーム後に「column does not exist」という分かりにくいエラーが出るのは、このパターンが多いです。回避策は、マイグレーション後にプリペアドステートメントを破棄する DEALLOCATE ALL を全コネクションに発行することです。Claude Code に対応スクリプトを生成させると安全です。
SELECT pid, state, wait_event_type, wait_event, now() - xact_start AS xact_duration, LEFT(query, 80) AS query_previewFROM pg_stat_activityWHERE state != 'idle'ORDER BY xact_duration DESC NULLS LASTLIMIT 20;
私は Claude Code に「このクエリを2秒間隔でポーリングして、トランザクション継続時間で色分けする小さなターミナルダッシュボードを書いて」と依頼します。マイグレーション実行中はそのダッシュボードと進捗ログを別ペインで並べて開きます。30秒以上ブロックされているセッションが赤くなったら、数秒で「続行か中断か」を判断します。
レプリカラグは PostgreSQL なら pg_last_xact_replay_timestamp() で取れます。5秒間隔でメトリクスを送信する小さなラッパースクリプトがあれば、ラグスパイクを早期に捕捉できます。Claude Code に書かせる際の注意点は、メトリクス送信パスをマイグレーション対象の DB に依存させないことです。DB が一瞬詰まった瞬間にダッシュボードまで止まると、判断材料を失います。
第一に、テスト DB で同等のデータ量に対してマイグレーションを実測したか。1万行のテストではなく、本番に近い1,000万行で時間を測ります。第二に、ロック時間の上限を見積もったか。SLA に対して許容範囲内か確認します。第三に、ロールバック手順が文書化されているか。深夜の障害時、寝ぼけた頭で読んでも実行できる粒度になっているかが基準です。