「本番環境のテーブルにカラムを追加したいけれど、サービスを止めずにできるのか?」「マイグレーションを実行したら戻せなくなった」「チーム開発でマイグレーションの競合が起きる」――データベースのスキーマ変更は、アプリケーション開発で避けて通れない作業ですが、失敗すると深刻な障害につながります。
本記事では、データベースマイグレーションの基本概念から、安全に実行するための戦略、ゼロダウンタイムマイグレーションの手法、主要なツールの使い方まで、実践的なガイドとして解説します。
データベースマイグレーションの基本
データベースマイグレーションとは、データベースのスキーマ(テーブル構造、カラム定義、インデックスなど)をバージョン管理しながら段階的に変更していくプロセスです。
なぜマイグレーションが必要なのか
アプリケーションの機能追加や改善に伴い、データベースのスキーマも変化します。手動で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に組み込む:自動化してヒューマンエラーを防ぐ
データベースマイグレーションは「やり直しが効かない」作業だからこそ、慎重な計画と事前テストが重要です。本記事で紹介した原則とテクニックを活用し、安全なスキーマ変更を実践してください。
関連記事
AIエージェント開発入門|自律型AIの仕組みと構築方法を解説【2026年版】
AI駆動コーディングワークフロー|Claude Code・Cursor・Copilotの実践的使い分け
プロンプトエンジニアリング上級編|Chain-of-Thought・Few-Shot・ReActの実践
APIレート制限の設計と実装|トークンバケット・スライディングウィンドウ解説
APIバージョニング戦略|URL・ヘッダー・クエリパラメータの使い分け
BIツール入門|Metabase・Redash・Looker Studioでデータ可視化する方法
チャットボット開発入門|LINE Bot・Slack Botの構築方法と活用事例
CI/CDパイプラインの基礎|継続的インテグレーション・デリバリーの全体像