データベースマイグレーションパターン
プロダクションシステムのための安全で可逆的なデータベーススキーマ変更。
アクティベーション条件
- データベーステーブルの作成または変更
- カラムやインデックスの追加/削除
- データマイグレーション(バックフィル、変換)の実行
- ダウンタイムゼロでのスキーマ変更の計画
- 新しいプロジェクトのマイグレーションツール設定
コア原則
- すべての変更はマイグレーションとして扱う — プロダクションデータベースを手動で変更しない
- マイグレーションはプロダクションでは前方のみに進む — ロールバックには新しい前方マイグレーションを使用する
- スキーマとデータのマイグレーションは分離する — 1つのマイグレーションでDDLとDMLを混在させない
- マイグレーションはプロダクション相当のデータ量でテストする — 100行で動作するマイグレーションが1000万行でロックする可能性がある
- マイグレーションは一度デプロイしたら不変 — プロダクションで実行されたマイグレーションを編集しない
マイグレーション安全チェックリスト
マイグレーションを適用する前に以下を確認:
- マイグレーションにUPとDOWNの両方がある(または明示的に不可逆とマークされている)
- 大規模テーブルに対するフルテーブルロックがない(同時実行操作を使用)
- 新しいカラムにはデフォルト値があるかNULL許容である(NOT NULLカラムをデフォルトなしで追加しない)
- インデックスは既存テーブルのCREATE TABLEインラインではなく、同時に作成する
- データバックフィルはスキーマ変更とは別のマイグレーションにする
- プロダクションデータのコピーでテスト済み
- ロールバック計画が文書化されている
PostgreSQLパターン
カラムの安全な追加
-- 良い例:NULL許容カラム、ロックなし
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- 良い例:デフォルト値ありのカラム(PostgreSQL 11+では即時、再書き込みなし)
ALTER TABLE users ADD COLUMN is_active BOOLEAN NOT NULL DEFAULT true;
-- 悪い例:既存テーブルにNOT NULLカラムをデフォルトなしで追加(フル再書き込みが必要)
ALTER TABLE users ADD COLUMN role TEXT NOT NULL;
-- テーブルをロックし、すべての行を再書き込みする
ダウンタイムなしでのインデックス追加
-- 悪い例:大規模テーブルで書き込みをブロックする
CREATE INDEX idx_users_email ON users (email);
-- 良い例:非ブロッキング、同時書き込みを許可
CREATE INDEX CONCURRENTLY idx_users_email ON users (email);
-- 注意:CONCURRENTLYはトランザクションブロック内では実行できない
-- ほとんどのマイグレーションツールでは特別な処理が必要
カラムのリネーム(ダウンタイムゼロ)
プロダクションで直接リネームしてはいけません。expand-contractパターンを使用します:
-- Step 1: 新しいカラムを追加(マイグレーション001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- Step 2: データをバックフィル(マイグレーション002、データマイグレーション)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- Step 3: アプリケーションコードを両方のカラムを読み書きするように更新
-- アプリケーション変更をデプロイ
-- Step 4: 古いカラムへの書き込みを停止し、削除(マイグレーション003)
ALTER TABLE users DROP COLUMN username;
カラムの安全な削除
-- Step 1: アプリケーション内のカラムへの参照をすべて削除
-- Step 2: カラム参照なしでアプリケーションをデプロイ
-- Step 3: 次のマイグレーションでカラムを削除
ALTER TABLE orders DROP COLUMN legacy_status;
-- Djangoの場合:SeparateDatabaseAndStateを使用してモデルから削除し、
-- DROP COLUMNを生成させず、次のマイグレーションで削除する
大規模データマイグレーション
-- 悪い例:1つのトランザクションですべての行を更新(テーブルをロック)
UPDATE users SET normalized_email = LOWER(email);
-- 良い例:進捗状況を確認しながらバッチ更新
DO $$
DECLARE
batch_size INT := 10000;
rows_updated INT;
BEGIN
LOOP
UPDATE users
SET normalized_email = LOWER(email)
WHERE id IN (
SELECT id FROM users
WHERE normalized_email IS NULL
LIMIT batch_size
FOR UPDATE SKIP LOCKED
);
GET DIAGNOSTICS rows_updated = ROW_COUNT;
RAISE NOTICE 'Updated % rows', rows_updated;
EXIT WHEN rows_updated = 0;
COMMIT;
END LOOP;
END $$;
Prisma (TypeScript/Node.js)
ワークフロー
# スキーマ変更からマイグレーションを作成
npx prisma migrate dev --name add_user_avatar
# 保留中のマイグレーションをプロダクションに適用
npx prisma migrate deploy
# データベースをリセット(開発環境のみ)
npx prisma migrate reset
# スキーマ変更後にクライアントを生成
npx prisma generate
スキーマ例
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String? @map("avatar_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
orders Order[]
@@map("users")
@@index([email])
}
カスタムSQLマイグレーション
Prismaが表現できない操作(同時インデックス、データバックフィルなど)に対応:
# 空のマイグレーションを作成し、SQLを手動で編集
npx prisma migrate dev --create-only --name add_email_index
-- migrations/20240115_add_email_index/migration.sql
-- PrismaはCONCURRENTLYを生成できないため、手動で記述
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email ON users (email);
Drizzle (TypeScript/Node.js)
ワークフロー
# スキーマ変更からマイグレーションを生成
npx drizzle-kit generate
# マイグレーションを適用
npx drizzle-kit migrate
# スキーマを直接プッシュ(開発環境のみ、マイグレーションファイルなし)
npx drizzle-kit push
スキーマ例
import { pgTable, text, timestamp, uuid, boolean } from "drizzle-orm/pg-core";
export const users = pgTable("users", {
id: uuid("id").primaryKey().defaultRandom(),
email: text("email").notNull().unique(),
name: text("name"),
isActive: boolean("is_active").notNull().default(true),
createdAt: timestamp("created_at").notNull().defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
});
Kysely (TypeScript/Node.js)
ワークフロー(kysely-ctl)
# 設定ファイルを初期化(kysely.config.ts)
kysely init
# 新しいマイグレーションファイルを作成
kysely migrate make add_user_avatar
# 保留中のマイグレーションをすべて適用
kysely migrate latest
# 最後のマイグレーションをロールバック
kysely migrate down
# マイグレーションの状態を表示
kysely migrate list
マイグレーションファイル
// migrations/2024_01_15_001_create_user_profile.ts
import { type Kysely, sql } from 'kysely'
// 重要:常にKysely<any>を使用し、型付きDBインターフェースは使用しないこと。
// マイグレーションは時間的に固定され、現在のスキーマ型に依存してはいけません。
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_profile')
.addColumn('id', 'serial', (col) => col.primaryKey())
.addColumn('email', 'varchar(255)', (col) => col.notNull().unique())
.addColumn('avatar_url', 'text')
.addColumn('created_at', 'timestamp', (col) =>
col.defaultTo(sql`now()`).notNull()
)
.execute()
await db.schema
.createIndex('idx_user_profile_avatar')
.on('user_profile')
.column('avatar_url')
.execute()
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_profile').execute()
}
プログラムによるマイグレーター
import { Migrator, FileMigrationProvider } from 'kysely'
import { promises as fs } from 'fs'
import * as path from 'path'
// ESMのみ — CJSは__dirnameを直接使用可能
import { fileURLToPath } from 'url'
const migrationFolder = path.join(
path.dirname(fileURLToPath(import.meta.url)),
'./migrations',
)
// `db` はKysely<any>データベースインスタンス
const migrator = new Migrator({
db,
provider: new FileMigrationProvider({
fs,
path,
migrationFolder,
}),
// 警告:開発環境でのみ有効にしてください。タイムスタンプ順序の
// バリデーションを無効にし、環境間でスキーマのずれを引き起こす可能性があります。
// allowUnorderedMigrations: true,
})
const { error, results } = await migrator.migrateToLatest()
results?.forEach((it) => {
if (it.status === 'Success') {
console.log(`migration "${it.migrationName}" executed successfully`)
} else if (it.status === 'Error') {
console.error(`failed to execute migration "${it.migrationName}"`)
}
})
if (error) {
console.error('migration failed', error)
process.exit(1)
}
Django (Python)
ワークフロー
# モデル変更からマイグレーションを生成
python manage.py makemigrations
# マイグレーションを適用
python manage.py migrate
# マイグレーションの状態を表示
python manage.py showmigrations
# カスタムSQL用の空のマイグレーションを生成
python manage.py makemigrations --empty app_name -n description
データマイグレーション
from django.db import migrations
def backfill_display_names(apps, schema_editor):
User = apps.get_model("accounts", "User")
batch_size = 5000
users = User.objects.filter(display_name="")
while users.exists():
batch = list(users[:batch_size])
for user in batch:
user.display_name = user.username
User.objects.bulk_update(batch, ["display_name"], batch_size=batch_size)
def reverse_backfill(apps, schema_editor):
pass # データマイグレーション、逆方向は不要
class Migration(migrations.Migration):
dependencies = [("accounts", "0015_add_display_name")]
operations = [
migrations.RunPython(backfill_display_names, reverse_backfill),
]
SeparateDatabaseAndState
データベースからすぐに削除せずに、Djangoモデルからカラムを削除する:
class Migration(migrations.Migration):
operations = [
migrations.SeparateDatabaseAndState(
state_operations=[
migrations.RemoveField(model_name="user", name="legacy_field"),
],
database_operations=[], # まだDBに触らない
),
]
golang-migrate (Go)
ワークフロー
# マイグレーションペアを作成
migrate create -ext sql -dir migrations -seq add_user_avatar
# 保留中のマイグレーションをすべて適用
migrate -path migrations -database "$DATABASE_URL" up
# 最後のマイグレーションをロールバック
migrate -path migrations -database "$DATABASE_URL" down 1
# バージョンを強制(ダーティ状態を修正)
migrate -path migrations -database "$DATABASE_URL" force VERSION
マイグレーションファイル
-- migrations/000003_add_user_avatar.up.sql
ALTER TABLE users ADD COLUMN avatar_url TEXT;
CREATE INDEX CONCURRENTLY idx_users_avatar ON users (avatar_url) WHERE avatar_url IS NOT NULL;
-- migrations/000003_add_user_avatar.down.sql
DROP INDEX IF EXISTS idx_users_avatar;
ALTER TABLE users DROP COLUMN IF EXISTS avatar_url;
ダウンタイムゼロマイグレーション戦略
重要なプロダクション変更の場合、expand-contractパターンに従います:
フェーズ 1: EXPAND(拡張)
- 新しいカラム/テーブルを追加(NULL許容かデフォルト値あり)
- デプロイ:アプリは新旧両方に書き込む
- 既存データをバックフィル
フェーズ 2: MIGRATE(移行)
- デプロイ:アプリはNEWから読み取り、両方に書き込む
- データの一貫性を確認
フェーズ 3: CONTRACT(収束)
- デプロイ:アプリはNEWのみを使用
- 別のマイグレーションで古いカラム/テーブルを削除
タイムライン例
Day 1: 新しいステータスカラムを追加するマイグレーション(NULL許容)
Day 1: アプリv2をデプロイ — statusとnew_statusの両方に書き込む
Day 2: 既存行のバックフィルマイグレーションを実行
Day 3: アプリv3をデプロイ — new_statusからのみ読み取る
Day 7: 古いstatusカラムを削除するマイグレーション
アンチパターン
| アンチパターン | 失敗する理由 | より良い方法 |
|---|---|---|
| プロダクションでの手動SQL | 監査証跡なし、再現不可能 | 常にマイグレーションファイルを使用する |
| デプロイ済みマイグレーションの編集 | 環境間のずれを引き起こす | 代わりに新しいマイグレーションを作成する |
| デフォルトなしのNOT NULL | テーブルをロックし、全行を再書き込み | NULL許容で追加、バックフィル、次に制約を追加 |
| 大規模テーブルへのインラインインデックス | 構築中に書き込みをブロック | CREATE INDEX CONCURRENTLY |
| 1つのマイグレーションにスキーマとデータ | ロールバックが困難、長時間トランザクション | マイグレーションを分離 |
| コード削除前にカラムを削除 | カラム欠落によるアプリケーションエラー | 最初にコードを削除し、次のデプロイでカラムを削除 |