🔄
概念 #進化的アーキテクチャ #データベース #マイグレーション #PostgreSQL #ゼロダウンタイム 📚 進化的アーキテクチャ

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 フェーズの旧カラム削除が最も不可逆な操作なので、削除前に十分な観察期間を設ける。

関連概念

出典・参考文献

  • Martin Fowler, “Evolutionary Database Design” — martinfowler.com
  • Pramod Sadalage, Martin Fowler, Refactoring Databases (2006)

出典: 進化的アーキテクチャ(O'Reilly)/ Evolutionary Database Design(Martin Fowler)