「本番DBに ALTER TABLE を流したら、5分間サービスが止まりました」— これは私自身、個人開発で運営しているSaaSで実際に起きた失敗です。深夜のアクセスが少ない時間帯を狙ったつもりでしたが、運悪くロングランニングなクエリと衝突し、テーブル全体がロックされてしまいました。
幸いユーザーは数十人規模で、Slack に「メンテナンス中」と書いて謝罪すれば済む状況でした。しかしこの経験で、私は本番DBに対する考え方を根本から改めることになりました。ここではその学びを土台として、Antigravity の AI 支援を活用しながら Expand-Contract パターン で破壊的変更をゼロダウンタイムに進める手順を、実装可能なコードとともに紹介します。
このパターンを習得すると、ユーザーIDの型を BIGINT から UUID へ変更する、メールアドレスを別テーブルに分離する、巨大な users テーブルを users と user_profiles に分割する、といった「やりたいけれどリスクが怖くて先送りにしていた変更」を、堂々と本番に投入できるようになります。
なぜ単純な ALTER TABLE では失敗するのか
PostgreSQL の ALTER TABLE は、変更内容によっては テーブル全体の ACCESS EXCLUSIVE ロック を取得します。このロックが取られている間は SELECT すら通りません。100万行のテーブルでカラム型を変更した場合、行を全て書き直す処理が走り、数十秒から数分のロックが発生します。
加えて、本番では同時に動いているクエリやコネクションプールがあります。ALTER TABLE が完了を待っている間に新しいクエリが詰まり、コネクションが枯渇してアプリ全体が応答不能に陥る、という二次災害が起きやすいのです。
しかし現実のSaaSではスキーマは進化し続けるものです。避けられないなら、安全に進める方法論が必要になります。
その方法論こそが Expand-Contract パターン(別名 Parallel Change パターン)です。
Expand-Contract パターンの3つのフェーズ
このパターンは、破壊的変更を「拡張 → 移行 → 縮約」の3フェーズに分解します。
- Phase 1: Expand(拡張) — 新しいスキーマを追加するだけ。古いスキーマには触らありません。アプリは両方に書き込む(デュアルライト)
- Phase 2: Migrate(移行) — 既存データを新スキーマへバックフィルします。読み取りを段階的に新スキーマへ切り替える
- Phase 3: Contract(縮約) — 古いスキーマへの参照を全て除去してから、最後に古いカラム・テーブルを削除する
各フェーズの間に「読み取りも書き込みも壊れていない」状態を維持することが鍵です。途中でロールバックできるよう、各フェーズの境界で本番デプロイを区切ります。
Phase 1: Expand — 並行スキーマの設計
例として「users.id を BIGINT から UUID へ移行する」シナリオを考えます。最初のステップは、新しいカラムを追加することです。
-- migration_001_expand.sql
-- Phase 1: 新しい uuid カラムを追加(NOT NULL 制約はまだ付けない)
ALTER TABLE users
ADD COLUMN id_new UUID DEFAULT gen_random_uuid();
-- 既存行にも UUID を割り当て(高速・小規模テーブル向け)
-- 大規模テーブルでは Phase 2 のバックフィルで処理する
UPDATE users SET id_new = gen_random_uuid() WHERE id_new IS NULL;
-- 新カラムにユニーク制約を追加(CONCURRENTLY でロック回避)
CREATE UNIQUE INDEX CONCURRENTLY users_id_new_unique
ON users(id_new);ここで重要なのは CREATE INDEX CONCURRENTLY の使用です。通常の CREATE INDEX はテーブル全体をロックしますが、CONCURRENTLY を付けると書き込みをブロックせずにインデックスを構築できます。代わりに処理時間は伸びますが、本番では時間と引き換えに可用性を取るべきです。
次に、アプリケーション側でデュアルライトを実装します。Antigravity の Inline Edit (Cmd+I) で「この create 関数を、id と id_new の両方に書き込むよう修正して、既存の API は壊さないで」とプロンプトを送ると、以下のような変更を提案してくれます。
// app/server/users/create.ts
import { randomUUID } from "node:crypto";
import { db } from "@/lib/db";
interface CreateUserInput {
email: string;
name: string;
}
export async function createUser(input: CreateUserInput) {
// Phase 1: 古い id(自動採番)と新しい id_new(UUID)の両方を書き込む
const newUuid = randomUUID();
const [user] = await db
.insertInto("users")
.values({
email: input.email,
name: input.name,
id_new: newUuid,
// id は serial で自動採番されるため明示的に指定しない
})
.returning(["id", "id_new", "email", "name"])
.execute();
return user;
}このコードを本番にデプロイすれば、新規登録ユーザーは両カラムが同期した状態で作成されます。既存ユーザーの id_new は Phase 2 でバックフィルします。
Phase 2: Migrate — 大規模テーブルのバックフィル戦略
100万行規模のテーブルで UPDATE users SET id_new = gen_random_uuid() を一発で流すと、長時間のロックが発生して Phase 1 の苦労が水の泡になります。チャンク化バックフィル で少しずつ進めるのが鉄則です。
// scripts/backfill_user_uuid.ts
import { db } from "@/lib/db";
const CHUNK_SIZE = 1000;
const SLEEP_MS = 200;
async function backfillUuid() {
let lastId = 0;
let processed = 0;
while (true) {
const updated = await db
.updateTable("users")
.set((eb) => ({ id_new: eb.fn("gen_random_uuid") }))
.where("id", ">", lastId)
.where("id_new", "is", null)
.where("id", "in",
db.selectFrom("users")
.select("id")
.where("id", ">", lastId)
.where("id_new", "is", null)
.orderBy("id", "asc")
.limit(CHUNK_SIZE)
)
.returning(["id"])
.execute();
if (updated.length === 0) break;
lastId = Math.max(...updated.map((u) => Number(u.id)));
processed += updated.length;
console.log(`Processed: ${processed}, lastId: ${lastId}`);
// バックフィル中も他のクエリが流せるよう、意図的にスリープ
await new Promise((r) => setTimeout(r, SLEEP_MS));
}
console.log(`✅ Backfill complete. Total processed: ${processed}`);
}
backfillUuid().catch((err) => {
console.error("❌ Backfill failed:", err);
process.exit(1);
});このスクリプトには3つの工夫が入っています。
第一に、lastId をカーソルとして使い、レジューム可能にしている点です。途中で OOM やネットワーク断で停止しても、最後の lastId を引数で渡せば続きから再開できます。
第二に、SLEEP_MS の意図的なスリープで本番トラフィックへの影響を抑えています。完全に最速で回すよりも、CPU・I/O に余裕を残しておくことで、ユーザーへの影響を最小化できます。
第三に、ログ出力を頻繁に行うことで、進捗が分かるようにしています。長時間バッチでは「動いているのか止まっているのか」を可視化することが運用ストレスを大幅に減らします。
ここで Antigravity の Plan Mode に「このバックフィルスクリプトの安全性を評価して、想定リスクと対策を箇条書きで挙げて」と聞くと、ロック競合の可能性、gen_random_uuid() の重複リスク、デッドロック発生時のリトライ戦略まで網羅的にレビューしてくれます。私は本番デプロイ前に必ずこのレビューを通すようにしています。
Phase 2 の続き:読み取りの段階的切り替え
バックフィルが完了したら、アプリの 読み取り処理 を段階的に新カラムへ切り替えます。ここで威力を発揮するのが フィーチャーフラグ です。
// app/server/users/get.ts
import { db } from "@/lib/db";
import { isFeatureEnabled } from "@/lib/feature-flags";
export async function getUserById(idOrUuid: string | number) {
const useUuid = await isFeatureEnabled("users.read.use_uuid_id");
if (useUuid && typeof idOrUuid === "string") {
return db
.selectFrom("users")
.selectAll()
.where("id_new", "=", idOrUuid)
.executeTakeFirst();
}
// 旧経路(数値ID)
const numericId = typeof idOrUuid === "string" ? Number(idOrUuid) : idOrUuid;
return db
.selectFrom("users")
.selectAll()
.where("id", "=", numericId)
.executeTakeFirst();
}users.read.use_uuid_id フラグを 0% → 1% → 10% → 50% → 100% と段階的に上げることで、何か問題が起きてもすぐに切り戻せます。私の場合、各段階で 24 時間の観察期間を置き、エラーレートとレイテンシに変化がないかを確認してから次の段階へ進めます。
このパターンの詳細は別記事 Antigravity でフィーチャーフラグを実装する にも書いていますので、フラグ基盤がまだない場合はそちらを先に整えると安心です。
Phase 3: Contract — 旧カラムの段階的撤去
読み取りが 100% 新カラムに切り替わり、24〜48 時間問題が起きていないことを確認したら、いよいよ Contract フェーズです。ただし、ここでも一発削除ではなく、段階的な縮約 を行います。
-- migration_002_contract_step1.sql
-- Step 1: 古いカラムへの書き込みを停止する準備
-- アプリ側で id への明示的な書き込みを止める PR をマージしてから実行
-- 古いカラムに NOT NULL 制約を外す(万一の書き込みエラーを避けるため)
ALTER TABLE users ALTER COLUMN id DROP NOT NULL;ここで一旦止めて、本番で 1 週間ほど運用します。ログを精査して「古いカラムへの参照が完全にゼロになっている」ことを確認するためです。
-- migration_003_contract_step2.sql
-- Step 2: 古いカラムにリネームしてから削除(即時削除より安全)
-- 万が一参照が残っていてもアプリエラーですぐ気づける
ALTER TABLE users RENAME COLUMN id TO id_old_deprecated;
ALTER TABLE users RENAME COLUMN id_new TO id;リネームは即座に完了する操作なので、ロックは一瞬で済みます。新カラムが正式な id になり、旧カラムは id_old_deprecated という「触ると危ない」名前に変わります。アプリで id_old_deprecated を参照しているコードがあれば、ここで初めてエラーになります。
-- migration_004_contract_step3.sql
-- Step 3: 1〜2週間の観察期間後、旧カラムを最終削除
ALTER TABLE users DROP COLUMN id_old_deprecated;この削除は通常テーブルロックを伴いますが、カラム削除はメタデータ操作のため即座に完了します。実データの削除は VACUUM で後から回収されます。
落とし穴:実装中に踏みやすい3つの罠
理論は分かっても、実際にやってみると思わぬところで詰まります。ここでは私が実際に踏んだ罠を3つ共有します。
罠1: トリガーで同期するという誘惑
「Phase 1 のデュアルライトをアプリではなく DB トリガーで同期させれば楽じゃないか」と思いがちですが、これは罠です。
-- これは見た目は綺麗だが本番では避けるべき
CREATE TRIGGER sync_id_new
BEFORE INSERT ON users
FOR EACH ROW
EXECUTE FUNCTION generate_id_new();トリガーは確かに同期を保証しますが、デバッグが極めて困難になります。本番でアプリログに現れない動作が DB 内で起きるため、何かおかしいときの調査が地獄です。アプリ層で明示的にデュアルライトする方が、後から見て分かりやすく、トラブル時の対処もしやすいのです。
罠2: ロングランニングなトランザクションとの競合
CONCURRENTLY なインデックス作成中に、別のセッションで長いトランザクションが走っていると、CREATE INDEX CONCURRENTLY は そのトランザクションが終わるまで待ち続けます。
-- 本番開始前に必ず確認するクエリ
SELECT pid, now() - xact_start AS duration, query
FROM pg_stat_activity
WHERE state = 'active' AND xact_start IS NOT NULL
ORDER BY duration DESC
LIMIT 10;実行前にこのクエリで「何分も走っている怪しいトランザクション」がないかを確認し、必要なら開発者にエスカレーションして停止してもらってから進めるのが安全です。バッチジョブやレポート生成が原因で、知らない間にトランザクションが残っていることがあります。
罠3: ORM のスキーマキャッシュ
Prisma や Kysely などの ORM はスキーマ情報をキャッシュします。Phase 3 でカラムをリネームしたあとに、古いスキーマ定義のままアプリを再起動せずに動かし続けると、ORM が「id_new カラムが存在しない」というエラーを延々と吐き続けます。
対策は、リネーム後すぐに ORM 側のスキーマファイルを再生成 + 再デプロイ することです。Prisma なら prisma db pull && prisma generate、Kysely なら kysely-codegen を CI で必ず走らせるようにします。
私はこの罠で 30 分ほどサービスを不安定にしたことがあり、それ以来 Antigravity の Custom Command に「migration-postcheck」という名前で「マイグレーション後に必ずチェックする項目リスト」を仕込んでいます。
本番採用例:Antigravity でこのワークフローを駆動する
ここまでの手順を実際に Antigravity で進めるとき、私は以下のワークフローを使っています。
第一に、Plan Mode で「ユーザーIDをBIGINTからUUIDへ移行する Expand-Contract マイグレーションプランを作成して、各フェーズのSQL・アプリコード変更・ロールバック手順を含めて」とプロンプトを送ります。Plan Mode は Fast Mode と違い、複数のステップを横断して整合性を取ってくれるので、フェーズ間の依存関係を見落としません。
第二に、生成された各 SQL について「この SQL のロック影響を評価して、ロックを取らない代替案を提案して」と追加で問います。Antigravity は PostgreSQL のロック特性をかなり正確に把握しているため、CONCURRENTLY の付け忘れや EXCLUSIVE ロックを取る ALTER の検出に役立ちます。
第三に、各フェーズのデプロイ前に AGENTS.md(プロジェクトルートに置くプロンプトファイル)に「DB変更時の必須チェック項目」を書いておき、Antigravity の Inline Edit でその項目を全て確認するワークフローを実行します。私の AGENTS.md には以下のような項目が並んでいます。
- pg_stat_activity でロングランニングトランザクションの確認
- バックアップが過去24時間以内に取得されていることの確認
- 該当テーブルへの平均QPSと、ピーク時QPSの確認
- ロールバック SQL の事前準備とテスト環境での実行確認
- フィーチャーフラグの初期値(OFF)と段階的展開計画の確認
このようにルーチン化することで、本番DBに対する「えいや」での操作を完全に排除できます。
全体を振り返って:明日からできる最初の一歩
本記事で紹介した Expand-Contract パターンは、概念としてはシンプルですが、実装ディテールに勝負どころが詰まっています。今日から始める最初の一歩としては、今動いている本番DBで「いつかやり直したい」と思っている破壊的変更を1つ、紙に書き出してみる ことをお勧めします。
書き出した変更を Expand / Migrate / Contract の3フェーズに分解し、各フェーズで「アプリコードはどう変わるか」「DBにはどんなSQLを流すか」「ロールバックはどうするか」を整理するだけで、リスクが見える化されます。実際にデプロイする前に、Antigravity の Plan Mode に「このマイグレーション計画をレビューして、見落としている観点を3つ挙げて」と聞いてみてください。きっと自分では気づけなかった視点が出てきます。
破壊的変更を恐れず、しかし慎重に。Antigravity と Expand-Contract パターンが、その両立を可能にしてくれます。