PostgreSQL の「速さ」と「正しさ」は、両方とも MVCC(多版型同時実行制御) の上に乗っています。MVCC を理解せずにチューニングすると、「なぜか肥大化する」「直列化エラーが出る」「VACUUM が追いつかない」といった問題の原因が見えません。
この記事は、MVCC の仕組みから、トランザクション分離レベルの正しい選び方、行ロックによるジョブキュー、そして肥大化対策の本丸 VACUUM/autovacuum までを、PostgreSQL 公式ドキュメントに忠実に、本番運用の実務として解説します。パフォーマンス総論 の §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)です。
-- 隠し列を見るとバージョン管理が分かる
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)。最も強いが、競合時に直列化失敗を投げる。
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- このトランザクション内のすべての SELECT は同じスナップショットを見る
SELECT sum(amount) FROM ledger WHERE account_id = 1;
-- ... 他のクエリ ...
COMMIT;
SERIALIZABLE は「リトライ前提」
公式が強調する最重要点:SERIALIZABLE(および REPEATABLE READ)では、競合時に直列化失敗が起き、必ず SQLSTATE 40001 を返します。これはバグではなく正常な動作で、アプリはそのトランザクションを丸ごとリトライしなければなりません。
// 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を指定すると、即座にロックできない行はスキップされる。…ロック済み行をスキップするとデータの一貫した見え方は得られないため汎用用途には向かないが、キューのようなテーブルに複数のコンシューマがアクセスする際のロック競合を避けるのに使える。
これにより、各ワーカーは「他のワーカーが掴んでいない次の仕事」だけを待たずに取得できます。
-- 複数ワーカーが安全に仕事を取り合う(互いに待たず、同じ行を二重処理しない)
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.*;
// ワーカー側: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 の記事)。
緩和の第一歩は HOT 更新(総論 §7.2)。「索引対象列を変えない更新」かつ「同一ページに空きがある」なら、新しい索引エントリを作らず、通常運用中にデッド版を回収できます。更新の多い表は fillfactor を下げて空きを確保します。
-- デッドタプルの溜まり具合と 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ロックを取り、その間そのテーブルは完全に使えません。
VACUUM (VERBOSE, ANALYZE) orders; -- 通常運用。並行可能。領域は再利用可能になる
-- VACUUM FULL orders; -- ⚠ テーブルを排他ロック。本番のオンライン中は基本使わない
本番での鉄則:肥大化が深刻でも、軽々しく
VACUUM FULLを打たない(テーブルが止まる)。オンラインで物理縮小したいならpg_repack(拡張)等を検討。そもそも肥大化を溜めない=autovacuum を効かせるのが本筋です。
6. autovacuum を効かせる(PG18 の新機能つき)
autovacuum は、デッドタプルが一定量たまった表を自動で VACUUM/ANALYZE するバックグラウンド機構です(既定 on)。発火のしきい値は公式の式:
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 を下げて対処していました。
-- 巨大で更新の多い表は、行数比ではなく小さめのしきい値で頻繁に 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億トランザクション)。
-- 各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. 監視チェックリスト
-- ① 肥大化と 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 の実践 へ。