「カラムを1つ追加するだけ」のマイグレーションで、本番が全断した——PostgreSQL を運用していれば、いつか必ず聞く話です。原因はロック。ALTER TABLE の多くは強力なロックを取り、しかも長時間クエリの後ろで待たされると、その後ろに全クエリを巻き込んで止めます。
この記事は、本番でダウンタイムなしにスキーマを変更するための実践プレイブックです。ロックの仕組みから、操作ごとの安全な手順、lock_timeout による安全装置までを、公式ドキュメントに忠実に解説します。発注者が「この人にマイグレーションを任せて大丈夫か」を見るなら、ここが分水嶺。本番運用ガイド §4の深掘りです。
この記事のルール:ロックレベル・各DDLの挙動・PostgreSQL 18 の新機能は PostgreSQL 18 公式ドキュメント(2026年6月時点) に基づきます。「ロック待ちが後続を全部止める」という障害メカニズムは、ロック競合表と『無期限に待つ』仕様からの導出です(公式に一文があるわけではない旨を明記します)。
1. 危険の正体:ACCESS EXCLUSIVE はSELECTすら止める
PostgreSQL のテーブルロックには8段階あり、最強が ACCESS EXCLUSIVE。公式:
ACCESS EXCLUSIVE はすべてのモードのロックと競合する(ACCESS SHARE … ACCESS EXCLUSIVE すべて)。このモードは、保持者がそのテーブルにいかなる方法でもアクセスする唯一のトランザクションであることを保証する。
そして決定的な一文:
SELECT(FOR UPDATE/SHARE なし)をブロックするのは、ACCESS EXCLUSIVE ロックだけである。
ALTER TABLE は公式に「明記がない限り ACCESS EXCLUSIVE を取得する」。つまり多くのスキーマ変更は、実行中読み取りすら止め得るのです。
なぜ「全断」に発展するのか
ここが本質です。ALTER TABLE がロックを要求したとき、テーブルを長時間 SELECT しているトランザクションがいると、ALTER はロックを取れずに待ちます(公式:「競合ロックが解放されるまで無期限に待つ」)。
問題は、その待っている ALTER の後ろ。新しく来る SELECT は、ALTER が要求中の ACCESS EXCLUSIVE と競合するため、ALTER の後ろで待たされます。結果、たった1つの長時間クエリ+1つの ALTER が、新規クエリを全部せき止める——これが「カラム追加で全断」の正体です。
※「後続が待ち行列に積まれる」挙動は、ロック競合表と『無期限に待つ』仕様からの導出です(公式に直接の一文はありません)。しかし実運用で繰り返し観測される現実であり、対策の根拠になります。
2. 起点の安全装置:lock_timeout
対策の第一歩は、**マイグレーションに「世界を止める前に諦めさせる」**こと。lock_timeout を使います。公式:
ロックの取得を待っている間に指定時間を超えた文を中止する。…ゼロ(既定)はタイムアウトを無効化する。…
postgresql.confでの設定は推奨しない(全セッションに影響するため)。
→ マイグレーション実行時だけ短い lock_timeout を設定し、取れなければ即失敗させ、リトライします。これで「長時間クエリの後ろで待ち続けて全断」を防げます。
-- マイグレーションの定石:短いロックタイムアウトで即失敗 → 後でリトライ
SET lock_timeout = '3s';
SET statement_timeout = '0'; -- 文自体の実行は別管理(lock_timeout は「待ち」だけ)
ALTER TABLE orders ADD COLUMN memo text;
// マイグレーションランナー側:lock_timeout で失敗したら指数バックオフでリトライ
// (混雑の谷を狙って、短いロック窓を捕まえる)
const LOCK_NOT_AVAILABLE = "55P03"; // lock_not_available
export async function runWithLockRetry(sql: string, maxAttempts = 10) {
for (let attempt = 1; ; attempt++) {
const tx = await pool.connect();
try {
await tx.query("BEGIN");
await tx.query("SET LOCAL lock_timeout = '3s'");
await tx.query(sql); // 強いロックを要するDDL
await tx.query("COMMIT");
return;
} catch (e: unknown) {
await tx.query("ROLLBACK").catch(() => {});
if ((e as { code?: string }).code === LOCK_NOT_AVAILABLE && attempt < maxAttempts) {
await new Promise((r) => setTimeout(r, Math.min(2 ** attempt * 100, 30_000)));
continue; // ロックが空くタイミングを待って再挑戦
}
throw e;
} finally {
tx.release();
}
}
}
もう1つの予防策:長時間トランザクションを放置しないこと。
idle in transactionのセッションがロックを握り続けると、lock_timeoutを入れても DDL は永遠に成功しません(MVCC/VACUUM記事 §8)。
3. 操作別・安全なレシピ
3.1 列の追加:定数デフォルトは高速、volatile は危険
公式:「非volatileなDEFAULTで ADD COLUMN すると、デフォルト値は文の実行時に評価され、テーブルのメタデータに格納される。…既存行のアクセス時に返される。テーブルの書き換えは不要で、巨大表でも ALTER は非常に高速」。
逆に書き換えが起きるケース(公式):「volatileなDEFAULT(例 clock_timestamp())、stored生成列、identity列、制約付きドメイン型を追加すると、テーブルと索引が全書き換えされる。virtual生成列の追加は書き換え不要」。
-- ✅ 高速:定数デフォルト(書き換えなし・PG11以降)
ALTER TABLE users ADD COLUMN plan text NOT NULL DEFAULT 'free';
-- ❌ 危険:volatile デフォルトは全行書き換え(巨大表で長時間ロック)
-- ALTER TABLE users ADD COLUMN created_at timestamptz DEFAULT clock_timestamp();
-- → 代わりに「null許容で追加 → バッチで埋める → 後でNOT NULL」(§3.2)
3.2 NOT NULL の追加:全表スキャンを避ける
SET NOT NULL はテーブル全体をスキャンしてNULLが無いことを確認します(その間ロック)。公式は回避策を明記:「有効なCHECK制約が存在し『NULLは存在しない』ことを証明できれば、テーブルスキャンはスキップされる」。
PostgreSQL 18 はさらに素直な道を追加しました——NOT NULL 制約に NOT VALID を付与可能に。
-- 旧来の道:CHECK を NOT VALID で足し、VALIDATE してから SET NOT NULL(スキャンを分割)
ALTER TABLE orders ADD CONSTRAINT orders_amount_nn CHECK (amount IS NOT NULL) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_nn; -- 弱いロックで検証
ALTER TABLE orders ALTER COLUMN amount SET NOT NULL; -- 検証済みCHECKによりスキャン省略
-- PG18の道:NOT NULL を NOT VALID で追加 → VALIDATE(既存行の検証を切り離せる)
-- ALTER TABLE orders ALTER COLUMN amount SET NOT NULL NOT VALID;
-- ALTER TABLE orders VALIDATE CONSTRAINT ...;
3.3 制約・外部キーを安全に:NOT VALID → VALIDATE
大きな表に制約を足すとき、検証スキャンの間ずっとロックがかかるのは危険。公式が用意した二段階:
NOT VALIDを付ければ、ADD CONSTRAINTはテーブルをスキャンせず即コミットできる。その後VALIDATE CONSTRAINTで既存行を検証する。検証は並行更新をロックアウトしない——VALIDATEは SHARE UPDATE EXCLUSIVE ロックだけを取る。
-- 1) 即時:新規行にだけ制約を効かせる(既存行は未検証=スキャンなし)
ALTER TABLE orders
ADD CONSTRAINT orders_amount_positive CHECK (amount >= 0) NOT VALID;
-- 2) 後で:既存行を検証(書き込みを止めずに)
ALTER TABLE orders VALIDATE CONSTRAINT orders_amount_positive;
外部キーも同様。しかも公式:「ほとんどの ADD table_constraint は ACCESS EXCLUSIVE を要するが、ADD FOREIGN KEY は SHARE ROW EXCLUSIVE で済む」。それでも NOT VALID → VALIDATE の二段階が安全です。
ALTER TABLE orders
ADD CONSTRAINT orders_user_fk FOREIGN KEY (user_id) REFERENCES users(id) NOT VALID;
ALTER TABLE orders VALIDATE CONSTRAINT orders_user_fk;
3.4 索引:CONCURRENTLY で書き込みを止めない
-- 書き込みを止めずに索引を作る([索引設計の記事]も参照)
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
注意(公式・インデックス設計記事 §6も参照):2回スキャンで遅い、トランザクションブロック内で実行不可、失敗するとINVALID索引が残る(DROP INDEX CONCURRENTLY して再実行)。
3.5 型変更:シャドウカラム戦略
ALTER COLUMN ... TYPE は公式曰く「通常、テーブルと索引が全書き換えされる」(例外は text↔varchar)。巨大表では危険なので、新しい列を足して段階移行します。
-- 1) 新型の列を追加(高速・nullで)
ALTER TABLE events ADD COLUMN payload_jsonb jsonb;
-- 2) アプリを「両方書く(dual-write)」に。既存はバッチで少しずつ変換(lock_timeout+小バッチ)
-- UPDATE events SET payload_jsonb = payload_text::jsonb WHERE id BETWEEN $1 AND $2;
-- 3) 切替:読みを新列へ。検証後、旧列を DROP
ALTER TABLE events DROP COLUMN payload_text;
この「新列追加 → dual-write → バッチ変換 → 切替 → 旧列削除」は、型変更・列リネーム・正規化など重い変更すべてに効く汎用パターンです。MVCC的にも、書き換え系DDL(
TRUNCATEや rewrite を伴うALTER)はMVCCセーフでない(公式)ため、避けて段階移行する価値があります。
4. ロックレベル早見表
「この操作はどのロックか」を知っていれば、危険を避けられます(公式・抜粋)。
| ロック | 主な操作 | 並行read | 並行write |
|---|---|---|---|
| ACCESS EXCLUSIVE | DROP TABLE、TRUNCATE、ALTER COLUMN TYPE、多くの ALTER TABLE | ❌ | ❌ |
| SHARE(標準の索引作成) | CREATE INDEX(CONCURRENTLY なし) | ✅ | ❌ |
| SHARE ROW EXCLUSIVE | ADD FOREIGN KEY、CREATE TRIGGER | ✅ | ❌ |
| SHARE UPDATE EXCLUSIVE | VACUUM、ANALYZE、CREATE INDEX CONCURRENTLY、VALIDATE CONSTRAINT、SET STATISTICS、DETACH PARTITION CONCURRENTLY | ✅ | ✅ |
狙いは右下——SHARE UPDATE EXCLUSIVE(読みも書きも止めない)に寄せること。CONCURRENTLY と NOT VALID→VALIDATE が、強いロックを弱いロックに置き換えるための道具です。
5. PostgreSQL 18 の無停止DDL強化
PG18 はオンラインDDLを実務的に改善しました(公式リリースノート)。
NOT NULL制約にNOT VALIDを付与可能(§3.2)——無停止の NOT NULL 追加が素直に。NOT NULLをpg_constraintに格納——NOT NULL 制約に名前を付けられる、外部テーブルにも付与可能。- パーティションテーブルに
NOT VALID外部キーを許可——段階的な制約追加が可能に。 NOT ENFORCED制約(CHECK/外部キーを「定義はするが強制しない」)——移行期の柔軟性。- virtual生成列が既定——読み取り時に計算するので追加に書き換え不要(stored は書き換えが必要)。
6. マイグレーション・チェックリスト
本番マイグレーションをレビューするとき、この問いに答えてください。
lock_timeoutを設定したか?(取れなければ即失敗+リトライ)- テーブル書き換えが起きないか?(volatileデフォルト・型変更・stored生成列を避ける)
- 全表スキャンを分割したか?(
NOT VALID→VALIDATE、NOT NULLのCHECK経由) - 索引は
CONCURRENTLYか?(トランザクション外で・失敗時のINVALID後始末も) - 重い変更はシャドウカラムで段階移行したか?
- 長時間トランザクション(
idle in transaction)が走っていないか? - 1マイグレーション1論理変更になっているか?(巨大なDDLの束は失敗時のリスクが高い)
7. まとめ
ALTER TABLEの多くは ACCESS EXCLUSIVE=SELECTすら止める。長時間クエリの後ろで待つと全断に発展する。lock_timeout+リトライで「世界を止める前に諦めさせる」。- 列追加は定数デフォルト(高速)、volatile/型変更(書き換え)は避ける。
- 制約は
NOT VALID→VALIDATE、索引はCONCURRENTLY——強いロックを弱いロックに置き換える。 - 重い変更はシャドウカラムで段階移行(追加→dual-write→バッチ変換→切替→削除)。
- PG18 は
NOT NULL ... NOT VALID等で無停止DDLがさらに素直に。
「変更で止めない」が固まれば、運用の残る一角は「守る」——セキュリティ堅牢化(ロール・権限・TLS・SCRAM) へ。