データベースマイグレーション戦略|安全にスキーマ変更するための実践ガイド

kento_morota 19分で読めます

「本番環境のテーブルにカラムを追加したいけれど、サービスを止めずにできるのか?」「マイグレーションを実行したら戻せなくなった」「チーム開発でマイグレーションの競合が起きる」――データベースのスキーマ変更は、アプリケーション開発で避けて通れない作業ですが、失敗すると深刻な障害につながります。

本記事では、データベースマイグレーションの基本概念から、安全に実行するための戦略、ゼロダウンタイムマイグレーションの手法、主要なツールの使い方まで、実践的なガイドとして解説します。

データベースマイグレーションの基本

データベースマイグレーションとは、データベースのスキーマ(テーブル構造、カラム定義、インデックスなど)をバージョン管理しながら段階的に変更していくプロセスです。

なぜマイグレーションが必要なのか

アプリケーションの機能追加や改善に伴い、データベースのスキーマも変化します。手動でSQL文を実行する方法では、以下の問題が発生します。

  • 再現性がない:誰がいつ何を変更したか追跡できない
  • 環境差異:開発環境とステージング環境、本番環境でスキーマが異なる
  • チーム開発:複数の開発者が同時にスキーマを変更する際に競合が起きる
  • ロールバック困難:問題が起きた際に元の状態に戻せない

マイグレーションツールを使えば、スキーマの変更をコードとしてバージョン管理し、どの環境でも同じ手順で適用・ロールバックできます。

マイグレーションの基本的な流れ

# 一般的なマイグレーションの流れ
1. マイグレーションファイルを作成(up/down の SQL を記述)
2. ローカル環境で適用してテスト
3. コードレビュー(PR に含める)
4. ステージング環境で適用してテスト
5. 本番環境で適用
6. 問題があればロールバック(down を実行)

マイグレーションツールの比較と使い方

主要なマイグレーションツールを紹介します。プロジェクトの技術スタックに合わせて選択しましょう。

Prisma Migrate(TypeScript/JavaScript)

// prisma/schema.prisma
datasource db {
  provider = "postgresql"
  url      = env("DATABASE_URL")
}

generator client {
  provider = "prisma-client-js"
}

model User {
  id        Int      @id @default(autoincrement())
  email     String   @unique
  name      String
  posts     Post[]
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

model Post {
  id          Int      @id @default(autoincrement())
  title       String
  content     String?
  published   Boolean  @default(false)
  author      User     @relation(fields: [authorId], references: [id])
  authorId    Int
  createdAt   DateTime @default(now())
  updatedAt   DateTime @updatedAt
}
# マイグレーションの作成と適用
npx prisma migrate dev --name add_user_and_post_tables

# 本番環境への適用
npx prisma migrate deploy

# マイグレーション状態の確認
npx prisma migrate status

# スキーマのリセット(開発環境のみ!)
npx prisma migrate reset

Drizzle Kit(TypeScript)

// src/db/schema.ts
import { pgTable, serial, varchar, text, boolean, timestamp, integer } from 'drizzle-orm/pg-core';

export const users = pgTable('users', {
  id: serial('id').primaryKey(),
  email: varchar('email', { length: 255 }).unique().notNull(),
  name: varchar('name', { length: 100 }).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});

export const posts = pgTable('posts', {
  id: serial('id').primaryKey(),
  title: varchar('title', { length: 255 }).notNull(),
  content: text('content'),
  published: boolean('published').default(false),
  authorId: integer('author_id').references(() => users.id).notNull(),
  createdAt: timestamp('created_at').defaultNow(),
  updatedAt: timestamp('updated_at').defaultNow(),
});
# マイグレーションファイルの生成
npx drizzle-kit generate

# マイグレーションの適用
npx drizzle-kit migrate

# スキーマの差分確認
npx drizzle-kit check

golang-migrate(Go / 汎用CLIツール)

言語に依存しない汎用的なマイグレーションツールで、生SQLファイルを管理します。

# インストール
brew install golang-migrate

# マイグレーションファイルの作成
migrate create -ext sql -dir db/migrations -seq create_users_table
-- db/migrations/000001_create_users_table.up.sql
CREATE TABLE users (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,
    name VARCHAR(100) NOT NULL,
    created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX idx_users_email ON users(email);
-- db/migrations/000001_create_users_table.down.sql
DROP TABLE IF EXISTS users;
# マイグレーションの適用
migrate -path db/migrations -database "postgresql://user:pass@localhost:5432/mydb?sslmode=disable" up

# 1つ戻す
migrate -path db/migrations -database "..." down 1

# 特定バージョンまで適用
migrate -path db/migrations -database "..." goto 3

# 現在のバージョン確認
migrate -path db/migrations -database "..." version

安全なマイグレーションの原則

本番環境でマイグレーションを安全に実行するために、守るべき原則があります。

原則1:前方互換性を保つ

マイグレーションとアプリケーションのデプロイは同時に行われるとは限りません。古いコードと新しいスキーマ、新しいコードと古いスキーマ、どちらでも動作するように設計しましょう。

-- 悪い例:カラム名の変更を1ステップで行う
ALTER TABLE users RENAME COLUMN name TO display_name;
-- → 古いコードが「name」カラムを参照してエラーになる

-- 良い例:3ステップに分ける
-- Step 1: 新カラムを追加
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);
UPDATE users SET display_name = name;

-- Step 2: アプリケーションを更新(両方のカラムに書き込む)
-- ... デプロイ ...

-- Step 3: 古いカラムを削除(すべてのサーバーが新コードに切り替わった後)
ALTER TABLE users DROP COLUMN name;

原則2:小さな変更を頻繁に

大きなマイグレーションは失敗リスクが高く、ロールバックも困難です。変更を小さく分割しましょう。

-- 悪い例:1つのマイグレーションに全部詰め込む
-- migration_20260327_big_refactor.sql
CREATE TABLE new_users (...);
INSERT INTO new_users SELECT ... FROM old_users;
DROP TABLE old_users;
ALTER TABLE new_users RENAME TO users;
CREATE TABLE orders (...);
CREATE TABLE products (...);
-- ... 数百行のSQL

-- 良い例:小さなマイグレーションに分割
-- 001_create_new_users.sql
-- 002_migrate_user_data.sql
-- 003_create_orders.sql
-- 004_create_products.sql
-- 005_drop_old_users.sql

原則3:必ずロールバック手順を用意する

-- up.sql
ALTER TABLE products ADD COLUMN sku VARCHAR(50);
CREATE UNIQUE INDEX idx_products_sku ON products(sku);

-- down.sql(ロールバック)
DROP INDEX IF EXISTS idx_products_sku;
ALTER TABLE products DROP COLUMN IF EXISTS sku;

ただし、データの削除を伴うマイグレーションは完全なロールバックが不可能です。カラムやテーブルの削除は慎重に行い、事前にバックアップを取得しましょう。

ゼロダウンタイムマイグレーションの手法

サービスを停止せずにスキーマ変更を行う手法を解説します。

カラムの追加(安全)

-- PostgreSQLでのカラム追加はロックが軽い(AccessExclusiveLockは一瞬)
ALTER TABLE users ADD COLUMN phone VARCHAR(20);

-- デフォルト値を持つカラムの追加
-- PostgreSQL 11以降は即座に完了(テーブルの書き換えが発生しない)
ALTER TABLE users ADD COLUMN is_verified BOOLEAN DEFAULT false;

NOT NULL制約の安全な追加

-- 悪い例:直接NOT NULLを追加(テーブル全体のスキャンが発生)
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;

-- 良い例:段階的にNOT NULLを追加
-- Step 1: CHECK制約をNOT VALIDで追加(即座に完了、既存行はチェックしない)
ALTER TABLE users ADD CONSTRAINT users_phone_not_null
  CHECK (phone IS NOT NULL) NOT VALID;

-- Step 2: 既存のNULLデータを埋める
UPDATE users SET phone = '未設定' WHERE phone IS NULL;

-- Step 3: 制約をVALIDATEする(Share Update Exclusive Lock、読み書きは可能)
ALTER TABLE users VALIDATE CONSTRAINT users_phone_not_null;

-- Step 4: NOT NULL制約を追加しCHECK制約を削除
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_phone_not_null;

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

-- 悪い例:通常のCREATE INDEX(テーブルへの書き込みがブロックされる)
CREATE INDEX idx_posts_title ON posts(title);

-- 良い例:CONCURRENTLYオプション(書き込みをブロックしない)
CREATE INDEX CONCURRENTLY idx_posts_title ON posts(title);

-- CONCURRENTLYの注意点
-- ・トランザクション内では使用できない
-- ・失敗した場合はINVALIDなインデックスが残る → 手動で削除が必要
-- ・通常のCREATE INDEXより時間がかかる

テーブル名・カラム名の変更(Expand/Contract パターン)

-- Expand/Contractパターンでカラム名を安全に変更する

-- Phase 1: Expand(拡張)
-- 新しいカラムを追加し、トリガーで同期
ALTER TABLE users ADD COLUMN display_name VARCHAR(100);

CREATE OR REPLACE FUNCTION sync_user_name()
RETURNS TRIGGER AS $$
BEGIN
  IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN
    NEW.display_name = COALESCE(NEW.display_name, NEW.name);
    NEW.name = COALESCE(NEW.name, NEW.display_name);
  END IF;
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

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

-- 既存データの同期
UPDATE users SET display_name = name WHERE display_name IS NULL;

-- Phase 2: Migrate(移行)
-- アプリケーションを新しいカラム名に切り替え

-- Phase 3: Contract(収縮)
-- 古いカラムとトリガーを削除
DROP TRIGGER trg_sync_user_name ON users;
DROP FUNCTION sync_user_name;
ALTER TABLE users DROP COLUMN name;

大量データの安全なマイグレーション

数百万行以上のテーブルでデータ変換を行う場合、一括処理はロックやディスクI/Oの問題を引き起こします。

バッチ処理でのデータ移行

// Node.jsでのバッチ処理例
async function migrateData() {
  const BATCH_SIZE = 1000;
  let lastId = 0;
  let processedCount = 0;

  while (true) {
    const rows = await db.query(`
      SELECT id, name
      FROM users
      WHERE id > $1 AND display_name IS NULL
      ORDER BY id
      LIMIT $2
    `, [lastId, BATCH_SIZE]);

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

    // バッチ単位でUPDATE
    for (const row of rows) {
      await db.query(`
        UPDATE users
        SET display_name = $1
        WHERE id = $2
      `, [row.name, row.id]);
    }

    lastId = rows[rows.length - 1].id;
    processedCount += rows.length;
    console.log(`Processed ${processedCount} rows (last id: ${lastId})`);

    // 負荷を抑えるため少し待機
    await new Promise(r => setTimeout(r, 100));
  }

  console.log(`Migration complete: ${processedCount} rows processed`);
}

PostgreSQLのバッチUPDATE

-- CTEを使ったバッチUPDATE(1000件ずつ処理)
WITH batch AS (
  SELECT id
  FROM users
  WHERE display_name IS NULL
  ORDER BY id
  LIMIT 1000
  FOR UPDATE SKIP LOCKED  -- ロック競合を回避
)
UPDATE users
SET display_name = name
WHERE id IN (SELECT id FROM batch);

-- このクエリを影響行数が0になるまで繰り返す

マイグレーションのベストプラクティス

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

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    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 test

      - name: Run migrations on production
        run: |
          npx prisma migrate deploy
        env:
          DATABASE_URL: ${{ secrets.PRODUCTION_DATABASE_URL }}

      - name: Deploy application
        run: |
          # アプリケーションのデプロイ

マイグレーション前のチェックリスト

  • バックアップの取得:マイグレーション前に必ずバックアップを取る
  • ステージング環境でのテスト:本番と同じデータ量でテスト
  • ロールバック手順の確認:down マイグレーションが正しく動作するか確認
  • 実行時間の見積もり:大きなテーブルの場合、ALTERの所要時間を事前に把握
  • ロックの確認:どのレベルのロックが取得されるか理解しておく
  • アプリケーションの互換性:古いバージョンのコードでも動作するか確認
-- バックアップの取得(pg_dump)
pg_dump -h localhost -U postgres -d mydb -F c -f backup_20260327.dump

-- リストア(問題が発生した場合)
pg_restore -h localhost -U postgres -d mydb backup_20260327.dump

マイグレーションファイルの命名規則

# タイムスタンプベース(推奨:チーム開発での競合が少ない)
20260327120000_create_users_table.sql
20260327120100_add_email_to_users.sql
20260327120200_create_posts_table.sql

# シーケンス番号ベース
001_create_users_table.sql
002_add_email_to_users.sql
003_create_posts_table.sql

まとめ

データベースマイグレーションを安全に行うためのポイントを整理します。

  • マイグレーションツールを使う:手動SQL実行ではなく、ツールでバージョン管理する
  • 前方互換性を保つ:古いコードと新しいスキーマが共存できるように設計する
  • 小さく分割する:大きなマイグレーションを小さなステップに分ける
  • ロールバック手順を用意する:down マイグレーションを必ず書く
  • ゼロダウンタイム:CONCURRENTLY、NOT VALID、Expand/Contractパターンを活用
  • 大量データは段階的に:バッチ処理で負荷を分散する
  • CI/CDに組み込む:自動化してヒューマンエラーを防ぐ

データベースマイグレーションは「やり直しが効かない」作業だからこそ、慎重な計画と事前テストが重要です。本記事で紹介した原則とテクニックを活用し、安全なスキーマ変更を実践してください。

#マイグレーション#データベース#運用
共有:
無料メルマガ

週1回、最新の技術記事をお届け

AI・クラウド・開発の最新記事を毎週月曜にメールでお届けします。登録は無料、いつでも解除できます。

プライバシーポリシーに基づき管理します

AI活用のヒントをお探しですか?お気軽にご相談ください。

まずは話だけ聞いてもらう