【初心者向け】【PostgreSQL】 pg_stat_statements で遅いクエリを特定して改善してみた

【初心者向け】【PostgreSQL】 pg_stat_statements で遅いクエリを特定して改善してみた

2026.03.21

以前、以下ブログで pg_stat_statements の有効化方法を紹介しました。
https://dev.classmethod.jp/articles/steps-to-enable-pg-stat-statements/

今回は実際に pg_stat_statements を使って、以下の流れで遅いクエリの特定から改善までを確認していきます。

  • テストデータ(100万件)を用意する
  • インデックスがない状態で遅いクエリを実行する
  • pg_stat_statements で実行時間を計測する
  • インデックスを追加して改善する
  • pg_stat_statements で改善前後を比較する

環境準備

pg_stat_statements は PostgreSQL の Extension(拡張機能)のため、有効化の設定が必要です。詳細は下記ブログにまとめているので、ご参照頂けますと幸いです。

https://dev.classmethod.jp/articles/steps-to-enable-pg-stat-statements/

今後の内容は上記ブログの環境準備を満たした前提で進めます。

テストデータの作成

テストデータを準備します。
今現在は、postgres データベースの public スキーマにいて、その中にテーブルは無い状態です。

postgres=# \conninfo
You are connected to database "postgres" as user "postgres" via socket in "/var/run/postgresql" at port "5432".

postgres=# SELECT current_schema();
 current_schema 
----------------
 public
(1 row)

postgres=# \dt
Did not find any relations.

図にすると以下の感じです。

┌─────────────────────────────────────────┐
│ データベース:postgres                    │
│   └── スキーマ:public                   │
│         └── テーブル:なし               │
└─────────────────────────────────────────┘

テーブルを作ります。

-- orders テーブルを作成
postgres=# CREATE TABLE orders (
  id         SERIAL PRIMARY KEY,
  user_id    INT,
  status     VARCHAR(20),
  amount     NUMERIC(10,2),
  created_at TIMESTAMP
);
CREATE TABLE

-- orders テーブルが作成されているか確認 -> OK
postgres=# \dt
         List of relations
 Schema |  Name  | Type  |  Owner   
--------+--------+-------+----------
 public | orders | table | postgres
(1 row)

テーブルにデータ(100 万行)を挿入します。

-- 100 万行のデータを挿入
postgres=# INSERT INTO orders (user_id, status, amount, created_at)
SELECT
  (random() * 10000)::INT,
  (ARRAY['pending','completed','cancelled'])[ceil(random()*3)],
  (random() * 100000)::NUMERIC(10,2),
  NOW() - (random() * INTERVAL '3 years')
FROM generate_series(1, 1000000);
INSERT 0 1000000

-- テーブルの行数を確認 -> 100 万行入ってる
postgres=# SELECT count(*) FROM orders;
  count  
---------
 1000000
(1 row)

挿入したデータは以下のようなものです。

-- 先頭 3 行を表示
postgres=# SELECT * FROM orders LIMIT 3;
 id | user_id | status  |  amount  |         created_at         
----+---------+---------+----------+----------------------------
  1 |    2723 | pending | 39764.73 | 2024-02-23 07:25:06.942378
  2 |    7074 | pending | 69667.94 | 2023-05-02 07:35:04.225578
  3 |    8189 | pending | 37283.92 | 2025-02-17 16:06:49.489578
(3 rows)

-- 末尾 3 行を表示
postgres=# SELECT * FROM orders ORDER BY id DESC LIMIT 3;
   id    | user_id |  status   |  amount  |         created_at         
---------+---------+-----------+----------+----------------------------
 1000000 |    8180 | completed | 52387.84 | 2026-03-10 09:51:58.014378
  999999 |    5118 | pending   | 31474.99 | 2025-02-08 10:04:58.811178
  999998 |    1825 | pending   | 65006.52 | 2023-08-05 07:48:13.403178
(3 rows)

各カラムのデータ構造は以下のようになっています。

id         : 11,000,000 の連番
user_id    : 010,000 のランダムな整数
status     : 'pending' / 'completed' / 'cancelled' のいずれか
amount     : 0.0099,999.99 のランダムな数字
created_at : 現在(データ生成時刻)から過去 3 年以内のランダムな日時

遅いクエリを実行する

現在は、id カラムのみ PRIMARY KEY としてインデックスが自動で貼られている状態です。
その他のカラムにはインデックスがありません。

-- テーブルのインデックスの状態を確認
postgres=# \d orders
                                        Table "public.orders"
   Column   |            Type             | Collation | Nullable |              Default               
------------+-----------------------------+-----------+----------+------------------------------------
 id         | integer                     |           | not null | nextval('orders_id_seq'::regclass)
 user_id    | integer                     |           |          | 
 status     | character varying(20)       |           |          | 
 amount     | numeric(10,2)               |           |          | 
 created_at | timestamp without time zone |           |          | 
Indexes:
    "orders_pkey" PRIMARY KEY, btree (id)

この状態で処理が遅くなることが想定されるクエリを発行します。

-- 見やすくするため pg_stat_statements の内容を事前にリセット
postgres=# SELECT pg_stat_statements_reset();
   pg_stat_statements_reset    
-------------------------------
 2026-03-20 01:54:23.127595+00
(1 row)

-- 同じクエリを 3 回実行
-- 1 回目
postgres=# SELECT * FROM orders WHERE user_id = 1234;
   id   | user_id |  status   |  amount  |         created_at         
--------+---------+-----------+----------+----------------------------
  57439 |    1234 | cancelled | 50200.88 | 2025-06-01 18:46:57.687978
  82987 |    1234 | cancelled | 66608.50 | 2023-05-15 17:01:35.713578
...

-- 2 回目
postgres=# SELECT * FROM orders WHERE user_id = 1234;
   id   | user_id |  status   |  amount  |         created_at         
--------+---------+-----------+----------+----------------------------
  82987 |    1234 | cancelled | 66608.50 | 2023-05-15 17:01:35.713578
 126516 |    1234 | completed | 62820.17 | 2025-06-23 13:35:19.691178
 ...

 -- 3 回目
 postgres=# SELECT * FROM orders WHERE user_id = 1234;
   id   | user_id |  status   |  amount  |         created_at         
--------+---------+-----------+----------+----------------------------
  82987 |    1234 | cancelled | 66608.50 | 2023-05-15 17:01:35.713578
  83746 |    1234 | completed |  4570.70 | 2023-09-30 10:49:31.595178
 ...

 -- pg_stat_statements をクエリする
 postgres=# SELECT
  query,
  calls,
  mean_exec_time,
  total_exec_time
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 5;
                  query                  | calls |  mean_exec_time   | total_exec_time 
-----------------------------------------+-------+-------------------+-----------------
 SELECT * FROM orders WHERE user_id = $1 |     3 | 69.74858933333334 |      209.245768
 SELECT pg_stat_statements_reset()       |     1 |          0.191608 |        0.191608
(2 rows)

小数点表示が見づらい場合は、以下のようにクエリすると見やすくなります。

postgres=# SELECT
  query,
  calls,
  round(mean_exec_time::numeric, 2)  AS mean_exec_time,
  round(total_exec_time::numeric, 2) AS total_exec_time
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY mean_exec_time DESC
LIMIT 5;
                  query                  | calls | mean_exec_time | total_exec_time 
-----------------------------------------+-------+----------------+-----------------
 SELECT * FROM orders WHERE user_id = $1 |     3 |          69.75 |          209.25
(1 row)
補足: 上記クエリで使用されている 「round」 と 「::numeric」(型変換) の使い方
-- round() の使い方 ------------------------
-- round() は数値を指定した小数点以下の桁数に丸める関数
-- round(数値, 桁数)

postgres=# SELECT round(3.14159, 3);
 round 
-------
 3.142
(1 row)

postgres=# SELECT round(3.14159, 2);
 round 
-------
  3.14
(1 row)

postgres=# SELECT round(3.14159, 0);
 round 
-------
     3
(1 row)

-- ::numeric (型変換)の使い方 ------------------------
-- :: の左側の値を右側の型に変換する
-- 基本的な使い方
-- 変換したい値::変換後の型

-- double precision → numeric に変換
postgres=# SELECT 3.14::numeric; 
 numeric 
---------
    3.14
(1 row)

-- 文字列 → numeric に変換
postgres=# SELECT '100'::numeric;
 numeric 
---------
     100
(1 row)

-- double precision → int に変換
postgres=# SELECT 3.14::int;
 int4 
------
    3
(1 row)

上記の結果から以下のことがわかります。

  • query : 実行されたクエリステートメント($1 は実際の値の代わりにプレースホルダーが入ります)
  • calls : クエリが 3 回実行されたこと
  • mean_exec_time : 1回あたりの平均実行時間は 69.75 ms
  • total_exec_time : 3回合計の実行時間は 209.25 ms

pg_stat_statements の各カラム(mean_exec_time, total_exec_time など)の詳細はドキュメントを参照してください。

user_id で検索するクエリが 1回あたり約70ms かかっています。
現在は user_id カラムにインデックスがないため、PostgreSQL は100万件のデータを先頭から全件スキャンして該当するデータを探しています。

今回は呼び出し回数が 3 回のため 合計約 210 ms と影響は軽微ですが、
例えばアプリから 1 日 1 万回呼ばれる処理などに組み込まれていると

70ms × 10,000回 = 700,000ms = 約12分

となり、1つのクエリが DB に 12 分分の負荷をかけることになります。

インデックスを貼って改善する

前項の pg_stat_statements 結果より、クエリが遅いことがわかったので、インデックスを貼って高速化します。

-- user_id カラムにインデックスを作る
postgres=# CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX

-- user_id にインデックスが作られたか確認 -> OK( "Indexes:" 部分に記載されてる)
postgres=# \d orders
                                        Table "public.orders"
   Column   |            Type             | Collation | Nullable |              Default               
------------+-----------------------------+-----------+----------+------------------------------------
 id         | integer                     |           | not null | nextval('orders_id_seq'::regclass)
 user_id    | integer                     |           |          | 
 status     | character varying(20)       |           |          | 
 amount     | numeric(10,2)               |           |          | 
 created_at | timestamp without time zone |           |          | 
Indexes:
    "orders_pkey" PRIMARY KEY, btree (id)
    "idx_orders_user_id" btree (user_id)

インデックスを作成し終えたので、前項と同じクエリを 3 回実行します。

-- pg_stat_statements をリセット
postgres=# SELECT pg_stat_statements_reset();

-- 同じクエリを3回実行(結果は省略)
postgres=# SELECT * FROM orders WHERE user_id = 1234;
postgres=# SELECT * FROM orders WHERE user_id = 1234;
postgres=# SELECT * FROM orders WHERE user_id = 1234;

pg_stat_statements をクエリして高速化できたか確認します。

-- pg_stat_statements で確認
postgres=# SELECT
  query,
  calls,
  round(mean_exec_time::numeric, 2)  AS mean_exec_time,
  round(total_exec_time::numeric, 2) AS total_exec_time
FROM pg_stat_statements
WHERE query NOT LIKE '%pg_stat_statements%'
ORDER BY mean_exec_time DESC
LIMIT 5;

                  query                  | calls | mean_exec_time | total_exec_time 
-----------------------------------------+-------+----------------+-----------------
 SELECT * FROM orders WHERE user_id = $1 |     3 |           0.23 |            0.68
(1 row)

上記より user_id カラムにインデックスを追加した結果、平均実行時間(mean_exec_time)が 0.23 ms と改善しました。

before / after で比較すると以下の通りです。69.75/0.23=303倍の速度改善です。

mean_exec_time total_exec_time
Before(インデックスなし) 69.75ms(約0.07秒) 209.25ms(約0.21秒)
After(インデックスあり) 0.23ms(約0.0002秒) 0.68ms(約0.0007秒)

これならアプリから 10,000 回呼ばれることになったとしても、0.23ms × 10,000回 = 2,300ms = 約2.3秒で処理が終わるので、とても効率が良くなりました。

pg_stat_statements を使うことで、どのクエリが何回実行され、どのくらい時間がかかっているかを数字で把握できました。とても便利なツールですね。検証は以上です。

終わりに

今回は pg_stat_statements を使って遅いクエリを特定し、インデックスを追加することで改善するまでの流れを確認しました。

pg_stat_statements って最初はよくわからないツールで、どんなものなんだろ?と思っていましたが、本検証でクエリ回数や時間などを見られることがわかりました。
パフォーマンス改善を考える際にはとても活用できそうです。今後、機会を見つけてガンガン使っていこうと思いました。

本ブログがどなたかのお役に立てば幸いです。

参考情報

https://dev.classmethod.jp/articles/steps-to-enable-pg-stat-statements/
https://www.postgresql.jp/document/17/html/pgstatstatements.html

この記事をシェアする

FacebookHatena blogX

関連記事