データベースマイグレーション戦略 - 安全なスキーマ変更

15分 で読める | 2025.01.10

マイグレーションの基本原則

  1. 後方互換性を保つ - 新旧アプリが共存できる
  2. 段階的に実行 - 大きな変更を小さく分割
  3. ロールバック可能 - 常に戻れる状態を維持
  4. テスト必須 - 本番相当のデータで検証

マイグレーションツール

Prisma

// schema.prisma
model User {
  id        String   @id @default(cuid())
  email     String   @unique
  name      String?
  createdAt DateTime @default(now())
}

// マイグレーション作成
// npx prisma migrate dev --name add_user_table

// 生成されるSQL
// prisma/migrations/20250110_add_user_table/migration.sql

Drizzle

// drizzle.config.ts
export default defineConfig({
  schema: './src/db/schema.ts',
  out: './drizzle',
  dialect: 'postgresql',
});

// スキーマ定義
export const users = pgTable('users', {
  id: uuid('id').primaryKey().defaultRandom(),
  email: varchar('email', { length: 255 }).unique().notNull(),
  name: varchar('name', { length: 100 }),
});

// npx drizzle-kit generate
// npx drizzle-kit migrate

危険な操作と安全な代替手法

カラム名の変更

-- 危険: 直接リネーム(ダウンタイム発生)
ALTER TABLE users RENAME COLUMN name TO full_name;

-- 安全: 3段階で実行
-- Step 1: 新カラム追加
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);

-- Step 2: データコピー + トリガー設定
UPDATE users SET full_name = name WHERE full_name IS NULL;

CREATE OR REPLACE FUNCTION sync_name_columns()
RETURNS TRIGGER AS $$
BEGIN
  NEW.full_name := NEW.name;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

CREATE TRIGGER sync_names
BEFORE INSERT OR UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION sync_name_columns();

-- Step 3: アプリを新カラムに移行後、旧カラム削除
DROP TRIGGER sync_names ON users;
ALTER TABLE users DROP COLUMN name;

NOT NULL制約の追加

-- 危険: 直接追加(大量データでロック)
ALTER TABLE users ALTER COLUMN name SET NOT NULL;

-- 安全: 段階的に実行
-- Step 1: 既存NULLをデフォルト値で埋める
UPDATE users SET name = 'Unknown' WHERE name IS NULL;

-- Step 2: CHECK制約を追加(NOT VALIDで高速)
ALTER TABLE users ADD CONSTRAINT name_not_null
  CHECK (name IS NOT NULL) NOT VALID;

-- Step 3: 制約を検証(バックグラウンド)
ALTER TABLE users VALIDATE CONSTRAINT name_not_null;

-- Step 4: NOT NULLに変換
ALTER TABLE users ALTER COLUMN name SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT name_not_null;

大規模データ移行

// バッチ処理で移行
async function migrateInBatches(batchSize = 1000) {
  let lastId = '';
  let processedCount = 0;

  while (true) {
    const rows = await db.query(`
      SELECT * FROM old_table
      WHERE id > $1
      ORDER BY id
      LIMIT $2
    `, [lastId, batchSize]);

    if (rows.length === 0) break;

    await db.query(`
      INSERT INTO new_table (id, data, migrated_at)
      SELECT id, transform_data(data), NOW()
      FROM unnest($1::uuid[], $2::jsonb[])
      AS t(id, data)
      ON CONFLICT (id) DO NOTHING
    `, [rows.map(r => r.id), rows.map(r => r.data)]);

    lastId = rows[rows.length - 1].id;
    processedCount += rows.length;

    console.log(`Migrated ${processedCount} rows`);

    // レート制限
    await sleep(100);
  }
}

ロールバック戦略

// マイグレーションファイル構造
interface Migration {
  version: string;
  up: () => Promise<void>;
  down: () => Promise<void>;  // ロールバック必須
}

const migration: Migration = {
  version: '20250110_add_status_column',

  async up() {
    await db.query(`
      ALTER TABLE orders ADD COLUMN status VARCHAR(20) DEFAULT 'pending'
    `);
  },

  async down() {
    await db.query(`
      ALTER TABLE orders DROP COLUMN status
    `);
  },
};

// ロールバック実行
async function rollback(targetVersion: string) {
  const currentVersion = await getCurrentVersion();
  const migrationsToRollback = getMigrationsBetween(currentVersion, targetVersion);

  for (const migration of migrationsToRollback.reverse()) {
    console.log(`Rolling back: ${migration.version}`);
    await migration.down();
    await updateVersion(migration.version);
  }
}

Blue/Greenマイグレーション

sequenceDiagram
    participant Old as 旧DB
    participant New as 新DB
    participant App as アプリ

    Note over Old,New: Phase 1: 準備
    New->>New: スキーマ作成

    Note over Old,New: Phase 2: 同期
    Old->>New: 初期データコピー
    Old->>New: CDC (変更データキャプチャ)

    Note over Old,New: Phase 3: 切り替え
    App->>App: 書き込み停止
    Old->>New: 最終同期
    App->>New: 接続先変更
    App->>App: 書き込み再開

    Note over Old,New: Phase 4: 検証後、旧DB廃止

CI/CDでのマイグレーション

# GitHub Actions
name: Database Migration

on:
  push:
    branches: [main]
    paths:
      - 'prisma/migrations/**'

jobs:
  migrate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run migrations on staging
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.STAGING_DATABASE_URL }}

      - name: Run integration tests
        run: npm run test:integration

      - name: Run migrations on production
        if: success()
        run: npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.PROD_DATABASE_URL }}

関連記事

まとめ

安全なマイグレーションは、後方互換性、段階的実行、ロールバック可能性が鍵です。大規模変更はバッチ処理で、本番適用前には必ず本番相当データでテストしましょう。

← 一覧に戻る