Skip to content
Published on

PostgreSQL アーキテクチャを深く見る — MVCC からベクトルまで

Authors

はじめに

PostgreSQL は長い時間をかけて磨かれた、堅牢で拡張可能なオープンソースのリレーショナルデータベースです。単に SQL を実行するツールを超え、並行性制御、ストレージ構造、クエリ最適化、レプリケーション、拡張性までが一つの一貫した設計思想で結ばれています。

本記事は PostgreSQL の内部を「プロセスとメモリはどんな形で、トランザクションは互いをどう見ないようにし、クエリはどの経路で実行されるか」という問いを中心に解きほぐします。図を多く使って直観をまず立て、細かな挙動はバージョンによって変わり得るため、正確な動作は公式ドキュメント(postgresql.org)で確認することをおすすめします。


1. プロセスとメモリ構造

PostgreSQL はマルチプロセスアーキテクチャを採用します。クライアントが接続するたびに専用のバックエンドプロセスが生まれ、複数のバックグラウンドプロセスが共有メモリを中心に協力します。

                  ┌──────────────────────────────────┐
   クライアント ─▶│  Postmaster(メインプロセス)       │
   クライアント ─▶│  - 接続受付、バックエンド fork      │
                  └───────────────┬──────────────────┘
                                  │ fork
        ┌─────────────────────────┼─────────────────────────┐
        ▼                         ▼                         ▼
  ┌───────────┐            ┌───────────┐             ┌───────────┐
  │ Backend 1 │            │ Backend 2 │             │ Backend N │
  │ (セッション専用)│        │ (セッション専用)│         │ (セッション専用)│
  └─────┬─────┘            └─────┬─────┘             └─────┬─────┘
        │                        │                         │
        └────────────┬───────────┴────────────┬────────────┘
                     ▼                         ▼
         ┌───────────────────────────────────────────────┐
         │              共有メモリ(Shared Memory)         │
         │  ┌─────────────────┐  ┌─────────────────────┐  │
         │  │ Shared Buffers   │  │ WAL Buffers         │  │
         │  │ (テーブル/索引ページ)│  │ (変更ログバッファ)    │  │
         │  └─────────────────┘  └─────────────────────┘  │
         │  ┌─────────────────┐  ┌─────────────────────┐  │
         │  │ Lock / CLOG      │  │ その他の共有状態      │  │
         │  └─────────────────┘  └─────────────────────┘  │
         └───────────────────────────────────────────────┘
                     ▲                         ▲
        ┌────────────┴───────────┬─────────────┴────────────┐
        ▼                        ▼                          ▼
  ┌───────────┐           ┌──────────────┐          ┌──────────────┐
  │ Background│           │ WAL Writer    │          │ Checkpointer │
  │ Writer    │           │ (WAL をディスク)│          │              │
  └───────────┘           └──────────────┘          └──────────────┘
  ┌───────────┐           ┌──────────────┐
  │ Autovacuum│           │ Archiver など │
  └───────────┘           └──────────────┘

中核のメモリ領域

  • Shared Buffers: ディスクのテーブル/索引ページをメモリにキャッシュする共有プール。読み書きはほとんどここで起こり、ディスク I/O を減らす第一の防御線です。
  • WAL Buffers: 変更をまず記録する WAL(Write-Ahead Log)レコードを一時的にためるバッファ。
  • work_mem / maintenance_work_mem: ソート・ハッシュ・VACUUM など作業ごとにバックエンドが使う作業メモリ。
読み取り経路:  クエリ ─▶ Shared Buffers ヒット? ─はい─▶ メモリから返す
                                │ いいえ
                       ディスクからページ読込 ─▶ Shared Buffers に載せる ─▶ 返す

書き込み経路:  変更 ─▶ Shared Buffers のページをダーティに印
                  ─▶ WAL に変更を記録(先に!)
                  ─▶ 後で Background Writer/Checkpointer がディスク反映

WAL を先に書くこと(Write-Ahead Logging)が核心です。データファイルより先にログを安全に記録するので、障害が起きてもログを再生(replay)して一貫性を回復できます。


2. MVCC — 多版数並行性制御

PostgreSQL の並行性モデルは **MVCC(Multi-Version Concurrency Control)**です。核心の考えは「読みは書きを妨げず、書きは読みを妨げない」です。これを実現するために、同じ行(row)の複数バージョンを保持します。

各行バージョン(タプル)には見えないシステム列が付きます。

タプルヘッダの可視性情報
┌──────────┬─────────────────────────────────────┐
│ xmin     │ このバージョンを生成したトランザクション ID │
│ xmax     │ このバージョンを削除/更新したトランザクション ID │
│ ctid     │ 物理的位置(ブロック、オフセット)       │
└──────────┴─────────────────────────────────────┘

UPDATE は既存の行をその場で直さず、新バージョンを追加して旧バージョンに xmax を印します。これが PostgreSQL の UPDATE が事実上「削除 + 挿入」に近く動く理由です。

UPDATE 前:
  [v1: xmin=100, xmax=0  ]   ← 現在見えるバージョン

UPDATE(txid 150)後:
  [v1: xmin=100, xmax=150]   ← 旧バージョン、150 により失効
  [v2: xmin=150, xmax=0  ]   ← 新バージョン

可視性の判定

どのトランザクションがどのバージョンを見られるかはスナップショットで決まります。スナップショットは「今この時点でコミット済みのトランザクションは何か」を捉えます。

可視性ルール(簡略化)
  バージョンが見えるには:
   1) xmin のトランザクションがコミット済みで、私のスナップショットより前にあり
   2) xmax がないか、xmax のトランザクションがまだコミットされていない

  ──▶ したがって同時に走る二つのトランザクションは
      互いに異なる「バージョンの世界」を見られる。

VACUUM — 死んだタプルの掃除

MVCC の代償は「死んだタプル(dead tuple)」の蓄積です。もはやどのスナップショットからも見えない旧バージョンは空間だけを占めます。これを回収するのが VACUUM です。

  死んだタプルの蓄積 ──▶ テーブル肥大(bloat)──▶ 性能低下
  VACUUM:      死んだタプルの空間を再利用可能リストへ回収
  VACUUM FULL: テーブルを書き直して物理的に縮小(重いロック)
  Autovacuum:  閾値到達時に自動実行されるバックグラウンド掃除

また、トランザクション ID は有限なので、古いタプルの ID を「凍結(freeze)」して wraparound 問題を防ぐのも VACUUM の重要な役割です。autovacuum のチューニングは運用で非常に重要です。


3. インデックス構造

PostgreSQL はデータと問い合わせの型に合わせて複数のインデックス種類を提供します。正しいインデックス選択がそのまま性能です。

インデックス適する問い合わせ代表例
B-tree等号/範囲/整列主キー、一般列
Hash等号のみ単純な等値比較
GIN多値の包含配列、JSONB、全文検索
GiST幾何/近接/範囲位置データ、範囲型
BRIN物理整列された大容量時系列、ログ
B-tree(最も一般的)
            [ 50 ]
           /      \
      [20 35]    [70 90]
       / | \      / | \
     リーフノード(整列キー + 行ポインタ)が互いに連結
     ──▶ 範囲スキャンが非常に効率的

BRIN(ブロック範囲インデックス)
   テーブルブロックを区間にまとめ「この区間の最小/最大」のみ格納
   ┌──────────┬──────────┬──────────┐
   │ blk 0-127│ blk128-255│ blk256-..│
   │ min/max  │ min/max   │ min/max  │
   └──────────┴──────────┴──────────┘
   ──▶ インデックスが非常に小さく、自然整列された大容量に適する

GIN は一つの行が多くのキーを持つとき(例: JSONB 文書、配列、単語トークン)に強力です。GiST は「近さ」や「重なり」のような空間/範囲問い合わせに適します。


4. クエリ実行 — プランナと実行器

SQL 一行が結果に変わるまでにはいくつかの段階を経ます。

  SQL テキスト
  [ パーサ ] ──▶ 構文木(パースツリー)
  [ 解析/書き換え ] ──▶ ビュー展開、ルール適用
  [ プランナ/オプティマイザ ]
     │   - 可能な実行計画を生成
     │   - 統計(pg_statistic)で各計画の「コスト」を推定
     │   - 最も安い計画を選択
  [ 実行器(Executor)] ──▶ 計画ツリーに沿ってタプルを引き上げる
  結果

コストベース最適化

オプティマイザは統計を基にコストを推定します。同じ問い合わせでもデータ分布によって、インデックススキャンが良いことも、順次スキャンが良いこともあります。

  WHERE status = 'active'

  分岐 1) 'active' が全体の 1% ──▶ インデックススキャンが安い
  分岐 2) 'active' が全体の 90% ──▶ 順次スキャンの方が安い
                                    (ランダム I/O より順次 I/O が速い)

  ──▶ ANALYZE で最新統計を保つことでオプティマイザが正しく選ぶ。

結合戦略

Nested Loop : 片方が小さくインデックスがあるとき有利
Hash Join   : 大きな二つのテーブルを等号結合するとき(ハッシュ表構築)
Merge Join  : 両側が整列されているとき効率的

EXPLAIN ANALYZE は推定計画と実際の実行を併せて示すため、推定と現実の乖離を診断する最も重要なツールです。

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE customer_id = 42 AND status = 'paid';

5. レプリケーション — ストリーミングと論理

PostgreSQL のレプリケーションは WAL を基礎とします。核心の区別は「物理レプリケーション(ストリーミング)」と「論理レプリケーション」です。

ストリーミングレプリケーション(物理)
  Primary ──── WAL レコードストリーム ───▶ Standby(バイト単位の複製)
     │                                       │
   書き込み可                            読み取り専用(ホットスタンバイ)
  ──▶ クラスタ全体をまるごと複製、バージョン/構造が同一である必要

論理レプリケーション(パブリッシュ/サブスクライブ)
  Publisher ── 変更(行単位 INSERT/UPDATE/DELETE)──▶ Subscriber
  ──▶ テーブル単位で選択的に複製可能
  ──▶ 異なるメジャーバージョン間、部分複製、データ統合に有利

同期 vs 非同期

非同期(既定): Primary が先にコミットを確定、Standby は追従
                ──▶ 速いが、障害時に末尾の一部を失う可能性

同期:          Standby が WAL 受信を確認してコミット確定
                ──▶ 安全だが遅延が増す

レプリケーションは高可用性(HA)と読み取りスケーリングの基礎です。読み取り負荷を Standby に分散し、障害時には昇格(failover)で可用性を確保します。


6. パーティショニング

大きなテーブルを論理的に一つのように扱いつつ、物理的には複数の断片(パーティション)に分ける手法です。

            orders(親、パーティションキー: order_date)
            ┌──────────────┬──────────────┬──────────────┐
            ▼              ▼              ▼              ▼
       orders_2026q1  orders_2026q2  orders_2026q3  orders_2026q4
       (1〜3月)        (4〜6月)       (7〜9月)       (10〜12月)
CREATE TABLE orders (
  id bigint,
  order_date date NOT NULL,
  amount numeric
) PARTITION BY RANGE (order_date);

CREATE TABLE orders_2026q1 PARTITION OF orders
  FOR VALUES FROM ('2026-01-01') TO ('2026-04-01');

パーティショニング最大の利点は**パーティションの刈り込み(pruning)**です。問い合わせ条件が特定のパーティションのみを指せば、残りはまったくスキャンしません。

  WHERE order_date >= '2026-07-01'

  プランナ:  q1, q2 パーティションを除外(prune)─▶ q3, q4 のみスキャン
             ──▶ スキャン範囲が減り性能向上

範囲(RANGE)以外に LIST、HASH パーティショニングも対応し、古いパーティションをまるごと分離(DETACH)してデータ寿命管理を単純化できます。


7. 拡張(Extension)エコシステム — pgvector を中心に

PostgreSQL の大きな強みは、**拡張(extension)**でコア機能を広げられる点です。代表例が AI 時代に注目される pgvector です。

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE documents (
  id bigserial PRIMARY KEY,
  content text,
  embedding vector(1536)   -- 埋め込み次元
);

ベクトル類似検索は「意味的に近い」文書を見つけるのに使います。距離演算子で最近傍を問い合わせます。

-- コサイン距離で最も近い 5 件
SELECT id, content
FROM documents
ORDER BY embedding <=> '[0.12, -0.04, ...]'
LIMIT 5;

大規模では近似最近傍探索(ANN)インデックスで速度を確保します。

厳密探索(brute force) : すべてのベクトルと距離計算 ──▶ 正確だが遅い
ANN インデックス(HNSW/IVF): グラフ/クラスタ構造で候補を絞って探索
                            ──▶ わずかな近似の代償で大きな高速化
        RAG パイプラインでの位置
  文書 ─▶ 埋め込み ─▶ [pgvector テーブルに保存]
  質問 ─▶ 埋め込み ─▶ [<=> で類似文書検索] ─▶ LLM のコンテキストへ注入

このほか PostGIS(空間)、pg_stat_statements(クエリ統計)、各種の外部データラッパー(FDW)など、豊富な拡張エコシステムが PostgreSQL を「プログラマブルなデータプラットフォーム」にしています。


8. 運用でよく出会う落とし穴

[ ] Autovacuum を切るか放置 ─▶ テーブル bloat、wraparound リスク
[ ] 統計未更新(ANALYZE 不在)─▶ オプティマイザ誤判、誤った計画
[ ] インデックス乱用 ─▶ 書き込みコスト増、ディスク/保守の負担
[ ] long-running トランザクション放置 ─▶ VACUUM が死んだタプルを掃けない
[ ] 接続数の急増 ─▶ プロセスごとのメモリ負担、プーラ(PgBouncer)を検討
[ ] work_mem の過大設定 ─▶ 並行時にメモリ爆発

特に「長く開いたトランザクション」は静かな殺し屋です。トランザクションが長く開いていると、そのスナップショットが旧バージョンを「必要」とし続け、VACUUM が死んだタプルを回収できず bloat が積み重なります。


9. チェックポイントとクラッシュリカバリ

WAL は変更をまず記録しますが、データファイルに実際に反映される時点は別にあります。その同期点が**チェックポイント(checkpoint)**です。

   時間 ───────────────────────────────────────────▶
   │        │                 │                 │
   チェックポイント A     チェックポイント B     チェックポイント C
   │◀── この区間の変更は WAL のみに記録 ──▶│
   チェックポイント時: ダーティページをディスクに flush
                      ──▶ この時点より前の WAL はリカバリに不要

クラッシュが起きると、最後のチェックポイント以降の WAL だけを再生(replay)すればよいのです。チェックポイントが頻繁ならリカバリは速くなりますが I/O 負担が増し、まれならその逆です。このトレードオフの調整が運用の一軸です。

   クラッシュ発生 ──▶ 再起動
   最後のチェックポイント位置を確認
   その後の WAL レコードを順に再生(REDO)
   コミットされていないトランザクションを整理 ──▶ 一貫した状態へ復旧完了

PITR(Point-In-Time Recovery)

WAL をアーカイブしておけば、単純なリカバリを超えて「特定の時点」へ巻き戻す PITR が可能です。ベースバックアップの上に、望む時刻までの WAL を再生する方式です。

   ベースバックアップ(スナップショット)──▶ + アーカイブ WAL 再生 ──▶ 目標時刻の状態
   ──▶ 誤って消したデータの直前時点へ復旧するのに有用

このメカニズムはバックアップ/リカバリ戦略と高可用性設計の中核的な基盤であり、本番環境では定期的なベースバックアップと WAL アーカイブを併せて運用することが推奨されます。


おわりに

PostgreSQL のアーキテクチャは一つの一貫した思想で編まれています。MVCC は並行性のためにバージョンを増やし、その代償を VACUUM が返します。WAL は耐久性とレプリケーションの基礎となり、コストベースのオプティマイザは統計という燃料で最善の経路を選びます。インデックスとパーティショニングはデータの形に合わせた道具で、拡張はコアを新たな領域へ伸ばします。

これらのメカニズムを理解すれば、「なぜこのクエリが遅いのか」「なぜディスクが埋まり続けるのか」といった運用上の問いに構造的に答えられます。細かな挙動と既定値はバージョンによって変わり得るため、実際のチューニングと運用では公式ドキュメントを併せて参照することをおすすめします。


参考資料