ストーリー
「24時間365日稼働しているサービスで、メンテナンスウィンドウなしにスキーマを変更できるか?」
高橋アーキテクトの問いに、あなたは考え込む。
「できる。ただし、慎重に段階を踏む必要がある。ゼロダウンタイムマイグレーションは、サービスを止めずにスキーマを安全に変更する技術だ。Expand-Contract パターンを覚えれば、恐れることはない」
なぜゼロダウンタイムが必要か
| 従来のアプローチ | 問題 |
|---|---|
| メンテナンスウィンドウ | サービス停止 → ユーザー離脱 |
| 深夜のメンテナンス | グローバルサービスでは「深夜」がない |
| ビッグバンリリース | 失敗時のリスクが大きい |
Expand-Contract パターン
スキーマ変更を3段階に分けて安全に実行する。
graph TD
P1["Phase 1: Expand(拡張)<br/>新しい構造を追加(旧構造はそのまま)"]
P2["Phase 2: Migrate(移行)<br/>データを新構造に移行、アプリを新構造に切り替え"]
P3["Phase 3: Contract(縮小)<br/>旧構造を削除"]
P1 --> P2 --> P3
例1: カラム名の変更
users.name → users.full_name に変更する場合。
NG: 直接RENAME
-- サービスが止まる: アプリがまだ "name" を参照している
ALTER TABLE users RENAME COLUMN name TO full_name;
OK: Expand-Contract
-- Phase 1: Expand(新カラム追加)
ALTER TABLE users ADD COLUMN full_name VARCHAR(100);
-- Phase 2: Migrate(データコピー + アプリ更新)
UPDATE users SET full_name = name WHERE full_name IS NULL;
-- アプリを更新: name と full_name の両方に書き込む(Dual Write)
-- 十分な期間、両方のカラムを使う
-- Phase 3: Contract(旧カラム削除)
ALTER TABLE users DROP COLUMN name;
TypeScript: Dual Write フェーズ
// Phase 2: 両方のカラムに書き込む
async function updateUserName(userId: number, newName: string): Promise<void> {
await prisma.$executeRaw`
UPDATE users SET name = ${newName}, full_name = ${newName}
WHERE id = ${userId}
`;
}
// Phase 2 後半: 新カラムから読む
async function getUserName(userId: number): Promise<string> {
const user = await prisma.user.findUnique({ where: { id: userId } });
return user.fullName ?? user.name; // full_name優先、なければname
}
// Phase 3: 新カラムのみ使用
async function getUserName(userId: number): Promise<string> {
const user = await prisma.user.findUnique({ where: { id: userId } });
return user.fullName;
}
例2: NOT NULL カラムの追加
-- NG: NOT NULL + DEFAULT なしで追加
ALTER TABLE users ADD COLUMN phone VARCHAR(20) NOT NULL;
-- ERROR: column "phone" contains null values
-- OK: 段階的に追加
-- Phase 1: NULLable で追加
ALTER TABLE users ADD COLUMN phone VARCHAR(20);
-- Phase 2: 既存データにデフォルト値を設定
UPDATE users SET phone = '' WHERE phone IS NULL;
-- Phase 3: NOT NULL 制約を追加
ALTER TABLE users ALTER COLUMN phone SET NOT NULL;
ALTER TABLE users ALTER COLUMN phone SET DEFAULT '';
例3: テーブルの分割
users テーブルから user_profiles を分離する。
-- Phase 1: Expand(新テーブル作成)
CREATE TABLE user_profiles (
user_id INT PRIMARY KEY REFERENCES users(id),
bio TEXT,
avatar_url VARCHAR(500),
website VARCHAR(500)
);
-- Phase 2: Migrate(データコピー)
INSERT INTO user_profiles (user_id, bio, avatar_url, website)
SELECT id, bio, avatar_url, website FROM users;
-- アプリ更新: 両方から読み書き(Dual Read/Write)
-- 新テーブル優先で読む
-- Phase 3: Contract(旧カラム削除)
ALTER TABLE users DROP COLUMN bio;
ALTER TABLE users DROP COLUMN avatar_url;
ALTER TABLE users DROP COLUMN website;
大量データの安全な更新
大テーブルを一括UPDATEすると長時間ロックがかかる。バッチ処理で分割する。
// バッチでデータマイグレーション
async function migrateInBatches(
batchSize: number = 1000,
delayMs: number = 100
): Promise<void> {
let totalMigrated = 0;
while (true) {
const result = await prisma.$executeRaw`
UPDATE users
SET full_name = name
WHERE full_name IS NULL
AND id IN (
SELECT id FROM users
WHERE full_name IS NULL
ORDER BY id
LIMIT ${batchSize}
)
`;
totalMigrated += result;
console.log(`Migrated ${totalMigrated} rows`);
if (result < batchSize) {
break; // すべて完了
}
// DBに負荷を与えすぎないよう一定間隔を空ける
await new Promise(resolve => setTimeout(resolve, delayMs));
}
console.log(`Migration complete: ${totalMigrated} total rows`);
}
インデックスの安全な作成
-- NG: 通常のCREATE INDEX(テーブルロック)
CREATE INDEX idx_orders_status ON orders(status);
-- → 大テーブルでは数分~数十分のロック
-- OK: CONCURRENTLY(PostgreSQL)
CREATE INDEX CONCURRENTLY idx_orders_status ON orders(status);
-- → ロックなし、ただし時間はかかる(書き込みをブロックしない)
危険な操作チェックリスト
| 操作 | リスク | 安全な方法 |
|---|---|---|
| DROP COLUMN | データ消失 | Expand-Contractで段階的に |
| RENAME COLUMN | アプリとの不整合 | 新カラム追加 → Dual Write → 旧削除 |
| ALTER TYPE | テーブルロック | 新カラム追加 → データコピー → 旧削除 |
| ADD NOT NULL | 既存NULL値でエラー | NULLable追加 → データ埋め → NOT NULL化 |
| CREATE INDEX | テーブルロック | CREATE INDEX CONCURRENTLY |
| DROP TABLE | データ消失 | 事前バックアップ + リネーム保持 |
デプロイ戦略との連携
graph TD
A["マイグレーション Phase 1<br/>Expand"]
B["アプリ デプロイ v2<br/>新旧両対応"]
C["マイグレーション Phase 2<br/>Data Migration"]
D["動作確認・検証期間"]
E["アプリ デプロイ v3<br/>新のみ使用"]
F["マイグレーション Phase 3<br/>Contract"]
A --> B --> C --> D --> E --> F
まとめ
| ポイント | 内容 |
|---|---|
| Expand-Contract | 拡張 → 移行 → 縮小の3段階 |
| Dual Write | 移行期間は新旧両方に書き込む |
| バッチ処理 | 大量データは分割して更新 |
| CONCURRENTLY | インデックスはロックなしで作成 |
| デプロイ連携 | マイグレーションとアプリデプロイを段階的に |
理解度チェックリスト
- Expand-Contract パターンの3段階を説明できる
- カラム名変更をゼロダウンタイムで行う手順を設計できる
- 大量データの安全な更新をバッチ処理で実装できる
- CREATE INDEX CONCURRENTLY の必要性を理解している
次のステップ
次のレッスンではバックアップとリストア戦略を学ぶ。マイグレーション失敗時の保険として、確実なバックアップ体制を構築しよう。
推定読了時間: 30分