# PostgreSQL 本番パフォーマンスチューニング総論（v18対応）：計測→インデックス→実行計画→メモリ→VACUUM を正しい順で速くする

> PostgreSQL を本番で速くするための体系的ガイド。pg_stat_statements での計測起点、EXPLAIN による実行計画の読み方、shared_buffers/work_mem などメモリ設定の意味、MVCC/VACUUM とインデックス、接続プーリング、PostgreSQL 18 の非同期I/O・B-treeスキップスキャンまでを、公式ドキュメント（v18）に忠実な実コードで解説します。

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

## 要点

- チューニングは『推測』ではなく『計測』から。pg_stat_statements で重いクエリを特定し、EXPLAIN (ANALYZE, BUFFERS) で実行計画を見てから手を打つ
- 効く順番がある：①インデックス・クエリ設計 → ②統計情報（ANALYZE）→ ③メモリ設定（work_mem は『クエリ単位』ではなく『ノード単位』）→ ④VACUUM/autovacuum → ⑤接続プーリング → ⑥パーティショニング
- shared_buffers の初期目安は RAM の約25%、40%超は逆効果になりやすい。work_mem の総使用量は work_mem × 同時ソート/ハッシュ数 × 同時セッション数になり得る
- PostgreSQL 18 で非同期I/O（io_method=worker が既定）が入り、シーケンシャルスキャン・bitmap heap scan・VACUUM が最大3倍高速化。EXPLAIN ANALYZE は BUFFERS が既定でON、複合B-treeにスキップスキャンが追加
- SSDなら random_page_cost を 4.0 から下げる（例 1.1）。バルクロードは COPY＋索引後付け＋最後に必ず ANALYZE

---

「PostgreSQL が遅い」という相談の9割は、**正しい順番で計測していない**ことに原因があります。`shared_buffers` をいきなり巨大化したり、当てずっぽうでインデックスを増やしたり——それは多くの場合、効かないどころか**書き込みを遅くし、プランナーを混乱させ、メモリを食い潰します**。

この記事は、PostgreSQL を本番で速くするための**全体地図**です。「どこから手を付け、何を計測し、どの順で最適化するか」という方法論を軸に、インデックス・実行計画・メモリ・VACUUM・接続プーリング・パーティショニングを**正しい優先順位**で並べ直します。各テーマの深掘りは個別記事に譲り、本稿は**「全体を貫く設計判断」**に集中します。

> **この記事のルール**：設定項目の既定値・意味・SQL構文・PostgreSQL 18 の新機能は、すべて **PostgreSQL 18 公式ドキュメント（2026年6月時点）** に基づきます。**マネージドサービス（RDS/Aurora・Cloud SQL・Supabase 等）の既定値はエンジンのマイナーやサービス側の上書きで本家と異なる**ことがあります。本番に適用する前に、必ず**お使いのインスタンスで `SHOW <param>;` と実測**で確認してください。数値はワークロードで変わり得ます。

---

## 1. 大原則：推測するな、計測せよ

パフォーマンスチューニングの鉄則は「**Don't speculate, measure.**」です。PostgreSQL のプランナーはコストベースで動き、その判断は**統計情報**と**設定値**から導かれます。人間の直感は、たいてい外れます。

最適化に着手する前に、必ずこの2点を押さえます。

1. **どのクエリが、どれだけ全体の負荷を占めているか**（→ `pg_stat_statements`）
2. **その遅いクエリは、なぜ遅いのか**（→ `EXPLAIN (ANALYZE, BUFFERS)`）

そして、以下の**効く順番**で手を入れます。上ほど効果が大きく、コストが低い。

| 優先度 | レイヤー | 主な武器 | 深掘り記事 |
| --- | --- | --- | --- |
| 1 | **クエリ・インデックス設計** | 適切な索引、N+1除去、SARGable な述語 | [インデックス設計](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide) |
| 2 | **実行計画の理解** | `EXPLAIN ANALYZE`、統計の鮮度 | [EXPLAIN とスロークエリ改善](/blog/postgresql-explain-analyze-slow-query-optimization-guide) |
| 3 | **メモリ設定** | `work_mem` / `shared_buffers` / `effective_cache_size` | 本記事 §4 |
| 4 | **MVCC・VACUUM** | autovacuum、肥大化（bloat）対策、HOT更新 | [MVCC と VACUUM](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide) |
| 5 | **接続管理** | 接続プーリング（PgBouncer 等） | 本記事 §6 |
| 6 | **物理設計** | 宣言的パーティショニング | [パーティショニング](/blog/postgresql-declarative-partitioning-range-list-hash-guide) |

> **やってはいけない最適化**：①計測前に設定値をいじる、②`EXPLAIN ANALYZE` を見ずにインデックスを足す、③本番で `SET enable_seqscan = off` を常用する（プランナーの判断を壊す対症療法）、④全カラムにインデックスを張る（書き込みが遅くなり、肥大化する）。

---

## 2. 計測の起点：pg_stat_statements

`pg_stat_statements` は、実行されたクエリを**正規化（リテラルを `$1` に置換）して集計**する拡張です。「`SELECT * FROM orders WHERE id = 42`」と「`= 99`」を同一クエリとして束ね、呼び出し回数・合計実行時間・平均時間・返却行数を記録します。本番の「重いクエリ Top N」はここから出します。

`shared_preload_libraries` に登録（サーバー再起動が必要）し、対象DBで有効化します。

```sql
-- postgresql.conf: shared_preload_libraries = 'pg_stat_statements'
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
```

「**累計実行時間が長い＝全体への影響が大きい**」クエリから潰すのが費用対効果の鉄則です。1回0.5秒のクエリより、1回5msでも100万回呼ばれるクエリの方が、システム全体では重い。

```sql
-- 全体負荷への寄与が大きい順（時間は ms）
SELECT
  substring(query, 1, 80)              AS query,
  calls,
  round(total_exec_time::numeric, 1)   AS total_ms,
  round(mean_exec_time::numeric, 2)    AS mean_ms,
  rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
```

> この `total_exec_time DESC` のリストが、あなたの最適化の **To-Do リスト** です。上位5本を改善するだけで、体感は劇的に変わります。

本番では、しきい値を超えた遅いクエリの**実行計画そのもの**をログに残す `auto_explain` も併用します（詳細は[EXPLAIN の記事](/blog/postgresql-explain-analyze-slow-query-optimization-guide)）。

---

## 3. プランナーは統計情報で動く（だから ANALYZE が命）

PostgreSQL のプランナーは、テーブルを実際にスキャンする前に「この `WHERE` は何行に絞れるか（**選択率**）」を**統計情報**から見積もり、最も安いプランを選びます。統計が古いと、見積もりが外れ、**間違ったプラン**（本来インデックスを使うべき所でシーケンシャルスキャン等）を選びます。

統計には2種類あります（公式 `planner-stats`）。

- **テーブル単位**（`pg_class`）：`reltuples`（行数）、`relpages`（ブロック数）。`VACUUM` / `ANALYZE` / `CREATE INDEX` 等で更新され、**常に多少古い**。
- **列単位**（`pg_statistic` → `pg_stats` ビュー）：`most_common_vals`（高頻度値）、`histogram_bounds`（ヒストグラム）、`n_distinct`、`null_frac`。`ANALYZE` / `VACUUM ANALYZE` で更新され、**常に近似値**。

実務上の指針はシンプルです。

```sql
-- 統計の収集精度（既定 100）。偏った分布の列だけ列単位で引き上げる
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
```

**相関する複数列**（例：`city` と `postal_code`）は、プランナーが「各条件は独立」と仮定するため、組み合わせ条件で行数を桁違いに過小評価します。これは**拡張統計**で矯正します。

```sql
-- 「都市が決まれば郵便番号もほぼ決まる」関数従属を学習させる
CREATE STATISTICS orders_city_zip (dependencies, ndistinct)
  ON city, postal_code FROM orders;
ANALYZE orders;
```

> 「インデックスを張ったのに使われない」の典型原因は**統計の陳腐化**です。大量 INSERT/更新の直後は必ず `ANALYZE`。autovacuum 任せにせず、バッチ処理の最後に明示実行するのが安全です。

---

## 4. メモリ設定：意味を理解して触る

メモリ設定は「大きくすれば速い」ではありません。**それぞれが守る対象が違う**ので、意味を理解して触ります。以下はすべて公式 `runtime-config-resource` / `runtime-config-query` の既定値です。

### 4.1 shared_buffers — PostgreSQL 自身のキャッシュ

PostgreSQL がデータページをキャッシュする共有メモリ。**既定 128MB**。公式の指針は「RAM が 1GB 以上なら **25% から始める**」「**40% を超えても良くなることは稀**」。理由は、PostgreSQL は **OS のページキャッシュにも強く依存**しており、shared_buffers を上げすぎると OS キャッシュと二重持ちになり、かえって効率が落ちるためです。

```ini
# 例：RAM 16GB のサーバー
shared_buffers = 4GB              # RAM の約25%。サーバー起動時のみ反映
effective_cache_size = 12GB       # 後述。OS含めキャッシュ可能と見込む量
```

### 4.2 work_mem — 最大の地雷（クエリ単位ではない）

ソート（`ORDER BY` / `DISTINCT` / マージ結合）やハッシュ（ハッシュ結合・ハッシュ集約）が**一時ファイルに書き出す前に使えるメモリ**。**既定 4MB**。

ここが最大の落とし穴です。公式の表現を引くと——

> 複雑なクエリは複数のソート・ハッシュ操作を**同時に**実行することがあり、**各操作がそれぞれ work_mem まで**使える。さらに複数セッションが同時に実行し得る。**したがって総メモリ使用量は work_mem の何倍にもなり得る。**

つまり実効上限は **`work_mem × クエリ内の同時ソート/ハッシュ・ノード数 × 同時セッション数`**。安易に `work_mem = 1GB` などにすると、ピーク時に OOM でサーバーが落ちます。さらにハッシュ系は `hash_mem_multiplier`（既定 **2.0**）が掛かるため、実際は `work_mem × 2.0` まで使えます。

```sql
-- グローバルは控えめ（例 16MB〜64MB）。重い集計クエリだけセッション/トランザクションで上げる
SET LOCAL work_mem = '256MB';   -- このトランザクション内だけ。BEGIN〜COMMIT で囲む
SELECT customer_id, sum(amount) FROM orders GROUP BY customer_id;
```

`EXPLAIN ANALYZE` で `Sort Method: external merge  Disk: 12345kB` が出たら、そのソートが work_mem を溢れてディスクに退避した証拠＝`work_mem` 不足のサインです。

### 4.3 maintenance_work_mem — 保守作業用

`VACUUM`・`CREATE INDEX`・`ALTER TABLE ADD FOREIGN KEY` 等が使うメモリ。**既定 64MB**。同時に1セッション1つしか走らない前提なので、公式も「work_mem より大幅に大きく設定して安全」としています。インデックス再作成やバルクロードの前に一時的に上げると効果絶大です。

```sql
SET maintenance_work_mem = '1GB';   -- 索引作成・VACUUM を高速化
```

### 4.4 effective_cache_size と random_page_cost — プランナーの世界観

この2つは**メモリを確保しません**。プランナーに「世界の見え方」を教えるパラメータです。

- **`effective_cache_size`**（既定 **4GB**）：1クエリが利用できそうな**ディスクキャッシュの総量**（shared_buffers＋OSキャッシュ）の見積もり。**大きいほどインデックススキャンが有利**に評価されます。RAM の 50〜75% を目安に。
- **`random_page_cost`**（既定 **4.0**）：ランダムなページ読み取りの相対コスト（`seq_page_cost` は 1.0）。HDD前提の既定値で、**SSD/NVMe では実態に合いません**。SSD なら **1.1 程度**に下げると、インデックススキャンが正当に選ばれやすくなります。

```ini
effective_cache_size = 12GB       # RAM 16GB の 75%
random_page_cost = 1.1            # SSD/NVMe の現実に合わせる（HDD既定は 4.0）
```

> **これは「効くのに見落とされがち」な設定です。** クラウドのマネージドDBはほぼ全て SSD ですが、`random_page_cost` が 4.0 のままだと、プランナーは「インデックスは高い」と誤解してシーケンシャルスキャンを選びがちです。

---

## 5. PostgreSQL 18 の新機能：何が速くなったか

PostgreSQL 18（2025年9月リリース、本稿時点の最新は 18.4）は、**I/O 性能**を大きく押し上げました。チューニングの前提が変わるので押さえておきます。

### 5.1 非同期I/O（AIO）— 最大3倍

公式リリースアナウンス曰く、PG18 は**非同期I/Oサブシステム**を導入し、I/O要求を逐次待たずに**並行発行**できるようになりました。対象は**シーケンシャルスキャン・bitmap heap scan・VACUUM**で、ベンチマークでは**最大3倍**の性能向上が示されています。

これを制御するのが新パラメータ **`io_method`**（サーバー起動時のみ）。値は3つ。

| 値 | 内容 |
| --- | --- |
| `worker` | **既定**。専用ワーカープロセスで非同期I/Oを実行（`io_workers` 既定 3） |
| `io_uring` | Linux の io_uring を使用（`--with-liburing` ビルドが必要） |
| `sync` | 旧来どおり同期I/O（無効化） |

あわせて `effective_io_concurrency` と `maintenance_io_concurrency` の既定値が **1 → 16** に引き上げられました（公式コメント：「現代のハードウェアをより正確に反映する」）。

### 5.2 B-tree スキップスキャン

複合インデックスの**先頭列に等値条件がなくても**、後続列の条件でインデックスを使えるケースが増えました。公式リリースノートを引くと——

> 複数列のB-treeインデックスを、**先頭や前方の列に制約がない（あるいは非等値の制約しかない）場合でも、後続の列に有用な制約がある**ときに利用できるようにする。

これは「複合インデックスの列順を間違えると全く使われない」という従来の鉄則を**部分的に緩和**します（ただし先頭列のカーディナリティが低いほど効く）。詳細は[インデックス設計の記事](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide)へ。

### 5.3 EXPLAIN ANALYZE が BUFFERS を既定で表示

地味ですが効きます。PG18 から `EXPLAIN ANALYZE` は **`BUFFERS` を自動で含む**ようになりました（公式：「Buffers information is automatically included when ANALYZE is used.」）。バッファヒット/読み込みが標準で見えるので、「キャッシュに乗っているか／ディスクを叩いているか」の診断が一段楽になります。

```sql
-- PG18 では BUFFERS を明示しなくても shared hit/read が出る
EXPLAIN (ANALYZE) SELECT * FROM orders WHERE customer_id = 42;
-- 抑制したいときだけ: EXPLAIN (ANALYZE, BUFFERS OFF) ...
```

その他、`uuidv7()`（時刻順ソート可能なUUID＝インデックス局所性が良い）、仮想生成列が既定化、複合主キーに**非btreeのユニークインデックス**を使える、など実務に効く改善が入っています。

---

## 6. 接続管理：スレッドではなくプロセス、だからプーリング必須

PostgreSQL は接続ごとに**OSプロセスを1つ生成**します（スレッドではない）。1接続あたり数MB〜のメモリを消費し、`work_mem` の同時消費もここに乗ります。アプリのコネクションを無制限に張ると、`max_connections` を上げる→メモリ枯渇、という典型的な事故になります。

対策は**接続プーリング**です。アプリ↔DB の間にプーラー（PgBouncer 等）を挟み、物理接続を**少数で使い回す**。サーバーレス（Lambda 等）や多数の Web ワーカーでは必須級です。

```text
[多数のアプリプロセス] ──▶ [PgBouncer transaction mode] ──▶ [少数の物理接続] ──▶ PostgreSQL
                                  ↑ 物理接続を共有し、max_connections を低く保つ
```

アプリ側でも、サーバーレスでは「リクエストごとに接続を作らない」「プーラー前提でプール上限を絞る」のが鉄則です。

```ts
// node-postgres: アプリ内プールは小さく、外側で PgBouncer に集約する設計
import { Pool } from "pg";

export const pool = new Pool({
  connectionString: process.env.DATABASE_URL, // 秘密情報は環境変数（コードに埋めない）
  max: 10,                    // 1インスタンスあたりの上限。台数×max が物理接続を超えないこと
  idleTimeoutMillis: 30_000,
  connectionTimeoutMillis: 5_000,
});

// クエリは必ずパラメータ化（文字列連結は SQL インジェクションの温床）
export async function findOrder(id: string) {
  const { rows } = await pool.query(
    "SELECT id, customer_id, amount FROM orders WHERE id = $1",
    [id],
  );
  return rows[0] ?? null;
}
```

> **セキュリティの最重要点**：ユーザー入力は**必ずパラメータ化クエリ（`$1`）**で渡す。文字列連結で SQL を組み立てた瞬間、SQL インジェクションの扉が開きます。ORM（Drizzle/Prisma 等）や `pg` のパラメータ化は、この境界を構造的に守るための仕組みです。

---

## 7. 書き込みを速くする：バルクロードとHOT更新

読み取りだけでなく、**投入と更新**にもセオリーがあります。

### 7.1 大量投入は INSERT ではなく COPY

公式 `populate` の指針：大量データは `INSERT` ではなく **`COPY`** を使う（1コマンドで全行を流し、パース・プラン・コミットのオーバーヘッドを償却）。さらに、

- **インデックスと外部キーは投入後に作る**（投入中の索引メンテを避ける）
- `maintenance_work_mem` と `max_wal_size` を一時的に上げる
- **投入後に必ず `ANALYZE`**（さもないとプランナーが空テーブルの統計で誤動作）

```sql
-- 初期ロードの定石
ALTER TABLE big_table SET (autovacuum_enabled = false);  -- ロード中の自動VACUUMを止める
-- （索引を落としてから）COPY で投入
COPY big_table FROM '/path/data.csv' WITH (FORMAT csv, HEADER true);
-- 索引・FKを作成し直す → 統計を更新
CREATE INDEX ...;
ANALYZE big_table;
ALTER TABLE big_table SET (autovacuum_enabled = true);
```

### 7.2 更新は「肥大化」を生む（HOT更新で抑える）

PostgreSQL の `UPDATE` は、MVCC のため**古い行を即座に消さず、新しい行バージョンを追記**します（[MVCC の記事](/blog/postgresql-mvcc-transaction-isolation-vacuum-autovacuum-guide)で詳述）。これが**テーブル肥大化（bloat）**の原因です。

緩和策が **HOT 更新**。「更新がインデックス対象列を変えず」「同一ページに空きがある」場合、新しいインデックスエントリを作らずに済み、肥大化を抑えます。更新が多いテーブルは `fillfactor` を下げて同一ページの空きを確保すると、HOT 更新が成立しやすくなります。

```sql
-- 更新の多いテーブルは fillfactor を下げて HOT 更新を促す（既定100）
ALTER TABLE sessions SET (fillfactor = 85);
```

---

## 8. 緊急時のチェックリスト（本番が重いとき）

「いま遅い」を切り分ける順番です。上から確認してください。

1. **詰まっているクエリは？** `pg_stat_activity` で `state = 'active'` かつ `wait_event` を確認。長時間 `idle in transaction` のセッションは VACUUM を妨げ、ロックを握り続ける元凶。
2. **ロック待ちは？** `pg_locks` を `pg_stat_activity` と結合し、ブロック元のPIDを特定。
3. **重いクエリの計画は？** 該当クエリを `EXPLAIN (ANALYZE, BUFFERS)`。`Seq Scan` + 大きい `Rows Removed by Filter`、`external merge Disk`、巨大な `loops` を探す。
4. **統計は新鮮か？** 直近に大量更新があったテーブルを `ANALYZE`。
5. **肥大化していないか？** `pg_stat_user_tables` の `n_dead_tup` と autovacuum の最終実行時刻を確認。

```sql
-- いま実行中で重いセッション（長時間 idle in transaction は要注意）
SELECT pid, state, wait_event_type, wait_event,
       now() - xact_start AS xact_age, substring(query,1,60) AS query
FROM pg_stat_activity
WHERE state <> 'idle'
ORDER BY xact_start;
```

---

## 9. 設計判断のまとめ（トレードオフ表）

| 施策 | 効く対象 | コスト/リスク | 判断基準 |
| --- | --- | --- | --- |
| インデックス追加 | 読み取り | 書き込み低下・肥大化・容量 | `pg_stat_statements` 上位の検索条件にだけ張る |
| `work_mem` 引き上げ | ソート/ハッシュ | OOM（同時実行で乗算） | グローバルは控えめ、重い集計のみ `SET LOCAL` |
| `random_page_cost` 低下 | プラン選択 | 誤って seq scan を捨てる | SSD なら 1.1 前後（HDDは 4.0 維持） |
| パーティショニング | 超大規模表 | 設計・運用の複雑化 | 表サイズが物理メモリを超えるのが目安 |
| 接続プーリング | 同時接続多 | 運用コンポーネント増 | サーバーレス/多ワーカーでは必須 |
| 非durable設定 | 投入速度 | OSクラッシュ時のデータ損失 | 初期ロード等の再生成可能な処理のみ |

> **非durable設定の注意**（公式 `non-durability`）：`synchronous_commit = off` は「OSクラッシュ時にトランザクションを失う**リスクはあるが、データ破損はしない**」。`fsync = off` は破損リスクあり。**いつでも作り直せるデータ**（初期ロード、再生成可能な集計）に限って使うこと。

---

## 10. まとめ：速さは「順番」で決まる

- **計測が先**。`pg_stat_statements` で重いクエリを特定し、`EXPLAIN (ANALYZE, BUFFERS)` で原因を見てから手を打つ。
- **効く順**は、①インデックス・クエリ設計 → ②統計（ANALYZE）→ ③メモリ → ④VACUUM → ⑤接続 → ⑥物理設計。上ほど安く効く。
- **`work_mem` は per-operation**。総使用量は乗算で膨らむ。グローバルは控えめに、必要な所だけ `SET LOCAL`。
- **SSD なら `random_page_cost` を下げる**。`effective_cache_size` は実態に合わせて大きく。
- **PG18 は I/O が速くなった**（非同期I/O、BUFFERS既定ON、スキップスキャン）。前提が変わったので最新で測り直す。

このシリーズでは、本記事で並べた各レイヤーを個別に深掘りします。まずは [EXPLAIN の読み方](/blog/postgresql-explain-analyze-slow-query-optimization-guide) と [インデックス設計](/blog/postgresql-index-design-btree-gin-gist-brin-covering-guide) から。PostgreSQL は「正しく計測し、正しい順で触れば」、専用DBを増やさずとも本番の要求に十分応えます。

---

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

- [Chapter 14. Performance Tips](https://www.postgresql.org/docs/18/performance-tips.html)
- [19.4. Resource Consumption（shared_buffers / work_mem / io_method）](https://www.postgresql.org/docs/18/runtime-config-resource.html)
- [19.7. Query Planning（effective_cache_size / random_page_cost）](https://www.postgresql.org/docs/18/runtime-config-query.html)
- [14.2. Statistics Used by the Planner](https://www.postgresql.org/docs/18/planner-stats.html)
- [14.4. Populating a Database](https://www.postgresql.org/docs/18/populate.html)
- [PostgreSQL 18 Released!（非同期I/O・最大3倍）](https://www.postgresql.org/about/news/postgresql-18-released-3142/)
