# PostgreSQL の MVCC・トランザクション分離・VACUUM/autovacuum 実務ガイド（肥大化・行ロック・周回防止・v18対応）

> PostgreSQL の正しさと性能の土台である MVCC を実務目線で解説。トランザクション分離レベル（Read Committed / Repeatable Read / Serializable）と直列化失敗のリトライ、FOR UPDATE SKIP LOCKED によるジョブキュー、デッドタプルと肥大化、VACUUM と VACUUM FULL の違い、autovacuum のチューニングと PostgreSQL 18 の autovacuum_vacuum_max_threshold、トランザクションID周回の防止までを公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-23
- 著者: 友田 陽大
- タグ: PostgreSQL, アーキテクチャ設計, パフォーマンス
- URL: https://tomodahinata.com/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide

## 要点

- MVCC＝『読み取りは書き込みをブロックせず、書き込みは読み取りをブロックしない』。UPDATE は古い行を消さず新バージョンを追記するので、デッドタプル（肥大化）が生まれる
- 既定は READ COMMITTED。PostgreSQL の REPEATABLE READ はファントムも防ぐ（スナップショット分離）。SERIALIZABLE は直列化失敗(SQLSTATE 40001)を投げるのでアプリ側でリトライ必須
- ジョブキューは SELECT ... FOR UPDATE SKIP LOCKED が定石。複数ワーカーが同じ行を奪い合わず、ロック競合を避けられる
- VACUUM はデッドタプルを『再利用可能』にするだけでOSには返さない。VACUUM FULL は書き直して縮小するが ACCESS EXCLUSIVE ロックを取る（本番では基本使わない）
- autovacuum の発火は『50 + 0.2 × 行数』。巨大表で発火が遅すぎる問題を、PostgreSQL 18 の autovacuum_vacuum_max_threshold（既定1億）が上限でキャップする。周回(wraparound)防止のフリーズは autovacuum を切っていても走る

---

PostgreSQL の「速さ」と「正しさ」は、両方とも **MVCC（多版型同時実行制御）** の上に乗っています。MVCC を理解せずにチューニングすると、「なぜか肥大化する」「直列化エラーが出る」「VACUUM が追いつかない」といった問題の**原因が見えません**。

この記事は、MVCC の仕組みから、トランザクション分離レベルの正しい選び方、行ロックによるジョブキュー、そして肥大化対策の本丸 VACUUM/autovacuum までを、PostgreSQL 公式ドキュメントに忠実に、本番運用の実務として解説します。[パフォーマンス総論](/blog/postgresql-performance-tuning-production-guide) の §4（VACUUM）と §7（HOT更新）の深掘りにあたります。

> **この記事のルール**：MVCC の挙動・分離レベルの保証・VACUUM の仕様・autovacuum の既定値・PostgreSQL 18 の変更点は、すべて **PostgreSQL 18 公式ドキュメント（2026年6月時点）** に基づきます。マネージドDBは autovacuum 既定値を上書きしていることがあるため、`SHOW` と `pg_stat_user_tables` で実態を確認してください。

---

## 1. MVCC：読みと書きが互いをブロックしない

PostgreSQL の同時実行制御の核心を、公式は一文で述べています。

> MVCC では、**問い合わせ（読み取り）のために取得するロックと、書き込みのために取得するロックが衝突しない。したがって読み取りが書き込みをブロックすることは決してなく、書き込みが読み取りをブロックすることも決してない。**

これを実現するのが**スナップショット**です。各文（または各トランザクション）は「**ある時点のデータベースのバージョン**」を見ます。あなたが長いレポートを集計している最中に他人が更新しても、あなたのクエリは一貫した過去の断面を見続けます。

### 仕組み：行は「版（バージョン）」で管理される

各行（タプル）は、隠しシステム列を持ちます。

- **`xmin`**：その行版を**挿入した**トランザクションID（XID）。
- **`xmax`**：その行版を**削除・更新で無効化した**XID（未削除なら 0）。
- **`ctid`**：同じ行の**新しい版**への物理ポインタ（版が連鎖する）。

ここが肥大化の出発点です。公式曰く——

> `UPDATE` や `DELETE` は、**古い版の行を即座には削除しない**。他のトランザクションからまだ見える可能性がある間は、削除できないからだ。

つまり **`UPDATE` は「上書き」ではなく「新しい行版の追記＋古い版に xmax を刻む」**。古い版は、どのトランザクションからも見えなくなった時点で**デッドタプル**になります。このデッドタプルを掃除するのが VACUUM（§5）です。

```sql
-- 隠し列を見るとバージョン管理が分かる
SELECT ctid, xmin, xmax, * FROM accounts WHERE id = 1;
-- UPDATE すると同じ id でも ctid（物理位置）が変わり、古い版がデッドタプルになる
```

---

## 2. トランザクション分離レベル：既定と「本当の保証」

SQL標準は4レベルを定義しますが、PostgreSQL が内部的に持つのは**3つ**（Read Uncommitted は Read Committed として振る舞う）。**既定は READ COMMITTED**。

各レベルが**防ぐ**異常（PostgreSQL の実際の挙動）：

| レベル | ダーティリード | 反復不能読み取り | ファントムリード | 直列化異常 |
| --- | --- | --- | --- | --- |
| Read Committed（既定） | 起きない | 起きうる | 起きうる | 起きうる |
| Repeatable Read | 起きない | 起きない | **起きない**※ | 起きうる |
| Serializable | 起きない | 起きない | 起きない | 起きない |

※ここが PostgreSQL の特徴。公式曰く「PostgreSQL の Repeatable Read 実装は**ファントムリードを許さない**」——標準の最低要件より強く、**スナップショット分離**として実装されています。

### いつ何を使うか

- **READ COMMITTED（既定）**：各文が「文の開始時点」のスナップショットを見る。Web アプリの大半はこれで十分。
- **REPEATABLE READ**：トランザクション全体が「最初の文の時点」の一貫スナップショットを見る。**複数のクエリで整合した断面が必要なレポート/集計・残高計算**に。
- **SERIALIZABLE**：**直列実行と同じ結果を保証**（SSI＝Serializable Snapshot Isolation）。最も強いが、競合時に**直列化失敗を投げる**。

```sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- このトランザクション内のすべての SELECT は同じスナップショットを見る
SELECT sum(amount) FROM ledger WHERE account_id = 1;
-- ... 他のクエリ ...
COMMIT;
```

### SERIALIZABLE は「リトライ前提」

公式が強調する最重要点：SERIALIZABLE（および REPEATABLE READ）では、競合時に**直列化失敗**が起き、必ず **SQLSTATE `40001`** を返します。これは**バグではなく正常な動作**で、アプリは**そのトランザクションを丸ごとリトライ**しなければなりません。

```ts
// SQLSTATE 40001（直列化失敗）と 40P01（デッドロック）は「リトライすれば成功する」エラー。
// トランザクション全体を冪等に再実行する汎用ラッパー。
import type { PoolClient } from "pg";
import { pool } from "./db";

const RETRYABLE = new Set(["40001", "40P01"]); // serialization_failure / deadlock_detected

export async function withSerializableRetry<T>(
  work: (tx: PoolClient) => Promise<T>,
  maxAttempts = 5,
): Promise<T> {
  for (let attempt = 1; ; attempt++) {
    const tx = await pool.connect();
    try {
      await tx.query("BEGIN ISOLATION LEVEL SERIALIZABLE");
      const result = await work(tx);
      await tx.query("COMMIT");
      return result;
    } catch (err: unknown) {
      await tx.query("ROLLBACK").catch(() => {});
      const code = (err as { code?: string }).code;
      if (code && RETRYABLE.has(code) && attempt < maxAttempts) {
        // 指数バックオフ＋ジッタで競合を散らす
        await new Promise((r) => setTimeout(r, 2 ** attempt * 5 + Math.random() * 10));
        continue;
      }
      throw err;
    } finally {
      tx.release();
    }
  }
}
```

> **設計の含意**：SERIALIZABLE はロックを増やさずに正しさを担保できる強力な道具ですが、「**40001 は必ずリトライする**」というハンドリングが**前提条件**です。これを用意せずに使うと、たまに失敗する不安定なシステムになります。

---

## 3. 行ロック：FOR UPDATE と SKIP LOCKED でジョブキュー

MVCC でも、「この行を確実に自分が更新する」ために**明示的な行ロック**が要ります。強さ順（強→弱）：

| ロック | 用途 |
| --- | --- |
| `FOR UPDATE` | 行を更新/削除する前提で排他ロック。最強 |
| `FOR NO KEY UPDATE` | キー列を変えない更新用。`FOR KEY SHARE` をブロックしない |
| `FOR SHARE` | 共有ロック。他の更新/削除は防ぐが共有読みは許す |
| `FOR KEY SHARE` | 最弱。キー値を変える更新だけブロック（外部キー検証で使われる） |

### ジョブキューの定石：FOR UPDATE SKIP LOCKED

「複数ワーカーがキューテーブルから仕事を取り合う」ときの正解が `SKIP LOCKED`。公式の定義：

> `SKIP LOCKED` を指定すると、即座にロックできない行は**スキップ**される。…ロック済み行をスキップするとデータの一貫した見え方は得られないため汎用用途には向かないが、**キューのようなテーブルに複数のコンシューマがアクセスする際のロック競合を避ける**のに使える。

これにより、各ワーカーは「他のワーカーが掴んでいない次の仕事」だけを**待たずに**取得できます。

```sql
-- 複数ワーカーが安全に仕事を取り合う（互いに待たず、同じ行を二重処理しない）
WITH next_job AS (
  SELECT id
  FROM jobs
  WHERE status = 'queued'
  ORDER BY priority DESC, created_at
  FOR UPDATE SKIP LOCKED          -- 他ワーカーがロック中の行は飛ばす
  LIMIT 1
)
UPDATE jobs j
SET status = 'running', started_at = now()
FROM next_job
WHERE j.id = next_job.id
RETURNING j.*;
```

```ts
// ワーカー側：1行を掴んで処理し、結果に応じて done/failed に遷移（トランザクションで囲む）
export async function pullAndRunOneJob(run: (job: Job) => Promise<void>) {
  const tx = await pool.connect();
  try {
    await tx.query("BEGIN");
    const { rows } = await tx.query<Job>(`
      WITH next_job AS (
        SELECT id FROM jobs WHERE status = 'queued'
        ORDER BY priority DESC, created_at
        FOR UPDATE SKIP LOCKED LIMIT 1
      )
      UPDATE jobs j SET status='running', started_at=now()
      FROM next_job WHERE j.id = next_job.id
      RETURNING j.*`);
    if (rows.length === 0) { await tx.query("COMMIT"); return null; }
    const job = rows[0];
    await run(job);                                  // 実処理（冪等に設計する）
    await tx.query("UPDATE jobs SET status='done', finished_at=now() WHERE id=$1", [job.id]);
    await tx.query("COMMIT");
    return job;
  } catch (e) {
    await tx.query("ROLLBACK").catch(() => {});
    throw e;
  } finally {
    tx.release();
  }
}
```

`NOWAIT`（待たずに即エラー）や**アドバイザリロック**（アプリ定義の軽量ロック。`pg_advisory_xact_lock` 等。テーブル肥大化を生まず高速）も状況により有効です。

---

## 4. なぜ肥大化（bloat）するのか

§1 のとおり、`UPDATE`/`DELETE` はデッドタプルを残します。デッドタプルが溜まると：

- **テーブル・インデックスが物理的に膨らむ**（ディスク・キャッシュを浪費）。
- スキャンが「生きている行＋死んだ行」を読むので**遅くなる**。
- index-only scan の可視性マップが汚れ、`Heap Fetches` が増える（[EXPLAIN の記事](/blog/postgresql-explain-analyze-slow-query-optimization-guide)）。

緩和の第一歩は **HOT 更新**（[総論 §7.2](/blog/postgresql-performance-tuning-production-guide)）。「索引対象列を変えない更新」かつ「同一ページに空きがある」なら、新しい索引エントリを作らず、通常運用中にデッド版を回収できます。更新の多い表は `fillfactor` を下げて空きを確保します。

```sql
-- デッドタプルの溜まり具合と autovacuum の最終実行を監視
SELECT relname, n_live_tup, n_dead_tup,
       round(n_dead_tup::numeric / nullif(n_live_tup,0), 3) AS dead_ratio,
       last_autovacuum
FROM pg_stat_user_tables
ORDER BY n_dead_tup DESC
LIMIT 20;
```

---

## 5. VACUUM と VACUUM FULL：似て非なるもの

VACUUM は4つの仕事をします（公式）：①デッドタプルの領域を**再利用可能にする**、②プランナー統計の更新、③可視性マップの更新（index-only scan を速くする）、④**トランザクションID周回からの保護**（§7）。

ここで**致命的によくある誤解**を正します。

- **通常の `VACUUM`**：デッドタプルの領域を**「再利用可能」とマークするだけ**で、**OS にディスクを返しません**（末尾ページが完全に空いた特殊ケースを除く）。本番稼働中に並行実行でき、`SELECT`/`INSERT`/`UPDATE`/`DELETE` を止めません。
- **`VACUUM FULL`**：テーブルを**まるごと書き直して**デッド領域を排除し、**OS にも領域を返す**。ただし **`ACCESS EXCLUSIVE` ロック**を取り、その間そのテーブルは**完全に使えません**。

```sql
VACUUM (VERBOSE, ANALYZE) orders;   -- 通常運用。並行可能。領域は再利用可能になる
-- VACUUM FULL orders;              -- ⚠ テーブルを排他ロック。本番のオンライン中は基本使わない
```

> **本番での鉄則**：肥大化が深刻でも、軽々しく `VACUUM FULL` を打たない（テーブルが止まる）。オンラインで物理縮小したいなら `pg_repack`（拡張）等を検討。**そもそも肥大化を溜めない＝autovacuum を効かせる**のが本筋です。

---

## 6. autovacuum を効かせる（PG18 の新機能つき）

autovacuum は、デッドタプルが一定量たまった表を自動で VACUUM/ANALYZE するバックグラウンド機構です（既定 `on`）。**発火のしきい値**は公式の式：

```text
vacuum しきい値 = autovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × 行数
            既定 =            50            +          0.2（＝20%）        × reltuples
```

主な既定値：

| パラメータ | 既定 |
| --- | --- |
| `autovacuum_vacuum_threshold` | 50 |
| `autovacuum_vacuum_scale_factor` | 0.2（20%） |
| `autovacuum_analyze_scale_factor` | 0.1（10%） |
| `autovacuum_max_workers` | 3 |
| `autovacuum_naptime` | 1min |
| `autovacuum_vacuum_cost_delay` | 2ms |

### 巨大表の落とし穴と PostgreSQL 18 の解決

「20%」は小さい表では妥当ですが、**巨大表では致命的**です。1億行の表なら「2000万行がデッドになるまで」VACUUM が走らず、その間ずっと肥大化が進みます。

従来は表ごとに `scale_factor` を下げて対処していました。

```sql
-- 巨大で更新の多い表は、行数比ではなく小さめのしきい値で頻繁に VACUUM
ALTER TABLE big_events SET (
  autovacuum_vacuum_scale_factor = 0.02,    -- 2% で発火
  autovacuum_vacuum_cost_delay   = 0        -- VACUUM を速く回す（I/O に余裕がある場合）
);
```

**PostgreSQL 18 はこれを正面から解決しました**。新パラメータ **`autovacuum_vacuum_max_threshold`（既定 1億）** が、上の式の計算値に**上限（キャップ）**を設けます。公式の説明：

> 1つのテーブルで VACUUM を起動するのに必要な、更新・削除タプル数の**最大値**を指定する（`autovacuum_vacuum_threshold` と `autovacuum_vacuum_scale_factor` で計算される値の上限）。

効果：巨大表でも「デッドタプルが1億に達したら」VACUUM が走るようになり、「20% まで待つ」問題が既定で緩和されます。**PG18 にアップグレードするだけで、巨大表の肥大化が起きにくくなる**ということです。

---

## 7. トランザクションID周回（wraparound）：最も怖い障害

これは性能の話ではなく**データ消失の話**です。XID は 32bit。公式の警告：

> トランザクションIDはサイズが有限（32bit）なので、長期間稼働するクラスタ（40億トランザクション超）は**トランザクションID周回**に陥る。XID カウンタがゼロに巻き戻り、過去のトランザクションが突然「未来」に見え、その出力が**不可視**になる。要するに**破滅的なデータ損失**。

これを防ぐのが**フリーズ**——十分古い行版を「常に過去」とみなす特別なXIDで凍結します。フリーズを駆動するのが autovacuum で、**ここが重要**：公式曰く、周回防止のための autovacuum は **`autovacuum` を無効にしていても走ります**（既定 `autovacuum_freeze_max_age` = **2億**トランザクション）。

```sql
-- 各DBの「あと何トランザクションで危険か」を監視（age が大きいほど危険）
SELECT datname, age(datfrozenxid) AS xid_age
FROM pg_database
ORDER BY xid_age DESC;
-- age が autovacuum_freeze_max_age（既定2億）に近づいたら要対応
```

> **運用の含意**：「autovacuum は重いから切る」は**厳禁**。切っても周回防止 VACUUM は走り、しかも溜め込んだ分、**いつか巨大な anti-wraparound VACUUM が一気に走って I/O を飽和**させます。autovacuum は「切る」のではなく「**ワークロードに合わせて調整する**」のが正解です。

---

## 8. 監視チェックリスト

```sql
-- ① 肥大化と autovacuum の効き（§4 のクエリ）
-- ② 長時間 idle in transaction（VACUUM を妨げ、デッドタプルを掃除できなくする元凶）
SELECT pid, state, now() - xact_start AS xact_age, substring(query,1,60) AS query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
ORDER BY xact_start;

-- ③ 周回リスク（§7 のクエリ）
-- ④ ブロックしているロック
SELECT blocked.pid AS blocked_pid, blocking.pid AS blocking_pid,
       substring(blocked.query,1,40) AS blocked_query
FROM pg_stat_activity blocked
JOIN pg_stat_activity blocking
  ON blocking.pid = ANY(pg_blocking_pids(blocked.pid))
WHERE blocked.wait_event_type = 'Lock';
```

特に **`idle in transaction` の放置**は MVCC 最大の運用事故です。古いスナップショットを握り続けるため、「最も古い実行中トランザクションより新しいデッドタプル」を VACUUM が掃除できず、肥大化が止まりません。アプリ側で**トランザクションを短く保ち、必ず COMMIT/ROLLBACK する**ことが、性能にも直結します。

---

## 9. まとめ

- **MVCC**：読みと書きが互いをブロックしない。代償として `UPDATE`/`DELETE` は**デッドタプル（肥大化）**を生む。
- **分離レベル**：既定 READ COMMITTED。PostgreSQL の REPEATABLE READ はファントムも防ぐ。SERIALIZABLE は **40001 を必ずリトライ**して使う。
- **行ロック**：ジョブキューは `FOR UPDATE SKIP LOCKED` が定石。
- **VACUUM ≠ VACUUM FULL**：通常 VACUUM は領域を再利用可能にするだけ（OSに返さない・並行可能）。FULL は縮小するが排他ロック——本番では基本使わない。
- **autovacuum** は切らずに調整。**PG18 の `autovacuum_vacuum_max_threshold`（1億）** が巨大表の肥大化を既定で緩和。
- **周回防止**のフリーズは autovacuum を切っても走る。`idle in transaction` の放置が最大の運用事故。

MVCC の挙動が腹落ちすると、「肥大化」「直列化エラー」「VACUUM 詰まり」が**因果でつながって**見えます。次は、半構造データを RDB に載せる [JSONB の実践](/blog/postgresql-jsonb-operators-gin-index-design-guide) へ。

---

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

- [Chapter 13. Concurrency Control（MVCC）](https://www.postgresql.org/docs/18/mvcc-intro.html)
- [13.2. Transaction Isolation](https://www.postgresql.org/docs/18/transaction-iso.html)
- [13.3. Explicit Locking（FOR UPDATE / SKIP LOCKED）](https://www.postgresql.org/docs/18/explicit-locking.html)
- [25.1. Routine Vacuuming（VACUUM・周回防止）](https://www.postgresql.org/docs/18/routine-vacuuming.html)
- [19.10. Automatic Vacuuming（autovacuum 既定値）](https://www.postgresql.org/docs/18/runtime-config-autovacuum.html)
