はじめに
サービスを止めずにデータベーススキーマを変更する「ゼロダウンタイムマイグレーション」は、本番運用において必須のスキルです。本記事では Claude Code を活用し、Expand-Contract パターン・後方互換バックフィル・並行インデックス作成を組み合わせた設計手法を解説します。
Expand-Contract パターンとは
「Expand(拡張)→ 移行 → Contract(収縮)」という4フェーズでスキーマ変更を安全に進める手法です。
フェーズ1: Expand → NULL許容で新カラム追加
フェーズ2: Backfill → バッチで既存データ移行
フェーズ3: 両書き → アプリが旧・新カラムの両方に書く
フェーズ4: Contract → 旧カラム削除(完全移行後)
このパターンの核心は デプロイ順序とDB変更順序を分離 することです。
フェーズ1:NULL許容カラムの追加(Expand)
-- 新カラムは必ず NULL 許容で追加する
-- NOT NULL は既存行を壊すため絶対に使わない
ALTER TABLE users ADD COLUMN email_verified_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);
NG パターン: ADD COLUMN foo VARCHAR NOT NULL DEFAULT '' はテーブルロックが発生するケースがある(PostgreSQL バージョンによる)。
フェーズ2:カーソルベースのバッチバックフィル
全件を一度に UPDATE するとロックで本番が止まります。カーソルベースで小分けに処理します。
import { Pool } from 'pg';
const pool = new Pool({ connectionString: process.env.DATABASE_URL });
const BATCH_SIZE = 500;
const SLEEP_MS = 50;
async function sleep(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function backfillDisplayName(): Promise<void> {
let lastId = 0;
let processed = 0;
while (true) {
const { rows } = await pool.query(
`UPDATE users
SET display_name = username
WHERE id > $1
AND display_name IS NULL
RETURNING id
LIMIT $2`,
[lastId, BATCH_SIZE]
);
if (rows.length === 0) break;
lastId = rows[rows.length - 1].id;
processed += rows.length;
console.log(`Backfilled ${processed} rows (last id: ${lastId})`);
// 50ms スリープで DB 負荷を分散
await sleep(SLEEP_MS);
}
console.log(`Backfill complete: ${processed} rows`);
}
backfillDisplayName().catch(console.error);
フェーズ3:アプリケーションの両書き対応
バックフィル中・完了後しばらくは、アプリが 旧カラムと新カラムの両方に書き込む ようにします。
async function updateUser(userId: number, name: string): Promise<void> {
await pool.query(
`UPDATE users
SET username = $2, -- 旧カラム(後方互換)
display_name = $2 -- 新カラム
WHERE id = $1`,
[userId, name]
);
}
async function getDisplayName(userId: number): Promise<string> {
const { rows } = await pool.query(
`SELECT COALESCE(display_name, username) AS name
FROM users WHERE id = $1`,
[userId]
);
return rows[0]?.name ?? '';
}
COALESCE で新カラムを優先しつつ旧カラムをフォールバックにすることで、移行途中でも安全に読み取りができます。
CREATE INDEX CONCURRENTLY
通常の CREATE INDEX はテーブル全体にロックをかけます。本番では必ず CONCURRENTLY を使います。
-- ロックなしでインデックス作成(時間はかかるが本番影響なし)
CREATE INDEX CONCURRENTLY idx_users_display_name
ON users (display_name);
-- 完了確認
SELECT schemaname, tablename, indexname, indexdef
FROM pg_indexes
WHERE tablename = 'users'
AND indexname = 'idx_users_display_name';
Concurrent インデックス作成は通常の2〜3倍時間がかかりますが、ダウンタイムなしで完了します。
フェーズ4:旧カラムの削除(Contract)前バージョンチェック
旧カラムを削除する前に、旧バージョンのアプリが完全に停止していることをデプロイシステムで確認します。
const MINIMUM_APP_VERSION = '2.5.0';
async function contractPhaseCheck(): Promise<void> {
// アプリバージョンを Redis やヘルスエンドポイントで確認
const runningVersions = await getRunningAppVersions();
const hasOldVersion = runningVersions.some(
(v) => compareVersion(v, MINIMUM_APP_VERSION) < 0
);
if (hasOldVersion) {
throw new Error(
`Contract phase blocked: old app versions still running: ${runningVersions.join(', ')}`
);
}
console.log('Version check passed. Running Contract phase...');
await pool.query('ALTER TABLE users DROP COLUMN username');
console.log('Old column dropped successfully.');
}
まとめ
- Expand-Contract の4フェーズ: NULL許容追加 → バッチバックフィル → 両書き → 旧カラム削除の順序を守ることでダウンタイムをゼロにできる
- カーソルバックフィル + 50ms スリープ: バッチサイズ500行・50ms間隔で本番 DB への負荷を最小化する
- CREATE INDEX CONCURRENTLY: 本番インデックス追加は必ず CONCURRENTLY を使い、テーブルロックを回避する
- Contract 前バージョンチェック: 旧コードが完全に退役してから旧カラムを削除する仕組みを自動化し、ヒューマンエラーを防ぐ
Code Review Pack ¥980
セキュリティ・パフォーマンス・可読性を一括レビューするプロンプトパック。Claude Code / Codex CLI 対応。
Top comments (0)