Back

無停止PostgreSQLスキーママイグレーション:プロダクションDBを壊さずに変更する完全ガイド

深夜2時。チームが5000万行のテーブルにNOT NULLカラムを追加するマイグレーションをデプロイした。ACCESS EXCLUSIVEロックがかかって、そのテーブルへのクエリが全部止まった。APIのレスポンスタイムが50msから30秒に跳ね上がる。オンコールエンジニアの電話が鳴りやまない。顧客がエラーページのスクリーンショットをツイートしている。

ALTER TABLE ... ADD COLUMN ... NOT NULL DEFAULTがプロダクションで大惨事を引き起こすということを、最悪の形で思い知った瞬間だ。

このガイドは、その教訓を身をもって学ぶ必要がないように書いた。PostgreSQLのスキーマをアプリを落とさずに変更するあらゆるパターン、ツール、落とし穴をカバーする。理論じゃない。数億行のテーブルで実戦検証済みの戦略だ。

スキーママイグレーションが危険な理由

多くの開発者がDBマイグレーションをただのコードデプロイのように扱っている。ALTER TABLEを書いて、デプロイ時に実行して、終わり。開発環境でusersテーブルが50行なら問題ない。でもプロダクションで5000万行だと、同じマイグレーションがアプリ全体を止めてしまう。

理由はこうだ:

PostgreSQLのロックシステム

DDL文は全てロックを取得する。問題はロックの存在じゃなくて、ロックキューなんだ。DDLがACCESS EXCLUSIVEロックを要求して即座に取得できない場合(アクティブなクエリが競合するロックを持っているから)、キューで待機する。その間に、そのテーブルへの新しいクエリも全部キューの後ろに並ぶ

災害のタイムライン:

00:00  SELECTクエリが実行中(ACCESS SHAREロックを保持)
00:01  ALTER TABLEがACCESS EXCLUSIVEロックを要求 → キューで待機
00:02  新しいSELECTが到着 → ALTER TABLEの後ろに並ぶ
00:03  新しいSELECTが到着 → ALTER TABLEの後ろに並ぶ
00:04  新しいSELECTが到着 → ALTER TABLEの後ろに並ぶ
  ...  すべてのクエリがキューに。アプリがフリーズしたように見える。
00:30  元のSELECTが終了 → ALTER TABLEがロックを取得
00:31  ALTER TABLEが実行(大きなテーブルだと数分かかる)
02:00  ALTER TABLE完了 → キューに溜まったクエリがようやく実行

たった1つのALTER TABLE2分のダウンタイムが発生。トラフィックの多いテーブルだと、コネクションプール枯渇、アプリクラッシュ、システム全体の連鎖障害にまでカスケードする。

致命的な操作

すべてのスキーマ変更が同じように危険なわけじゃない。リスクマトリクスを整理する:

操作ロックタイプリスクレベル5000万行での所要時間
ADD COLUMN(nullable、デフォルトなし)ACCESS EXCLUSIVE🟢 低ミリ秒
ADD COLUMN ... DEFAULT(PG 11+)ACCESS EXCLUSIVE🟢 低ミリ秒
ADD COLUMN ... NOT NULL DEFAULT(PG 11+)ACCESS EXCLUSIVE🟡 中ミリ秒(ただしロックキューリスク)
DROP COLUMNACCESS EXCLUSIVE🟢 低ミリ秒
ALTER COLUMN TYPEACCESS EXCLUSIVE🔴 危険数分〜数時間(テーブル全体リライト)
ADD INDEXSHAREロック🔴 危険数分(書き込みブロック)
ADD INDEX CONCURRENTLYロックなし🟢 低数分(非ブロッキング)
ADD NOT NULL CONSTRAINTACCESS EXCLUSIVE🔴 危険数分(テーブル全体スキャン)
ADD FOREIGN KEYSHARE ROW EXCLUSIVE🔴 危険数分(テーブル全体スキャン)

🔴の操作が障害を引き起こすもの。それぞれを安全にする方法を見ていこう。

Expand-Contractパターン

Expand-Contractパターン(「並行変更」とも呼ばれる)は、無停止マイグレーションの基本戦略。アイデアはシンプルで、壊れる変更を1ステップで絶対にやらないこと。代わりに、壊れない複数のステップに分割する。

┌─────────────────────────────────────────────────────────────┐
│              EXPAND-CONTRACTパターン                          │
│                                                              │
│  Phase 1: EXPAND(拡張)                                    │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 古いものの横に新しいカラム/テーブルを追加          │       │
│  │ 新旧両方に書き込むコードをデプロイ                │       │
│  │ 古い読み取りコードはそのまま動作                   │       │
│  └──────────────────────────────────────────────────┘       │
│                         │                                    │
│                         ▼                                    │
│  Phase 2: MIGRATE(移行)                                   │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 既存データを新しいカラム/テーブルにバックフィル    │       │
│  │ データ整合性を検証                                 │       │
│  │ 読み取りを新しいカラム/テーブルに切り替え          │       │
│  └──────────────────────────────────────────────────┘       │
│                         │                                    │
│                         ▼                                    │
│  Phase 3: CONTRACT(縮小)                                  │
│  ┌──────────────────────────────────────────────────┐       │
│  │ 古いカラム/テーブルを削除                          │       │
│  │ 互換コードを削除                                   │       │
│  │ クリーンアップ                                     │       │
│  └──────────────────────────────────────────────────┘       │
│                                                              │
└─────────────────────────────────────────────────────────────┘

最もよくある危険なマイグレーションに適用してみよう。

例:カラム名の変更

users.full_nameusers.display_nameに変えたい。単純にALTER TABLE users RENAME COLUMN full_name TO display_nameを実行すると、full_nameを参照するすべてのクエリがその瞬間に壊れる。

Step 1: Expand: 新しいカラムを追加

-- デプロイ1: 新カラム追加(即座、リライトなし) ALTER TABLE users ADD COLUMN display_name TEXT; -- 両方のカラムを同期するトリガーを作成 CREATE OR REPLACE FUNCTION sync_display_name() RETURNS TRIGGER AS $$ BEGIN IF TG_OP = 'INSERT' OR TG_OP = 'UPDATE' THEN IF NEW.display_name IS NULL AND NEW.full_name IS NOT NULL THEN NEW.display_name := NEW.full_name; ELSIF NEW.full_name IS NULL AND NEW.display_name IS NOT NULL THEN NEW.full_name := NEW.display_name; END IF; END IF; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_display_name BEFORE INSERT OR UPDATE ON users FOR EACH ROW EXECUTE FUNCTION sync_display_name();

Step 2: Backfill: 既存データをコピー

-- 長時間トランザクションを避けるためバッチでバックフィル DO $$ DECLARE batch_size INT := 10000; rows_updated INT; BEGIN LOOP UPDATE users SET display_name = full_name WHERE id IN ( SELECT id FROM users WHERE display_name IS NULL AND full_name IS NOT NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; RAISE NOTICE 'Updated % rows', rows_updated; PERFORM pg_sleep(0.1); -- 負荷軽減のための短い休止 COMMIT; END LOOP; END $$;

Step 3: 読み取り切り替え: アプリコードがdisplay_nameから読むように変更

Step 4: Contract: 古いカラムとトリガーを削除

-- デプロイ3: すべてのコードがdisplay_nameから読んでいる状態で DROP TRIGGER trigger_sync_display_name ON users; DROP FUNCTION sync_display_name(); ALTER TABLE users DROP COLUMN full_name;

デプロイ1回の代わりに4回。でもダウンタイムはゼロ。

安全なインデックス作成

大きなテーブルにインデックスを作成するのは、プロダクション障害の最も一般的な原因の1つ。通常のCREATE INDEXはSHAREロックを取得して、インデックスビルドの全期間中すべての書き込み(INSERT、UPDATE、DELETE)をブロックする。

必ずCONCURRENTLYを使う

-- ❌ 危険:すべての書き込みをブロック CREATE INDEX idx_users_email ON users(email); -- ✅ 安全:非ブロッキング CREATE INDEX CONCURRENTLY idx_users_email ON users(email);

CREATE INDEX CONCURRENTLYは書き込みをブロックするロックなしでインデックスを構築する。テーブルを2回スキャンすることで実現している。1回目で初期インデックスを構築し、2回目で1回目のスキャン中に発生した変更をキャプチャする。

CONCURRENTLYの落とし穴

知っておくべき重要な注意点がある:

1. サイレントに失敗する可能性がある。 CREATE INDEX CONCURRENTLYがエラーに遭遇すると(例:ユニーク制約違反)、INVALIDなインデックスを残す。必ず検証すること:

-- 作成後に無効なインデックスを確認 SELECT indexname, indexdef FROM pg_indexes WHERE schemaname = 'public' AND indexname = 'idx_users_email'; -- 有効性を確認 SELECT pg_index.indisvalid FROM pg_index JOIN pg_class ON pg_index.indexrelid = pg_class.oid WHERE pg_class.relname = 'idx_users_email';

インデックスが無効なら、ドロップしてリトライ:

DROP INDEX CONCURRENTLY IF EXISTS idx_users_email; -- その後CREATE INDEX CONCURRENTLYを再実行

2. トランザクション内で実行できない。 CREATE INDEX CONCURRENTLYBEGIN...COMMITブロックで囲めない。多くのマイグレーションツールはデフォルトで各マイグレーションをトランザクションで囲むため、コンカレントインデックス作成時にはこれを無効にする必要がある。

3. 時間がかかる。 テーブルを2回スキャンして排他ロックを取得しないため、通常のCREATE INDEXの2〜3倍時間がかかる。1億行のテーブルでは30分以上になることもある。計画を立てておこう。

NOT NULL制約を安全に追加する

既存カラムにNOT NULL制約を追加するのは、一見シンプルに見えて危険。PostgreSQLがNULL値が存在しないことを確認するためにテーブル全体をスキャンする必要があり、その間ACCESS EXCLUSIVEロックを保持する。

安全なパターン

-- Step 1: NOT VALIDでCHECK制約を追加(即座、スキャンなし) ALTER TABLE users ADD CONSTRAINT users_email_not_null CHECK (email IS NOT NULL) NOT VALID; -- Step 2: 別のトランザクションで制約を検証 -- テーブルをスキャンするがSHARE UPDATE EXCLUSIVE -- ロックのみ取得(読み書き両方許可) ALTER TABLE users VALIDATE CONSTRAINT users_email_not_null; -- Step 3(オプション): 正式なNOT NULL制約に変換 -- PostgreSQL 12+で有効なCHECK制約が存在すれば -- NOT NULL追加は即座(テーブルスキャン不要) ALTER TABLE users ALTER COLUMN email SET NOT NULL; -- Step 4: 不要になったCHECK制約を削除 ALTER TABLE users DROP CONSTRAINT users_email_not_null;

なぜこれでいいのか。NOT VALIDはPostgreSQLに「新しい行はこの制約を守れ。でも既存の行はまだチェックしなくていい」と伝えるもの。次のVALIDATE CONSTRAINTステップで既存行をチェックするが、このときのロックは弱いので読み書きはブロックされない。

外部キーを安全に追加する

外部キー作成もサイレントキラー。デフォルトではALTER TABLE ... ADD CONSTRAINT ... FOREIGN KEYは参照元と参照先の両方のテーブルをロックしてスキャンする。

-- ❌ 危険:検証中に両テーブルをロック ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id); -- ✅ 安全:2ステップアプローチ -- Step 1: 検証なしで制約を追加(即座) ALTER TABLE orders ADD CONSTRAINT fk_orders_user_id FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID; -- Step 2: 別に検証(弱いロック) ALTER TABLE orders VALIDATE CONSTRAINT fk_orders_user_id;

CHECK制約と同じNOT VALIDのトリック。新しい行は即座に検証され、既存の行は別のステップでより制限の少ないロックで検証される。

カラム型の変更

カラム型を変更する(例:INTからBIGINTVARCHAR(50)からVARCHAR(255))場合、通常テーブル全体のリライトが必要になる。数百万行のテーブルでは数分のダウンタイムを意味する。

Expand-Contractアプローチ

-- Step 1: 望みの型で新カラムを追加 ALTER TABLE orders ADD COLUMN amount_v2 BIGINT; -- Step 2: 同期トリガーを作成 CREATE OR REPLACE FUNCTION sync_amount_v2() RETURNS TRIGGER AS $$ BEGIN NEW.amount_v2 := NEW.amount; RETURN NEW; END; $$ LANGUAGE plpgsql; CREATE TRIGGER trigger_sync_amount_v2 BEFORE INSERT OR UPDATE ON orders FOR EACH ROW EXECUTE FUNCTION sync_amount_v2(); -- Step 3: バックフィル(バッチ) UPDATE orders SET amount_v2 = amount WHERE id BETWEEN 1 AND 1000000; -- ... 全範囲に対して繰り返し -- Step 4: アプリコードをamount_v2に切り替え -- Step 5: クリーンアップ DROP TRIGGER trigger_sync_amount_v2 ON orders; DROP FUNCTION sync_amount_v2(); ALTER TABLE orders DROP COLUMN amount; ALTER TABLE orders RENAME COLUMN amount_v2 TO amount;

例外:VARCHARの長さを増やす場合

テーブルリライトが不要なため安全なカラム型変更もある:

-- ✅ 安全:VARCHAR長の増加(リライト不要) ALTER TABLE users ALTER COLUMN name TYPE VARCHAR(255); -- 増やすときだけ安全。減らすのは不安全! -- ✅ 安全:VARCHAR制限を完全に除去 ALTER TABLE users ALTER COLUMN name TYPE TEXT; -- ✅ 安全:VARCHARをTEXTに変更 -- TEXTとVARCHARはPostgreSQLで同一に格納される

Lock Timeout:セーフティネット

すべてのマイグレーションにロックタイムアウトを設定すべきだ。設定しないと、マイグレーションがロックを無限に待ち続け、後ろのすべてのクエリをキューに入れてしまう。

-- このセッションに5秒のロックタイムアウトを設定 SET lock_timeout = '5s'; -- マイグレーションを実行 ALTER TABLE users ADD COLUMN bio TEXT; -- 5秒以内にロックを取得できなければ -- PostgreSQLが無限待機の代わりにエラーを発生させる

マイグレーションスクリプトでは必ずロックタイムアウトを設定する:

-- すべてのマイグレーションファイルの先頭に SET lock_timeout = '5s'; SET statement_timeout = '30s'; -- マイグレーションDDLをここに ALTER TABLE users ADD COLUMN bio TEXT; -- 安全のためにリセット RESET lock_timeout; RESET statement_timeout;

リトライロジック

ロックタイムアウトを使う場合、リトライロジックが必要になる。長時間実行中のクエリが競合するロックを保持していたためにマイグレーションが失敗する可能性がある。問題ない。少し待ってリトライすればいい:

async function safeMigrate(sql: string, maxRetries = 5): Promise<void> { for (let attempt = 1; attempt <= maxRetries; attempt++) { try { await db.query('SET lock_timeout = \'5s\''); await db.query(sql); console.log(`Migration succeeded on attempt ${attempt}`); return; } catch (error) { if (error.code === '55P03' && attempt < maxRetries) { // ロックタイムアウト - 待機してリトライ console.log( `Lock timeout on attempt ${attempt}, ` + `retrying in ${attempt * 2}s...` ); await sleep(attempt * 2000); } else { throw error; } } } }

バッチバックフィル:データ移動の技術

数百万の既存行を更新する必要がある場合(例:新カラムのバックフィル)、1つのUPDATEでやるのは危険。巨大な単一トランザクションが作られて:

  1. 影響を受けるすべての行に行レベルロックを保持
  2. 大量のWAL(Write-Ahead Log)を生成
  3. レプリケーション遅延を引き起こす可能性
  4. テーブルの肥大化(VACUUMが処理すべきデッドタプル)

バッチパターン

-- 10,000行のチャンクでバックフィル DO $$ DECLARE batch_size INT := 10000; last_id BIGINT := 0; max_id BIGINT; rows_updated INT; BEGIN SELECT MAX(id) INTO max_id FROM orders; WHILE last_id < max_id LOOP UPDATE orders SET amount_cents = amount * 100 WHERE id > last_id AND id <= last_id + batch_size AND amount_cents IS NULL; GET DIAGNOSTICS rows_updated = ROW_COUNT; last_id := last_id + batch_size; RAISE NOTICE 'Processed up to id %, updated % rows', last_id, rows_updated; -- レプリカが追いつき、autovacuumが -- デッドタプルを処理する時間を与える PERFORM pg_sleep(0.05); -- 各バッチを個別にコミット COMMIT; END LOOP; END $$;

バックフィルのモニタリング

バックフィル中はこれらのメトリクスをモニタリングする:

-- レプリケーション遅延を確認(リードレプリカに重要) SELECT client_addr, state, sent_lsn, write_lsn, flush_lsn, replay_lsn, pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes FROM pg_stat_replication; -- テーブルブロートを確認(蓄積するデッドタプル) SELECT relname, n_live_tup, n_dead_tup, round(n_dead_tup::numeric / GREATEST(n_live_tup, 1) * 100, 2) AS dead_pct, last_autovacuum FROM pg_stat_user_tables WHERE relname = 'orders'; -- 競合する可能性のある長時間クエリを確認 SELECT pid, now() - query_start AS duration, state, left(query, 100) AS query_preview FROM pg_stat_activity WHERE state != 'idle' AND query_start < now() - interval '30 seconds' ORDER BY duration DESC;

プロダクション向けマイグレーションツール

pgroll:無停止マイグレーションツール

pgrollはゼロダウンタイム変更のために設計されたスキーママイグレーションツール。Expand-Contractパターンを自動実装する:

{ "name": "add_display_name", "operations": [ { "rename_column": { "table": "users", "from": "full_name", "to": "display_name" } } ] }

pgrollが拡張フェーズ(ビュー、トリガー、一時カラムの作成)、マイグレーションフェーズ、縮小フェーズを自動処理する。バージョン付きスキーマビューを作成して、新旧のアプリバージョンが共存できるようにする。

Reshape

Reshapeも同様の思想に基づく。宣言的かつゼロダウンタイムのマイグレーションをExpand-Contractの自動化で実現する:

[[actions]] type = "alter_column" table = "users" column = "full_name" [actions.changes] name = "display_name"

sqitch + カスタムスクリプト

より細かい制御を好むチームには、sqitchとカスタムスクリプトの組み合わせが軽量な選択肢になる:

# sqitchのワークフロー sqitch add rename-user-column \ -n "Rename full_name to display_name (phase 1: expand)" sqitch deploy sqitch verify

フレームワーク別ツール

フレームワークツールゼロダウンタイム対応
Railsstrong_migrations gem危険な操作をブロック、安全な代替案を提示
Djangodjango-pg-zero-downtime-migrationsロックタイムアウトと安全パターンを追加
Laravelビルトインなし手動パターンが必要
Node.js/TypeScriptgraphile-migrate, node-pg-migrate良い制御性、手動パターン
Gogoose, atlasAtlasにはセーフティチェック付き宣言的マイグレーション

実戦マイグレーションプレイブック

最もよくあるプロダクションマイグレーションのための、コピペ可能なプレイブック:

プレイブック1:デフォルト値付きの必須カラムを追加

-- Step 1: nullableカラムを追加(即座) SET lock_timeout = '5s'; ALTER TABLE users ADD COLUMN subscription_tier TEXT; -- Step 2: 既存行をバックフィル DO $$ DECLARE batch_size INT := 5000; rows_updated INT; BEGIN LOOP UPDATE users SET subscription_tier = 'free' WHERE subscription_tier IS NULL AND id IN ( SELECT id FROM users WHERE subscription_tier IS NULL LIMIT batch_size FOR UPDATE SKIP LOCKED ); GET DIAGNOSTICS rows_updated = ROW_COUNT; EXIT WHEN rows_updated = 0; COMMIT; PERFORM pg_sleep(0.05); END LOOP; END $$; -- Step 3: NOT NULL制約を安全に追加 ALTER TABLE users ADD CONSTRAINT users_subscription_tier_not_null CHECK (subscription_tier IS NOT NULL) NOT VALID; ALTER TABLE users VALIDATE CONSTRAINT users_subscription_tier_not_null; -- Step 4: NOT NULL設定(有効なCHECK制約があれば即座) ALTER TABLE users ALTER COLUMN subscription_tier SET NOT NULL; -- Step 5: 今後の行のためにデフォルト値を設定 ALTER TABLE users ALTER COLUMN subscription_tier SET DEFAULT 'free'; -- Step 6: CHECK制約をクリーンアップ ALTER TABLE users DROP CONSTRAINT users_subscription_tier_not_null;

プレイブック2:大きなテーブルにインデックスを作成

-- Step 1: インデックスをconcurrentlyで作成 SET statement_timeout = '0'; CREATE INDEX CONCURRENTLY idx_orders_customer_created ON orders(customer_id, created_at DESC); -- Step 2: インデックスの有効性を検証 DO $$ BEGIN IF NOT EXISTS ( SELECT 1 FROM pg_index i JOIN pg_class c ON i.indexrelid = c.oid WHERE c.relname = 'idx_orders_customer_created' AND i.indisvalid = true ) THEN RAISE EXCEPTION 'Index is invalid! Drop and retry.'; END IF; END $$;

プレイブック3:テーブルの置換(全体スキーマ変更)

-- 個別カラムのexpand-contractでは対応しきれないほど -- 変更が広範な場合 -- Step 1: 望みのスキーマで新テーブルを作成 CREATE TABLE users_v2 ( id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, display_name TEXT NOT NULL, email TEXT NOT NULL UNIQUE, subscription_tier TEXT NOT NULL DEFAULT 'free', created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); -- Step 2: 新テーブルにインデックスを作成 CREATE INDEX idx_users_v2_email ON users_v2(email); CREATE INDEX idx_users_v2_created ON users_v2(created_at); -- Step 3: バッチでデータをコピー(バックフィルパターンと同様) -- Step 4: 旧テーブルにトリガーを作って新しい行を -- users_v2に同期 -- Step 5: テーブルをアトミックにスワップ BEGIN; ALTER TABLE users RENAME TO users_old; ALTER TABLE users_v2 RENAME TO users; COMMIT; -- Step 6: シーケンス、外部キーなどを更新 -- Step 7: 検証期間後に旧テーブルを削除

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

プロダクションでマイグレーションを実行する前に:

1. アクティブロックと長時間クエリを確認

-- 対象テーブルで5分以上実行中のクエリを終了 SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE query ILIKE '%your_table%' AND state != 'idle' AND query_start < now() - interval '5 minutes';

2. プロダクション規模のデータセットでテスト

開発DBだけでマイグレーションをテストしないこと。プロダクションスケールのデータでステージング環境を作る。

3. タイムアウトを設定

SET lock_timeout = '5s'; SET statement_timeout = '30m'; -- 長いバックフィル用

4. ロールバック計画を用意

すべてのマイグレーションにはテスト済みのロールバックが必要:

-- migration.sql ALTER TABLE users ADD COLUMN bio TEXT; -- rollback.sql ALTER TABLE users DROP COLUMN IF EXISTS bio;

5. デプロイ中のモニタリング

-- リアルタイムでロック競合を監視 SELECT blocked.pid AS blocked_pid, blocked.query AS blocked_query, blocking.pid AS blocking_pid, blocking.query AS blocking_query, now() - blocked.query_start AS blocked_duration FROM pg_stat_activity blocked JOIN pg_locks blocked_locks ON blocked.pid = blocked_locks.pid JOIN pg_locks blocking_locks ON blocked_locks.locktype = blocking_locks.locktype AND blocked_locks.database = blocking_locks.database AND blocked_locks.relation = blocking_locks.relation AND blocked_locks.pid != blocking_locks.pid JOIN pg_stat_activity blocking ON blocking_locks.pid = blocking.pid WHERE NOT blocked_locks.granted;

よくある失敗と回避法

失敗1:ピークトラフィック中にマイグレーションを実行

スキーママイグレーションはトラフィックが最も少ない時間帯にスケジュールすること。「安全な」マイグレーションでも低い同時実行性の恩恵を受ける。

失敗2:ロールバックをテストしない

すべてのマイグレーションロールバックはテスト済みであるべきだ。「カラムをドロップすればいい」はデータを破壊するロールバック計画。ロールバック時にデータの保持が必要かどうか検討すること。

失敗3:ORMの存在を忘れる

ORMがカラム名でSQLを生成する場合がある。Expand-Contractを使うとき、ORMバージョンが遷移状態(新旧両方のカラムが存在する状態)を処理できることを確認すること。

失敗4:レプリケーション遅延を無視する

リードレプリカを使っている場合、スキーマ変更はレプリケーション経由で伝播する。1000万行を書き込むバックフィルは深刻なレプリケーション遅延を引き起こし、リードレプリカが古いデータを返すようになる。

対策:バックフィル中に pg_stat_replicationをモニタリングし、遅延がしきい値を超えたらスロットリング:

async function throttledBackfill(batchSize: number) { while (true) { const lag = await getReplicationLag(); if (lag > MAX_LAG_BYTES) { console.log(`Replication lag ${lag} bytes, pausing...`); await sleep(5000); continue; } const updated = await updateBatch(batchSize); if (updated === 0) break; await sleep(50); // ベースラインスロットル } }

失敗5:アプリコードとマイグレーションを同時にデプロイ

アプリコードとマイグレーションは別々のステップでデプロイすべきだ。まずマイグレーションをデプロイし、成功を確認してから、新しいスキーマを使うコードをデプロイする。この分離が安全なロールバックに不可欠。

マイグレーション安全チェックリスト

プロダクションマイグレーションの前に毎回:

  • ロックタイムアウトを設定(SET lock_timeout = '5s'
  • 長い操作にステートメントタイムアウトを設定
  • プロダクションスケールのデータセットでマイグレーションをテスト
  • ロールバックスクリプトを作成しテスト済み
  • 同時デプロイやメンテナンスウィンドウなし
  • レプリケーション遅延のモニタリングが有効
  • バックフィルはバッチ更新を使用(単一UPDATEではなく)
  • インデックスはCONCURRENTLYで作成
  • NOT NULL制約はCHECK + VALIDATEパターンで追加
  • 外部キーはNOT VALID + VALIDATEで追加
  • アプリコードが古いスキーマと後方互換
  • オンコールエンジニアにマイグレーションを通知
  • 可能であればトラフィックが最も少ない時間帯

スキーママイグレーションは怖くない。このガイドのパターンは1日数百万リクエストをさばくDBで実戦検証済みだ。核心はシンプル。壊れる変更を1ステップで絶対にやらないこと。まず拡張、あとで縮小。タイムアウトを設定する。バッチでバックフィルする。全てをモニタリングする。

DBはアプリの土台だ。スキーマ変更は心臓手術と同じように扱おう。綿密な計画、精密な実行、そして絶え間ないモニタリング。

PostgreSQLdatabasemigrationszero-downtimeschema changesDevOpsbackendproductionexpand-contractdeploy

関連ツールを見る

Pockitの無料開発者ツールを試してみましょう