🔄
Expand-Contract パターン:ゼロダウンタイムでスキーマ変更する
Expand(拡張)→ Migrate(移行)→ Contract(縮小)の3フェーズで本番 DB スキーマをダウンタイムなしに変更するパターン。PostgreSQL SQL 例と注意点を整理する
問題:本番 DB のスキーマ変更でダウンタイムが発生する
カラム名を変更したい、型を変えたい、テーブルを分割したい──こうした変更を単純に ALTER TABLE で行うと次の問題が起きる。
- ロック: ALTER TABLE はテーブルロックを取得し、その間クエリがブロックされる
- アプリとの不整合: DB を変更した瞬間、古いコードが壊れる
- ロールバックの困難さ: データが既に移行されていると戻すのが難しい
Expand-Contract パターンはこれを 3 フェーズに分割することで解決する。
3フェーズ
Phase 1: Expand(拡張)
新カラム追加(nullable)、アプリは新旧両方へ書く
Phase 2: Migrate(移行)
旧データを新カラムへバックフィル
一時的 Fitness Function でデータ整合性を検証
Phase 3: Contract(縮小)
アプリの読みを新カラムへ切替、旧カラム削除
Phase 1: Expand(拡張)
DB 変更(DDL)
-- NULL を許容して追加(ALTER TABLE はロックが短時間で済む)
ALTER TABLE users ADD COLUMN email_new VARCHAR(255);
-- インデックスが必要な場合は CONCURRENTLY で作成(ロックなし)
CREATE INDEX CONCURRENTLY idx_users_email_new ON users(email_new);
アプリ変更(TypeScript)
// Expand フェーズ: 新旧両方のカラムへ書く
// 読みはまだ旧カラム(email)を使う
async function saveUser(user: User): Promise<void> {
await db.query(`
UPDATE users
SET
email = $1, -- 旧カラム(引き続き書く)
email_new = $1 -- 新カラム(追加で書き始める)
WHERE id = $2
`, [user.email, user.id]);
}
async function findUser(id: string): Promise<User> {
const row = await db.query(
'SELECT id, email FROM users WHERE id = $1', // 読みはまだ旧カラム
[id]
);
return mapToUser(row);
}
Phase 2: Migrate(移行)
バックフィル SQL
Expand フェーズ以前に存在したレコードには email_new = NULL のままのものがある。これを新カラムへコピーする。
-- バッチ処理でバックフィル(全件一括更新はロック危険)
-- アプリが稼働中に実行するので、小さなバッチに分割する
DO $$
DECLARE
batch_size INT := 1000;
last_id BIGINT := 0;
max_id BIGINT;
BEGIN
SELECT MAX(id) INTO max_id FROM users;
WHILE last_id < max_id LOOP
UPDATE users
SET email_new = email
WHERE id > last_id
AND id <= last_id + batch_size
AND email_new IS NULL;
last_id := last_id + batch_size;
PERFORM pg_sleep(0.01); -- 本番負荷への配慮
END LOOP;
END $$;
一時的 Fitness Function(整合性検証)
-- この検証は Migrate フェーズの間だけ CI で実行する
-- Contract フェーズ完了後に削除する
SELECT COUNT(*) AS inconsistent_count
FROM users
WHERE email_new IS NULL -- まだコピーされていない行
OR email != email_new; -- 値が食い違っている行
-- 結果が 0 になったら Contract フェーズへ進める
Phase 3: Contract(縮小)
アプリ変更(読みを新カラムへ切替)
// Contract フェーズ: 読みを新カラムへ切り替える
async function findUser(id: string): Promise<User> {
const row = await db.query(
'SELECT id, email_new AS email FROM users WHERE id = $1', // 新カラムを読む
[id]
);
return mapToUser(row);
}
// 書きも新カラムのみに絞る
async function saveUser(user: User): Promise<void> {
await db.query(`
UPDATE users
SET
email_new = $1 -- 旧カラムへの書き込みを停止
WHERE id = $2
`, [user.email, user.id]);
}
DB 変更(旧カラム削除)
-- アプリが旧カラムを参照しなくなったことを確認してから実行
ALTER TABLE users DROP COLUMN email;
-- 必要であればカラム名を変更
ALTER TABLE users RENAME COLUMN email_new TO email;
注意事項
NOT NULL 制約はいつ追加するか
Expand フェーズで NULL 許容として追加し、Contract フェーズでバックフィル完了後に制約を追加する。
-- Contract フェーズ(バックフィル完了後)
ALTER TABLE users ALTER COLUMN email_new SET NOT NULL;
外部キー制約の扱い
旧カラムに外部キー制約がある場合、新カラムにも同じ制約を追加してから旧制約を削除する。
-- Expand: 新カラムに制約追加
ALTER TABLE orders ADD COLUMN user_id_new BIGINT;
ALTER TABLE orders ADD CONSTRAINT fk_orders_user_new
FOREIGN KEY (user_id_new) REFERENCES users(id);
-- Contract: 旧制約削除
ALTER TABLE orders DROP CONSTRAINT fk_orders_user;
ALTER TABLE orders DROP COLUMN user_id;
ALTER TABLE orders RENAME COLUMN user_id_new TO user_id;
カラム名変更・テーブル分割への応用
- カラム名変更: 上記の通り Expand-Migrate-Contract を踏む
- テーブル分割: 新テーブルを Expand で追加 → Migrate でデータコピー → アプリの参照先を切替 → Contract で旧テーブル削除
ロールバック計画
| フェーズ | ロールバック方法 |
|---|---|
| Expand(新カラム追加) | ALTER TABLE DROP COLUMN(データ損失なし) |
| Migrate(バックフィル中) | アプリコードを Expand 版に戻す、新カラムを空にしても旧カラムは無事 |
| Contract(旧カラム削除後) | バックアップから旧カラムを復元(困難)→ Contract 前に十分な検証を |
Contract フェーズの旧カラム削除が最も不可逆な操作なので、削除前に十分な観察期間を設ける。
関連概念
- → Dual Write(ストア移行時のデータ整合性)
- → 適応度関数(一時的 Fitness Function の使い方)
- → 段階的変更パターン
出典・参考文献
- Martin Fowler, “Evolutionary Database Design” — martinfowler.com
- Pramod Sadalage, Martin Fowler, Refactoring Databases (2006)
- 1. 🧬進化的アーキテクチャとは:定義・誕生背景・3原則
- 2. 📐適応度関数(Fitness Functions):アーキテクチャ特性を自動検証する
- 3. 🔗適切な結合(Appropriate Coupling):Connascence と分散モノリスの罠
- 4. 🚀段階的変更(Incremental Change):Feature Toggle・Blue-Green・Canary・Dark Launch
- 5. 🔄Expand-Contract パターン:ゼロダウンタイムでスキーマ変更する
- 6. ✍️Dual Write:マイクロサービス分割時のデータ整合性パターンと落とし穴
- 7. 📤Outbox Pattern:トランザクション境界を越えた安全なイベント発行
- 8. 📡CDC(Change Data Capture):アプリを汚さずに変更を伝播する
- 9. 🐚Unix 哲学を現代アーキテクチャへ翻訳する
- 10. 🧅クリーンアーキテクチャが進化耐性をもたらす理由
- 11. 🏢Conway's Law:組織構造がアーキテクチャを決める
出典: 進化的アーキテクチャ(O'Reilly)/ Evolutionary Database Design(Martin Fowler)