個人開発の SaaS で、深夜の静かな時間帯を選んで ALTER TABLE を流した夜がありました。コマンドはいつまでも戻ってきません。別のターミナルで pg_stat_activity を開くと、待機中のクエリが一本、また一本と増えていきます。
原因は、ALTER 自体の重さではありませんでした。手前で走っていた長いトランザクションの終了を ALTER が待ち、その ALTER の後ろに、あらゆる SELECT が並んでしまったのです。胃が痛くなる数分間でした。ロック待ちの行列こそが本当の原因だったと気づいたのは、事態が落ち着いたあとでした。
最初にお伝えしたいのは、**危ないのは「重い変更」より「待たされる変更」**だということです。ここからは、その気づきを土台に、Expand-Contract パターンで破壊的変更を段階に刻む手順を、動かせるコードと検証クエリつきで書き残します。
なぜ単純な ALTER TABLE は止まるのか
PostgreSQL の多くの ALTER TABLE は、テーブル全体に ACCESS EXCLUSIVE ロックを要求します。このロックは、他のあらゆるロック(SELECT の ACCESS SHARE を含む)と衝突します。
ここに落とし穴があります。ロックは「取れた瞬間」だけでなく、「取ろうとして待っている間」も後続を堰き止めます。先行トランザクションが終わらないかぎり、ALTER は待ち続け、後から来たクエリは ALTER の後ろに並びます。
つまり、変更そのものが一瞬で終わる操作でも、待ち行列ができた時点でサービスは止まります。操作ごとの性質を、先に表で整理しておきます。
| 操作 | ロック | 実行時間の性質 |
|---|---|---|
| ADD COLUMN(デフォルトなし・NULL 許容) | ACCESS EXCLUSIVE | メタデータのみ。瞬時 |
ADD COLUMN(gen_random_uuid() など揮発性デフォルト) | ACCESS EXCLUSIVE | テーブル全体を書き直す。行数に比例 |
| CREATE INDEX | SHARE(書き込みを阻害) | 構築中ずっと書き込み停止 |
| CREATE INDEX CONCURRENTLY | SHARE UPDATE EXCLUSIVE | 長くなるが読み書きは通る |
| ADD CONSTRAINT ... CHECK ... NOT VALID | ACCESS EXCLUSIVE | 既存行を検証しないので瞬時 |
| VALIDATE CONSTRAINT | SHARE UPDATE EXCLUSIVE | 全行を走査するが読み書きは通る |
| ALTER COLUMN SET NOT NULL | ACCESS EXCLUSIVE | 検証済み CHECK があれば走査を省略(PostgreSQL 12 以降) |
| RENAME COLUMN / DROP COLUMN | ACCESS EXCLUSIVE | メタデータのみ。瞬時 |
表のなかで特に見落としやすいのが 2 行目です。ADD COLUMN ... DEFAULT gen_random_uuid() は、一見ただの列追加ですが、デフォルト値が行ごとに異なる揮発性の関数であるため、既存の全行が書き直されます。次章の Expand では、この罠を避けるところから始めます。
lock_timeout を先に決めておく
どの ALTER を流す場合も、先頭にこの 2 行を置きます。
-- ロックが取れなければ 3 秒で諦める。待ち行列を作らない
SET lock_timeout = '3s';
-- 文の実行時間にも上限をかける(バックフィル以外のDDL向け)
SET statement_timeout = '60s';lock_timeout を超えると ALTER はエラーで終わり、後続のクエリは何事もなかったように流れ続けます。失敗したら、少し間を置いてやり直せばよいのです。マイグレーションを「一回で通すもの」から「通るまで安全に試すもの」へ変える、いちばん小さな一手だと思っています。
リトライを含めたランナーの例です。
// scripts/run-ddl.ts
import { sql } from "kysely";
import { db } from "@/lib/db";
export async function runDdl(statement: string, maxAttempts = 8) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
await db.transaction().execute(async (trx) => {
await sql`SET LOCAL lock_timeout = '3s'`.execute(trx);
await sql.raw(statement).execute(trx);
});
console.log(`OK (attempt ${attempt})`);
return;
} catch (err: any) {
// 55P03 = lock_not_available
if (err?.code !== "55P03") throw err;
const wait = Math.min(1000 * 2 ** attempt, 30_000);
console.warn(`lock busy, retry in ${wait}ms (attempt ${attempt})`);
await new Promise((r) => setTimeout(r, wait));
}
}
throw new Error("could not acquire lock within the retry budget");
}ロック不足のエラーだけをリトライ対象にし、構文エラーなどはそのまま落とします。ここを区別しておかないと、本物のバグを 8 回繰り返すことになります。
Expand-Contract の 3 フェーズ
破壊的変更を「拡張 → 移行 → 縮約」に分解します。Phase 1 では新しいスキーマを足すだけで、古いものには触れません。
- Phase 1 Expand — 新スキーマの追加と、両方への書き込み(デュアルライト)
- Phase 2 Migrate — 既存データのバックフィルと、読み取りの段階的な切り替え
- Phase 3 Contract — 参照ゼロの確認、リネーム、そして削除
各フェーズの境界で本番へデプロイし、観察します。スキーマの変更は、一度に一つだけ、いつでも戻れる場所で止まります。 この線引きが、パターン全体の骨格なのです。
Phase 1: Expand — 並行スキーマを足す
例として、users.id を BIGINT から UUID へ移行する場面を考えます。
-- migration_001_expand.sql
SET lock_timeout = '3s';
-- デフォルトを付けずに追加する(揮発性デフォルトは全行を書き直すため)
ALTER TABLE users ADD COLUMN id_new uuid;
-- デフォルトの設定はメタデータのみ。以後に挿入される行にだけ効く
ALTER TABLE users ALTER COLUMN id_new SET DEFAULT gen_random_uuid();gen_random_uuid() は PostgreSQL 13 以降で標準搭載です。それ以前のバージョンでは pgcrypto 拡張が必要になります。
続けて、一意インデックスを CONCURRENTLY で作ります。このコマンドはトランザクションの中では実行できません。
-- migration_002_index.sql(トランザクションなしで実行)
CREATE UNIQUE INDEX CONCURRENTLY users_id_new_key ON users (id_new);構築中に失敗すると、INVALID 状態のインデックスが残ります。残ったまま次へ進むと、あとで一意性が効いていないことになるため、必ず確かめます。
SELECT c.relname, i.indisvalid
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE c.relname = 'users_id_new_key';
-- indisvalid が false なら DROP INDEX CONCURRENTLY して作り直す次に、アプリ側でデュアルライトを入れます。Antigravity のインラインコマンド(Cmd+I)に「create 関数が id_new にも UUID を書くよう直して。既存の戻り値の形は変えないで」と頼み、差分を一行ずつ読んでから採用します。
// 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 は serial が採番。新しい id_new をアプリ側でも明示する
const [user] = await db
.insertInto("users")
.values({
email: input.email,
name: input.name,
id_new: randomUUID(),
})
.returning(["id", "id_new", "email", "name"])
.execute();
return user;
}DB 側にもデフォルトがありますが、アプリが明示的に書く経路を残しておくと、後でデフォルトを外すときに挙動が変わりません。
Phase 2: Migrate — チャンク化バックフィル
既存行の id_new はまだ NULL のままです。ここで UPDATE users SET id_new = gen_random_uuid() を一発で流すと、長い書き込みトランザクションが生まれ、Phase 1 の努力が無駄になります。チャンクに刻んで進めます。
// scripts/backfill_user_uuid.ts
import { sql } from "kysely";
import { db } from "@/lib/db";
const CHUNK_SIZE = 1000;
const SLEEP_MS = 200;
async function backfillUuid(startFrom = 0) {
let lastId = startFrom;
let processed = 0;
while (true) {
const updated = await db
.updateTable("users")
.set({ id_new: sql<string>`gen_random_uuid()` })
.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));
}
}
backfillUuid(Number(process.argv[2] ?? 0)).catch((err) => {
console.error("backfill failed:", err);
process.exit(1);
});工夫は 3 つです。lastId をカーソルにして、途中で止まっても process.argv で続きから再開できること。チャンクの合間に眠って、本番のクエリに I/O の余地を残すこと。そして、1 チャンクごとに進捗を出して、動いているのか止まっているのかを見えるようにしていることです。
終わったかどうかは、感覚ではなくクエリで確かめます。
SELECT count(*) AS remaining FROM users WHERE id_new IS NULL;
-- 0 になるまで。Phase 1 のデプロイ後に挿入された行は、すでに埋まっています読み取りの段階的な切り替え
バックフィルが終わったら、読み取りを新カラムへ切り替えます。ここでフィーチャーフラグを使います。
// 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();
}
const numericId = typeof idOrUuid === "string" ? Number(idOrUuid) : idOrUuid;
return db.selectFrom("users").selectAll()
.where("id", "=", numericId)
.executeTakeFirst();
}フラグは 1% → 10% → 50% → 100% と上げ、各段階でエラー率とレイテンシを見ます。フラグ基盤がまだなければ、先に Antigravity でフィーチャーフラグを実装する を整えておくと安心です。
切り替えの前に、デュアルライトの食い違いがないかも見ておきます。
-- 新旧の両方が埋まっていて、重複もない状態を確認
SELECT
count(*) AS total,
count(*) FILTER (WHERE id_new IS NULL) AS missing_new,
count(*) - count(DISTINCT id_new) AS duplicated_new
FROM users;Phase 3: Contract — 主キーの付け替えと旧カラムの撤去
読み取りが 100% 新カラムで 24〜48 時間落ち着いていたら、縮約に入ります。ここで、多くの解説が省く部分に触れます。id が主キーなら、そのまま DROP NOT NULL はできません。 主キーの付け替えが先に必要です。
Step 1: NOT NULL を、走査なしで付ける
-- migration_003_notnull.sql
SET lock_timeout = '3s';
-- まず NOT VALID で追加(既存行を見ないので瞬時)
ALTER TABLE users
ADD CONSTRAINT users_id_new_not_null CHECK (id_new IS NOT NULL) NOT VALID;
-- 別の文で検証(読み書きを止めずに全行を走査)
ALTER TABLE users VALIDATE CONSTRAINT users_id_new_not_null;
-- 検証済み CHECK があれば、SET NOT NULL は全行走査を省略する(PostgreSQL 12 以降)
ALTER TABLE users ALTER COLUMN id_new SET NOT NULL;
ALTER TABLE users DROP CONSTRAINT users_id_new_not_null;Step 2: 主キーを 1 トランザクションで付け替える
-- migration_004_swap_pk.sql
SET lock_timeout = '3s';
BEGIN;
ALTER TABLE users DROP CONSTRAINT users_pkey;
ALTER TABLE users
ADD CONSTRAINT users_pkey PRIMARY KEY USING INDEX users_id_new_key;
COMMIT;既存の一意インデックスを主キーとして取り込むので、インデックスの再構築は起きません。ここは短い ACCESS EXCLUSIVE を取るため、先ほどの lock_timeout とリトライが効いてきます。
注意点を一つ。users.id を外部キーで参照している子テーブルがあると、DROP CONSTRAINT users_pkey は依存関係で失敗します。その場合は、子テーブル側にも同じ Expand-Contract を回して、参照先を id_new へ移してから進めることになります。この記事では 1 テーブルの範囲に絞りました。
Step 3: リネームで「触ると危ない」名前にする
-- migration_005_rename.sql
SET lock_timeout = '3s';
BEGIN;
ALTER TABLE users RENAME COLUMN id TO id_old_deprecated;
ALTER TABLE users RENAME COLUMN id_new TO id;
COMMIT;リネームは瞬時に終わります。旧カラムを参照しているコードが残っていれば、ここで初めてエラーになります。即時削除ではなくリネームを挟む理由は、壊れる場所を、削除前に見つけられるからです。
Step 4: 観察期間のあとに削除する
-- migration_006_drop.sql(1〜2 週間の観察後)
SET lock_timeout = '3s';
ALTER TABLE users DROP COLUMN id_old_deprecated;DROP COLUMN はメタデータ操作で、すぐに終わります。ただし、物理的な領域はその場では戻りません。行が書き換えられていくなかで少しずつ解放されるか、VACUUM FULL などで回収することになります。ディスクを急いで空けたい場面では、この点を覚えておいてください。
実際に踏んだ 3 つの罠
罠 1: トリガーで同期する誘惑
デュアルライトを DB トリガーに任せると、アプリの変更が不要で楽に見えます。実際、オンラインスキーマ変更ツールの多くは内部でトリガーを使っています。
問題は可視性です。アプリのログに現れない書き込みが DB 内で起きるため、食い違いの調査が難しくなります。私はアプリ層に明示的に書くほうを選んでいますが、書き込み元が複数のサービスに散らばっている場合は、トリガーのほうが取りこぼしを防げる、という判断もありえます。どちらを選ぶにせよ、どこで同期しているかを一か所に書き残すことが条件です。
罠 2: 長いトランザクションが CONCURRENTLY を止める
CREATE INDEX CONCURRENTLY は、構築開始時点より前に始まったトランザクションがすべて終わるまで待ちます。実行前に、怪しいものがないかを見ておきます。
SELECT pid, now() - xact_start AS duration, state, left(query, 80) AS query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY duration DESC
LIMIT 10;バッチやレポート生成が、思わぬトランザクションを開いたままにしていることがあります。idle in transaction の行は、まず疑う場所です。
罠 3: ORM のスキーマ定義が古いまま残る
リネーム後に、アプリが古いスキーマ定義のまま動いていると、存在しないカラムへのクエリが延々と失敗します。リネームの PR を、SQL を流すより先にマージしておくこと。そして、Prisma なら prisma db pull && prisma generate、Kysely なら kysely-codegen を CI に組み込んでおくことが、この罠への備えです。
Antigravity をどこに使うか
この手順で、私が Antigravity に任せているのは「叩き台」と「見落としの検出」までです。
まず、Planning モードに「ユーザー ID を BIGINT から UUID へ移す Expand-Contract 計画を、フェーズごとの SQL・アプリ変更・ロールバックつきで作って」と頼みます。生成された各 SQL には、「この文が取るロックと、ロックを避ける代替案を挙げて」と重ねて聞きます。CONCURRENTLY の付け忘れや、揮発性デフォルトの罠のような見落としを拾ってもらうには、役立つ使い方です。
一方で、AI の答えは、そのまま信じ切らないようにしています。ロックの強さは PostgreSQL 公式ドキュメントの Explicit Locking の章 と突き合わせ、最終的にはステージングで pg_locks を見て確かめます。
AGENTS.md には、DB 変更前の確認項目を書いておきます。
- 長いトランザクションが走っていないこと(
pg_stat_activity) - 直近 24 時間以内にバックアップが取れていること
- 対象テーブルの平常時とピーク時の QPS
- ロールバック SQL の用意と、ステージングでの実行確認
- フィーチャーフラグの初期値(OFF)と、段階的な展開計画
次の一歩
いま手元の本番 DB で、「いつかやり直したい」と思っている変更を一つ、紙に書き出してみてください。そして、Expand・Migrate・Contract のどこで止まれば戻れるのかを、線で引きます。
そのうえで、Antigravity の Planning モードに計画を見せ、見落としている観点を 3 つ挙げてもらってください。挙がった観点のうち、ステージングで確かめられるものから一つずつ潰していくのが、いちばん確実な始め方だと、いまも感じています。