LESSON 30分

ストーリー

「24時間365日稼働しているサービスで、メンテナンスウィンドウなしにスキーマを変更できるか?」

高橋アーキテクトの問いに、あなたは考え込む。

「できる。ただし、慎重に段階を踏む必要がある。ゼロダウンタイムマイグレーションは、サービスを止めずにスキーマを安全に変更する技術だ。Expand-Contract パターンを覚えれば、恐れることはない」


なぜゼロダウンタイムが必要か

従来のアプローチ問題
メンテナンスウィンドウサービス停止 → ユーザー離脱
深夜のメンテナンスグローバルサービスでは「深夜」がない
ビッグバンリリース失敗時のリスクが大きい

Expand-Contract パターン

スキーマ変更を3段階に分けて安全に実行する。

graph TD
    P1["Phase 1: Expand(拡張)<br/>新しい構造を追加(旧構造はそのまま)"]
    P2["Phase 2: Migrate(移行)<br/>データを新構造に移行、アプリを新構造に切り替え"]
    P3["Phase 3: Contract(縮小)<br/>旧構造を削除"]

    P1 --> P2 --> P3

例1: カラム名の変更

users.nameusers.full_name に変更する場合。

NG: 直接RENAME

-- サービスが止まる: アプリがまだ "name" を参照している
ALTER TABLE users RENAME COLUMN name TO full_name;

OK: Expand-Contract

-- Phase 1: Expand(新カラム追加)
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);

-- Phase 2: Migrate(データコピー + アプリ更新)
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- アプリを更新: name と full_name の両方に書き込む(Dual Write)
-- 十分な期間、両方のカラムを使う

-- Phase 3: Contract(旧カラム削除)
ALTER TABLE users DROP COLUMN name;

TypeScript: Dual Write フェーズ

// Phase 2: 両方のカラムに書き込む
async function updateUserName(userId: number, newName: string): Promise<void> {
  await prisma.$executeRaw`
    UPDATE users SET name = ${newName}, full_name = ${newName}
    WHERE id = ${userId}
  `;
}

// Phase 2 後半: 新カラムから読む
async function getUserName(userId: number): Promise<string> {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return user.fullName ?? user.name;  // full_name優先、なければname
}

// Phase 3: 新カラムのみ使用
async function getUserName(userId: number): Promise<string> {
  const user = await prisma.user.findUnique({ where: { id: userId } });
  return user.fullName;
}

例2: NOT NULL カラムの追加

-- NG: NOT NULL + DEFAULT なしで追加
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL;
-- ERROR: column "phone" contains null values

-- OK: 段階的に追加
-- Phase 1: NULLable で追加
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- Phase 2: 既存データにデフォルト値を設定
UPDATE users SET phone = '' WHERE phone IS NULL;

-- Phase 3: NOT NULL 制約を追加
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users ALTER COLUMN phone SET DEFAULT '';

例3: テーブルの分割

users テーブルから user_profiles を分離する。

-- Phase 1: Expand(新テーブル作成)
CREATE TABLE user_profiles (
  user_id INT PRIMARY KEY REFERENCES users(id),
  bio TEXT,
  avatar_url VARCHAR(500),
  website VARCHAR(500)
);

-- Phase 2: Migrate(データコピー)
INSERT INTO user_profiles (user_id, bio, avatar_url, website)
SELECT id, bio, avatar_url, website FROM users;

-- アプリ更新: 両方から読み書き(Dual Read/Write)
-- 新テーブル優先で読む

-- Phase 3: Contract(旧カラム削除)
ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url;
ALTER TABLE users DROP COLUMN website;

大量データの安全な更新

大テーブルを一括UPDATEすると長時間ロックがかかる。バッチ処理で分割する。

// バッチでデータマイグレーション
async function migrateInBatches(
  batchSize: number = 1000,
  delayMs: number = 100
): Promise<void> {
  let totalMigrated = 0;

  while (true) {
    const result = await prisma.$executeRaw`
      UPDATE users
      SET full_name = name
      WHERE full_name IS NULL
      AND id IN (
        SELECT id FROM users
        WHERE full_name IS NULL
        ORDER BY id
        LIMIT ${batchSize}
      )
    `;

    totalMigrated += result;
    console.log(`Migrated ${totalMigrated} rows`);

    if (result < batchSize) {
      break; // すべて完了
    }

    // DBに負荷を与えすぎないよう一定間隔を空ける
    await new Promise(resolve => setTimeout(resolve, delayMs));
  }

  console.log(`Migration complete: ${totalMigrated} total rows`);
}

インデックスの安全な作成

-- NG: 通常のCREATE INDEX(テーブルロック)
CREATE INDEX idx_orders_status ON orders(status);
-- → 大テーブルでは数分~数十分のロック

-- OK: CONCURRENTLY(PostgreSQL)
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
-- → ロックなし、ただし時間はかかる(書き込みをブロックしない)

危険な操作チェックリスト

操作リスク安全な方法
DROP COLUMNデータ消失Expand-Contractで段階的に
RENAME COLUMNアプリとの不整合新カラム追加 → Dual Write → 旧削除
ALTER TYPEテーブルロック新カラム追加 → データコピー → 旧削除
ADD NOT NULL既存NULL値でエラーNULLable追加 → データ埋め → NOT NULL化
CREATE INDEXテーブルロックCREATE INDEX CONCURRENTLY
DROP TABLEデータ消失事前バックアップ + リネーム保持

デプロイ戦略との連携

graph TD
    A["マイグレーション Phase 1<br/>Expand"]
    B["アプリ デプロイ v2<br/>新旧両対応"]
    C["マイグレーション Phase 2<br/>Data Migration"]
    D["動作確認・検証期間"]
    E["アプリ デプロイ v3<br/>新のみ使用"]
    F["マイグレーション Phase 3<br/>Contract"]

    A --> B --> C --> D --> E --> F

まとめ

ポイント内容
Expand-Contract拡張 → 移行 → 縮小の3段階
Dual Write移行期間は新旧両方に書き込む
バッチ処理大量データは分割して更新
CONCURRENTLYインデックスはロックなしで作成
デプロイ連携マイグレーションとアプリデプロイを段階的に

理解度チェックリスト

  • Expand-Contract パターンの3段階を説明できる
  • カラム名変更をゼロダウンタイムで行う手順を設計できる
  • 大量データの安全な更新をバッチ処理で実装できる
  • CREATE INDEX CONCURRENTLY の必要性を理解している

次のステップ

次のレッスンではバックアップとリストア戦略を学ぶ。マイグレーション失敗時の保険として、確実なバックアップ体制を構築しよう。


推定読了時間: 30分