DEV Community

myougaTheAxo
myougaTheAxo

Posted on • Originally published at zenn.dev

Claude CodeでゼロダウンタイムDBマイグレーションを設計する:Expand-Contract・後方互換

はじめに

サービスを止めずにデータベーススキーマを変更する「ゼロダウンタイムマイグレーション」は、本番運用において必須のスキルです。本記事では Claude Code を活用し、Expand-Contract パターン・後方互換バックフィル・並行インデックス作成を組み合わせた設計手法を解説します。


Expand-Contract パターンとは

「Expand(拡張)→ 移行 → Contract(収縮)」という4フェーズでスキーマ変更を安全に進める手法です。

フェーズ1: Expand   → NULL許容で新カラム追加
フェーズ2: Backfill → バッチで既存データ移行
フェーズ3: 両書き   → アプリが旧・新カラムの両方に書く
フェーズ4: Contract → 旧カラム削除(完全移行後)
Enter fullscreen mode Exit fullscreen mode

このパターンの核心は デプロイ順序と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);
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

フェーズ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 ?? '';
}
Enter fullscreen mode Exit fullscreen mode

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';
Enter fullscreen mode Exit fullscreen mode

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.');
}
Enter fullscreen mode Exit fullscreen mode

まとめ

  • Expand-Contract の4フェーズ: NULL許容追加 → バッチバックフィル → 両書き → 旧カラム削除の順序を守ることでダウンタイムをゼロにできる
  • カーソルバックフィル + 50ms スリープ: バッチサイズ500行・50ms間隔で本番 DB への負荷を最小化する
  • CREATE INDEX CONCURRENTLY: 本番インデックス追加は必ず CONCURRENTLY を使い、テーブルロックを回避する
  • Contract 前バージョンチェック: 旧コードが完全に退役してから旧カラムを削除する仕組みを自動化し、ヒューマンエラーを防ぐ

Code Review Pack ¥980

セキュリティ・パフォーマンス・可読性を一括レビューするプロンプトパック。Claude Code / Codex CLI 対応。

👉 Code Review Pack を見る(PromptWorks)

Top comments (0)