メインコンテンツへスキップ
友田 陽大
PostgreSQL本体・性能設計
PostgreSQL
パフォーマンス
アーキテクチャ設計

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

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

公開日
読了時間
16分
著者
友田 陽大
シェア

「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 な述語インデックス設計
2実行計画の理解EXPLAIN ANALYZE、統計の鮮度EXPLAIN とスロークエリ改善
3メモリ設定work_mem / shared_buffers / effective_cache_size本記事 §4
4MVCC・VACUUMautovacuum、肥大化(bloat)対策、HOT更新MVCC と VACUUM
5接続管理接続プーリング(PgBouncer 等)本記事 §6
6物理設計宣言的パーティショニングパーティショニング

やってはいけない最適化:①計測前に設定値をいじる、②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で有効化します。

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

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

-- 全体負荷への寄与が大きい順(時間は 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 の記事)。


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

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

統計には2種類あります(公式 planner-stats)。

  • テーブル単位pg_class):reltuples(行数)、relpages(ブロック数)。VACUUM / ANALYZE / CREATE INDEX 等で更新され、常に多少古い
  • 列単位pg_statisticpg_stats ビュー):most_common_vals(高頻度値)、histogram_bounds(ヒストグラム)、n_distinctnull_fracANALYZE / VACUUM ANALYZE で更新され、常に近似値

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

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

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

-- 「都市が決まれば郵便番号もほぼ決まる」関数従属を学習させる
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 キャッシュと二重持ちになり、かえって効率が落ちるためです。

# 例: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 まで使えます。

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

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

4.3 maintenance_work_mem — 保守作業用

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

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 程度に下げると、インデックススキャンが正当に選ばれやすくなります。
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_uringLinux の io_uring を使用(--with-liburing ビルドが必要)
sync旧来どおり同期I/O(無効化)

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

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

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

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

これは「複合インデックスの列順を間違えると全く使われない」という従来の鉄則を部分的に緩和します(ただし先頭列のカーディナリティが低いほど効く)。詳細はインデックス設計の記事へ。

5.3 EXPLAIN ANALYZE が BUFFERS を既定で表示

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

-- 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 ワーカーでは必須級です。

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

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

// 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_memmax_wal_size を一時的に上げる
  • 投入後に必ず ANALYZE(さもないとプランナーが空テーブルの統計で誤動作)
-- 初期ロードの定石
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 の記事で詳述)。これが**テーブル肥大化(bloat)**の原因です。

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

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

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

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

  1. 詰まっているクエリは? pg_stat_activitystate = 'active' かつ wait_event を確認。長時間 idle in transaction のセッションは VACUUM を妨げ、ロックを握り続ける元凶。
  2. ロック待ちは? pg_lockspg_stat_activity と結合し、ブロック元のPIDを特定。
  3. 重いクエリの計画は? 該当クエリを EXPLAIN (ANALYZE, BUFFERS)Seq Scan + 大きい Rows Removed by Filterexternal merge Disk、巨大な loops を探す。
  4. 統計は新鮮か? 直近に大量更新があったテーブルを ANALYZE
  5. 肥大化していないか? pg_stat_user_tablesn_dead_tup と autovacuum の最終実行時刻を確認。
-- いま実行中で重いセッション(長時間 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 の読み方インデックス設計 から。PostgreSQL は「正しく計測し、正しい順で触れば」、専用DBを増やさずとも本番の要求に十分応えます。


参考(PostgreSQL 18 公式ドキュメント)

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

木材流通DXのB2B SaaS — PostgreSQL を中核にマルチテナント・多段商流を支えたデータ基盤

ケーススタディを見る