PostgreSQLパフォーマンスチューニング: 中小企業DBの遅延を解消する手順
TL;DR
「サイトが遅い」「画面が固まる」というクレームが来たとき、原因はDB以外の場所(アプリコード・ネットワーク・フロントエンド)にあることも多いですが、本記事はPostgreSQL側に絞って疑う順番を決めます。
疑う順番は次の4段階です。① インデックス不足(EXPLAIN ANALYZEでSeq Scanを確認)→ ② クエリ設計(N+1・不要なSELECT *・JOIN順)→ ③ postgresql.confの設定パラメータ(メモリ配分が既定値のまま)→ ④ ハードウェアリソース(CPU・メモリ・ディスクI/Oの絶対的な不足)。 この順番で切り分けると、まず①②の無料の改善(設定変更・スペックアップ不要)で大半のケースが解決します。④まで来るのは、①〜③をやり切ってなお足りない場合だけです。
本記事はPostgreSQL 16を前提に、バックアップ・リストアではなく速度の遅延だけを扱います。バックアップ運用はPostgreSQL/MySQLのバックアップ運用を参照してください。
なぜこの順番で疑うのか
「遅い」と言われて真っ先にpostgresql.confをいじり始めるのはよくある遠回りです。
- インデックス不足は最も頻度が高く、最も直しやすい。 1本のCREATE INDEXで秒単位の遅延がミリ秒単位まで縮むこともあります。
- クエリ設計の問題(N+1・不要なJOIN)は、インデックスを足しても解決しません。 アプリ側のループが原因なら、DB側の調整だけでは効果が出ません。
- 設定パラメータのチューニングは「土台」であって「特効薬」ではありません。 個別クエリの設計不良を設定変更で覆い隠すことはできません。
- ハードウェア増強は最後の手段です。 ①〜③を放置したままスペックを上げても、非効率なクエリは速いハードウェアの上でも非効率なままでコストだけが増えます。
この順番を守れば、「とりあえずVPSのプランを上げる」という一番高くつく対策の前に、無料でできる改善を使い切れます。
① まずEXPLAIN ANALYZEで実行計画を見る
体感の「遅い」を数値に変える最初の一手がEXPLAIN ANALYZEです。
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 12345;
EXPLAIN単体はプランナーの見積もり(推定コスト・推定行数)だけを表示します。ANALYZEを付けると実際にクエリを実行し、各ノードの実測時間(actual time)と実際の行数(rows)も併せて表示します。見積もりと実測が大きくずれている場合、統計情報が古い(ANALYZE テーブル名の実行漏れ)可能性を疑います。
出力例と読み方
Seq Scan on orders (cost=0.00..18334.00 rows=1 width=120)
(actual time=0.030..142.995 rows=1 loops=1)
Filter: (customer_id = 12345)
Rows Removed by Filter: 999999
Planning Time: 0.075 ms
Execution Time: 143.021 ms
読み方のポイントは3つです。
Seq Scan(シーケンシャルスキャン)が出たら、まず「このテーブルにインデックスは効いているか」を疑います。 Seq Scanはテーブルの全行を先頭から読む方式で、大きいテーブルほど遅くなります。小さいテーブルやほとんどの行を返すクエリではプランナーがSeq Scanを選ぶのが正しい判断のこともあるため、「Seq Scan=即問題」ではなく、対象がヒットする行の少ない絞り込みクエリかどうかで判断します。Rows Removed by Filterが大きい場合、フィルタ条件に該当しない行を大量に読み捨てています。 上の例では100万行を読んで1行しか残っていません。これはインデックスで絞り込めていない典型的なサインです。loopsはそのノードが実行された回数です。 JOINのネストループなど、内側のノードが外側の行数分繰り返し実行される場合、actual timeは1回あたりの平均値なので、実際の合計時間はactual time × loopsに近くなります。
Index ScanやBitmap Heap Scanが出ていれば、インデックスが使われている状態です。同じクエリに対してSeq Scanとの実行時間を比較すると、インデックスの効果が数値で確認できます。
② インデックス設計の基本
Seq Scanが確認できたら、次はインデックスを検討します。
基本のB-treeインデックス
CREATE INDEX idx_orders_customer_id ON orders (customer_id);
PostgreSQLのデフォルトインデックスはB-tree型で、等値検索(=)や範囲検索(< > BETWEEN)、ORDER BYに効きます。
複合インデックス(列の順序が重要)
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
複合インデックスは左側から順に使われます。WHERE customer_id = ? AND status = ?には効きますが、WHERE status = ?だけの検索には(先頭列を飛ばすため)効きにくくなります。よく一緒に使われる条件・絞り込みが強い列を先頭に置くのが基本です。
インデックスを作っても使われない典型パターン
- カーディナリティが低い列(例: 性別のように値の種類が数個しかない列)は絞り込み効果が薄く、Seq Scanを選び続けることがあります。
- 関数を列にかけている(
WHERE LOWER(email) = ...など)と通常のインデックスは使われません。式インデックス(CREATE INDEX ... ON table (LOWER(email)))が必要です。 - 型の不一致(文字列カラムに数値リテラルを比較するなど)でも使われないことがあります。
- 統計情報が古いとSeq Scanを選ぶことがあります。
ANALYZE テーブル名で更新して再確認します。
インデックスは「多いほど良い」ではない
インデックスは検索を速くする一方、INSERT/UPDATE/DELETEのたびに更新コストがかかります。書き込みが多いテーブルに不要なインデックスを増やしすぎると、書き込み側が遅くなります。「よく使われるWHERE句・JOIN条件・ORDER BY」に絞って設計するのが基本です。
③ クエリ設計を見直す
インデックスを足しても改善しない、あるいはインデックスの問題ではないケースでは、クエリの書き方自体を疑います。
- N+1問題: アプリ側が1件ずつループで
SELECTを発行していないか確認します。件数分のクエリが積み重なると、個々のクエリが速くても合計では大きな遅延になります。JOINやINでまとめて取得できないか検討します。 SELECT *の多用: 使わない列まで読み出すと、特にワイドなテーブルで無駄なI/Oが発生します。必要な列だけを指定します。- JOIN順序とWHERE句の絞り込み: 大きいテーブル同士をJOINしてから絞り込むのではなく、先に絞り込んでからJOINできないか(サブクエリ・CTEでの事前フィルタ)を検討します。
OFFSETを使った大きなページネーション:OFFSETが大きくなるほど、DBはスキップする行も含めて数える必要があり遅くなります。カーソルベース(直前の主キー値を条件にする)のページネーションの方がスケールします。
④ postgresql.confの主要パラメータ
インデックスとクエリの見直しをやり切ってもなお遅い場合、あるいは複数のクエリ全体が遅い場合は、postgresql.confの設定を確認します。以下の数値は既定値と考え方の説明であり、実際の設定値はサーバーの搭載メモリ量・同時接続数に応じて調整するものです。断定的な「この数値にすべき」という基準はなく、変更後は必ず本番相当の負荷で検証してください。
| パラメータ | 既定値 | 何を制御するか |
|---|---|---|
shared_buffers | 128MB | PostgreSQLが共有メモリバッファとして使う量。専用サーバーではRAMの25%程度が目安とされる(公式ドキュメント)。OS側のディスクキャッシュも使われるため、RAMの40%を超える設定は推奨されない |
work_mem | 4MB | ソート(ORDER BY)・ハッシュ結合・DISTINCTなど、クエリ内の1つの操作が使えるメモリ量。1クエリ内で複数回・同時接続の数だけ消費されるため、大きくしすぎるとメモリ不足を招く |
maintenance_work_mem | 64MB | VACUUM・CREATE INDEX・外部キー追加などのメンテナンス操作が使うメモリ量。work_memより大きく設定しても比較的安全とされる(同時に走る本数が少ないため) |
effective_cache_size | 4GB | 実際にメモリを確保するパラメータではなく、「OSとPostgreSQLが使えるキャッシュの合計量」をプランナーに伝える見積もり用の値。大きく設定するほどインデックススキャンが選ばれやすくなる |
random_page_cost | 4.0 | ランダムI/O(インデックス経由のアクセスなど)のコスト見積もり。SSD中心の環境ではseq_page_cost(既定1.0)に近づけて下げると、インデックススキャンが選ばれやすくなる傾向がある |
変更はpostgresql.confを直接編集するか、ALTER SYSTEM SETで行い、設定によっては再起動が必要です。
-- ALTER SYSTEMで変更する場合の例(値はサーバーの搭載メモリに応じて要調整)
ALTER SYSTEM SET work_mem = '16MB';
SELECT pg_reload_conf();
shared_buffersなど一部のパラメータはサーバー起動時のみ反映されるため、pg_reload_conf()では効かず再起動が必要です。詳細はPostgreSQL公式ドキュメント Server Configurationを参照してください。
EXPLAIN ANALYZEの出力にSort Method: external merge Diskが出ていれば、ソートがメモリに収まらずディスクに書き出しているサイン(work_mem不足)です。サーバーのメモリに余裕があるのにshared_buffersが既定値の128MBのままなら、専用DBサーバーでは見直しの余地があります。
⑤ それでも遅ければハードウェアリソースを疑う
①〜④を確認してもなお遅延が解消しない場合、最後にCPU・メモリ・ディスクI/Oの絶対的な不足を疑います。特にディスクI/O待ちは、VPSの共有ストレージプランで発生しやすいポイントです。top/htop・iostatでのリソース監視についてはLinuxサーバーのリソース監視を参照してください。
また、更新頻度の低いデータ(マスタデータ・集計結果など)をDBへの都度クエリでなくキャッシュ層に持たせ、DB自体への負荷を根本的に減らす選択肢もあります。この設計はRedisでキャッシュ高速化入門で扱っています。
まとめ
| 段階 | 疑うポイント | 確認方法 |
|---|---|---|
| ① インデックス | Seq Scan・Rows Removed by Filterが多い | EXPLAIN ANALYZE |
| ② クエリ設計 | N+1・SELECT *・JOIN順・OFFSET多用 | アプリコードとクエリログの確認 |
| ③ 設定パラメータ | shared_buffers/work_mem等が既定値のまま | postgresql.conf / SHOW ALL; |
| ④ ハードウェア | CPU・メモリ・ディスクI/Oの絶対不足 | top/htop/iostat |
「本番DBが遅い」というクレームのほとんどは、①インデックス不足と②クエリ設計のどちらかに原因があります。postgresql.confのパラメータ調整やハードウェア増強は、この2つをやり切った後の話です。順番を守ることで、コストをかけずに直せる問題にコストをかけてしまう遠回りを避けられます。
バックアップ・リストアの運用はPostgreSQL/MySQLのバックアップ運用、DB負荷そのものを減らすキャッシュ設計はRedisでキャッシュ高速化入門、サーバー全体のリソース監視はLinuxサーバーのリソース監視で扱っています。