# PostgreSQL ゼロダウンタイムのスキーマ変更（ロックセーフDDL・lock_timeout・NOT VALID→VALIDATE・CONCURRENTLY・v18対応）

> 本番のPostgreSQLでダウンタイムなしにスキーマを変更する実践ガイド。ACCESS EXCLUSIVEロックがSELECTごと止める仕組み、lock_timeoutで世界を止めない方法、列追加(PG11+の高速パス)、NOT NULL/制約のNOT VALID→VALIDATE二段階、CONCURRENTLYな索引、型変更のシャドウカラム戦略、ロックレベル早見表、PostgreSQL 18のNOT NULL NOT VALIDまでを公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-21
- 著者: 友田 陽大
- タグ: PostgreSQL, アーキテクチャ設計
- URL: https://tomodahinata.com/blog/postgresql-zero-downtime-schema-migration-lock-safe-ddl-guide

## 要点

- ALTER TABLEの多くは ACCESS EXCLUSIVE ロックを取り、SELECTすらブロックする。長時間クエリの後ろでロック待ちに入ると、新規クエリが全部後ろに積み上がり実質全断になる
- 対策の起点は lock_timeout：マイグレーションを短いタイムアウトで即失敗させ、リトライする。世界を止める前に諦めさせる
- 列追加は定数デフォルトならPG11+で書き換え不要＝高速。volatileデフォルト・stored生成列・型変更はテーブル全体を書き換えるので避ける(virtual生成列は書き換え不要)
- 制約は NOT VALID で即追加→VALIDATE CONSTRAINT で弱いロック(SHARE UPDATE EXCLUSIVE)検証の二段階。ADD FOREIGN KEYはACCESS EXCLUSIVEではなくSHARE ROW EXCLUSIVE。索引は CONCURRENTLY
- PostgreSQL 18はNOT NULL制約にNOT VALIDを付与可能になり、無停止のNOT NULL追加が素直に。NOT ENFORCED制約・パーティション表のNOT VALID外部キーも追加

---

「カラムを1つ追加するだけ」のマイグレーションで、本番が全断した——PostgreSQL を運用していれば、いつか必ず聞く話です。原因は**ロック**。`ALTER TABLE` の多くは強力なロックを取り、しかも**長時間クエリの後ろで待たされると、その後ろに全クエリを巻き込んで止めます**。

この記事は、本番で**ダウンタイムなしにスキーマを変更する**ための実践プレイブックです。ロックの仕組みから、操作ごとの安全な手順、`lock_timeout` による安全装置までを、公式ドキュメントに忠実に解説します。発注者が「この人にマイグレーションを任せて大丈夫か」を見るなら、ここが分水嶺。[本番運用ガイド §4](/blog/postgresql-production-operations-guide)の深掘りです。

> **この記事のルール**：ロックレベル・各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` を設定し、取れなければ即失敗させ、**リトライ**します。これで「長時間クエリの後ろで待ち続けて全断」を防げます。

```sql
-- マイグレーションの定石：短いロックタイムアウトで即失敗 → 後でリトライ
SET lock_timeout = '3s';
SET statement_timeout = '0';      -- 文自体の実行は別管理（lock_timeout は「待ち」だけ）
ALTER TABLE orders ADD COLUMN memo text;
```

```ts
// マイグレーションランナー側：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](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide)）。

---

## 3. 操作別・安全なレシピ

### 3.1 列の追加：定数デフォルトは高速、volatile は危険

公式：「**非volatileなDEFAULT**で `ADD COLUMN` すると、デフォルト値は文の実行時に評価され、**テーブルのメタデータに格納**される。…既存行のアクセス時に返される。**テーブルの書き換えは不要**で、巨大表でも ALTER は非常に高速」。

逆に**書き換えが起きる**ケース（公式）：「**volatileなDEFAULT**（例 `clock_timestamp()`）、**stored生成列**、**identity列**、制約付きドメイン型を追加すると、**テーブルと索引が全書き換え**される。**virtual生成列の追加は書き換え不要**」。

```sql
-- ✅ 高速：定数デフォルト（書き換えなし・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` を付与可能**に。

```sql
-- 旧来の道：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 ロックだけ**を取る。

```sql
-- 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` の二段階が安全です。

```sql
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 で書き込みを止めない

```sql
-- 書き込みを止めずに索引を作る（[索引設計の記事]も参照）
CREATE INDEX CONCURRENTLY idx_orders_status ON orders (status);
```

注意（公式・[インデックス設計記事 §6](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)も参照）：2回スキャンで遅い、**トランザクションブロック内で実行不可**、失敗すると**INVALID索引が残る**（`DROP INDEX CONCURRENTLY` して再実行）。

### 3.5 型変更：シャドウカラム戦略

`ALTER COLUMN ... TYPE` は公式曰く「通常、**テーブルと索引が全書き換え**される」（例外は text↔varchar）。巨大表では危険なので、**新しい列を足して段階移行**します。

```sql
-- 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. マイグレーション・チェックリスト

本番マイグレーションをレビューするとき、この問いに答えてください。

1. **`lock_timeout` を設定したか？**（取れなければ即失敗＋リトライ）
2. **テーブル書き換えが起きないか？**（volatileデフォルト・型変更・stored生成列を避ける）
3. **全表スキャンを分割したか？**（`NOT VALID`→`VALIDATE`、NOT NULLのCHECK経由）
4. **索引は `CONCURRENTLY` か？**（トランザクション外で・失敗時のINVALID後始末も）
5. **重い変更はシャドウカラムで段階移行したか？**
6. **長時間トランザクション（`idle in transaction`）が走っていないか？**
7. **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）](/blog/postgresql-security-hardening-roles-privileges-ssl-scram-guide) へ。

---

### 参考（PostgreSQL 18 公式ドキュメント）

- [ALTER TABLE（ロックレベル・NOT VALID・列追加の挙動）](https://www.postgresql.org/docs/18/sql-altertable.html)
- [13.3. Explicit Locking（テーブルロックモード）](https://www.postgresql.org/docs/18/explicit-locking.html)
- [19.11. Client Connection Defaults（lock_timeout）](https://www.postgresql.org/docs/18/runtime-config-client.html)
- [CREATE INDEX（CONCURRENTLY）](https://www.postgresql.org/docs/18/sql-createindex.html)
- [E.4. Release 18（NOT NULL NOT VALID 等）](https://www.postgresql.org/docs/18/release-18.html)
