データベースマイグレーションパターン
本番システム向けの安全で可逆的なデータベーススキーマ変更。
アクティベーションタイミング
- データベーステーブルの作成または変更
- カラムやインデックスの追加/削除
- データマイグレーション(バックフィル、変換)の実行
- ダウンタイムゼロのスキーマ変更の計画
- 新規プロジェクトのマイグレーションツール設定
コア原則
- すべての変更はマイグレーションである — 本番データベースを手動で変更しない
- マイグレーションは本番では前方のみ進行 — ロールバックは新しい前方マイグレーションを使用する
- スキーママイグレーションとデータマイグレーションは分離する — DDLとDMLを1つのマイグレーションに混在させない
- 本番規模のデータでマイグレーションをテストする — 100行で動作するマイグレーションが1000万行ではロックする可能性がある
- 一度デプロイしたマイグレーションは不変 — 本番で実行されたマイグレーションを編集しない
マイグレーション安全チェックリスト
マイグレーションを適用する前に:
- マイグレーションにUPとDOWNの両方がある(または明示的に不可逆とマークされている)
- 大規模テーブルに対するフルテーブルロックがない(同時実行操作を使用)
- 新しいカラムにはデフォルト値があるか、NULL許容である(デフォルトなしでNOT NULLを追加しない)
- インデックスは同時に作成する(既存テーブルではCREATE TABLEにインライン化しない)
- データバックフィルはスキーマ変更とは別のマイグレーションである
- 本番データのコピーに対してテスト済み
- ロールバック計画が文書化されている
PostgreSQLパターン
カラムを安全に追加する
-- 良い例: NULL許容カラム、ロックなし
ALTER TABLE users ADD COLUMN avatar_url TEXT;
-- 良い例: デフォルト値を持つカラム(Postgres 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パターンを使用します:
-- ステップ1: 新しいカラムを追加(マイグレーション001)
ALTER TABLE users ADD COLUMN display_name TEXT;
-- ステップ2: データをバックフィル(マイグレーション002、データマイグレーション)
UPDATE users SET display_name = username WHERE display_name IS NULL;
-- ステップ3: アプリケーションコードを更新して両方のカラムを読み書きする
-- アプリケーション変更をデプロイ
-- ステップ4: 古いカラムへの書き込みを停止し、削除する(マイグレーション003)
ALTER TABLE users DROP COLUMN username;
カラムを安全に削除する
-- ステップ1: カラムへのすべてのアプリケーション参照を削除
-- ステップ2: カラム参照なしでアプリケーションをデプロイ
-- ステップ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 only — CJS can use __dirname directly
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=[], # まだデータベースには触れない
),
]
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のみを使用
- 別のマイグレーションで古いカラム/テーブルを削除
タイムライン例
1日目: マイグレーションで new_status カラムを追加(NULL許容)
1日目: アプリ v2 をデプロイ — status と new_status の両方に書き込む
2日目: 既存行のバックフィルマイグレーションを実行
3日目: アプリ v3 をデプロイ — new_status からのみ読み取り
7日目: マイグレーションで古い status カラムを削除
アンチパターン
| アンチパターン | 失敗する理由 | より良いアプローチ |
|---|---|---|
| 本番での手動SQL | 監査証跡なし、再現不可 | 常にマイグレーションファイルを使用する |
| デプロイ済みマイグレーションの編集 | 環境間でずれが生じる | 代わりに新しいマイグレーションを作成する |
| デフォルトなしのNOT NULL | テーブルをロックし、全行を再書き込み | NULL許容で追加、バックフィル、その後制約を追加 |
| 大規模テーブルへのインラインインデックス | ビルド中に書き込みをブロック | CREATE INDEX CONCURRENTLY を使用 |
| スキーマとデータを1つのマイグレーションに | ロールバックが困難、長時間トランザクション | マイグレーションを分離する |
| コード削除前のカラム削除 | カラム欠落によるアプリケーションエラー | まずコードを削除し、次のデプロイでカラムを削除する |