「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点を押さえます。
- どのクエリが、どれだけ全体の負荷を占めているか(→
pg_stat_statements) - その遅いクエリは、なぜ遅いのか(→
EXPLAIN (ANALYZE, BUFFERS))
そして、以下の効く順番で手を入れます。上ほど効果が大きく、コストが低い。
| 優先度 | レイヤー | 主な武器 | 深掘り記事 |
|---|---|---|---|
| 1 | クエリ・インデックス設計 | 適切な索引、N+1除去、SARGable な述語 | インデックス設計 |
| 2 | 実行計画の理解 | EXPLAIN ANALYZE、統計の鮮度 | EXPLAIN とスロークエリ改善 |
| 3 | メモリ設定 | work_mem / shared_buffers / effective_cache_size | 本記事 §4 |
| 4 | MVCC・VACUUM | autovacuum、肥大化(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_statistic→pg_statsビュー):most_common_vals(高頻度値)、histogram_bounds(ヒストグラム)、n_distinct、null_frac。ANALYZE/VACUUM ANALYZEで更新され、常に近似値。
実務上の指針はシンプルです。
-- 統計の収集精度(既定 100)。偏った分布の列だけ列単位で引き上げる
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 1000;
ANALYZE orders;
相関する複数列(例:city と postal_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 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 より大幅に大きく設定して安全」としています。インデックス再作成やバルクロードの前に一時的に上げると効果絶大です。
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_uring | Linux の io_uring を使用(--with-liburing ビルドが必要) |
sync | 旧来どおり同期I/O(無効化) |
あわせて effective_io_concurrency と maintenance_io_concurrency の既定値が 1 → 16 に引き上げられました(公式コメント:「現代のハードウェアをより正確に反映する」)。
5.2 B-tree スキップスキャン
複合インデックスの先頭列に等値条件がなくても、後続列の条件でインデックスを使えるケースが増えました。公式リリースノートを引くと——
複数列のB-treeインデックスを、先頭や前方の列に制約がない(あるいは非等値の制約しかない)場合でも、後続の列に有用な制約があるときに利用できるようにする。
これは「複合インデックスの列順を間違えると全く使われない」という従来の鉄則を部分的に緩和します(ただし先頭列のカーディナリティが低いほど効く)。詳細はインデックス設計の記事へ。
5.3 EXPLAIN ANALYZE が BUFFERS を既定で表示
地味ですが効きます。PG18 から EXPLAIN ANALYZE は BUFFERS を自動で含むようになりました(公式:「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_memとmax_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. 緊急時のチェックリスト(本番が重いとき)
「いま遅い」を切り分ける順番です。上から確認してください。
- 詰まっているクエリは?
pg_stat_activityでstate = 'active'かつwait_eventを確認。長時間idle in transactionのセッションは VACUUM を妨げ、ロックを握り続ける元凶。 - ロック待ちは?
pg_locksをpg_stat_activityと結合し、ブロック元のPIDを特定。 - 重いクエリの計画は? 該当クエリを
EXPLAIN (ANALYZE, BUFFERS)。Seq Scan+ 大きいRows Removed by Filter、external merge Disk、巨大なloopsを探す。 - 統計は新鮮か? 直近に大量更新があったテーブルを
ANALYZE。 - 肥大化していないか?
pg_stat_user_tablesのn_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を増やさずとも本番の要求に十分応えます。