
【PostgreSQL】 インデックスを貼りすぎたら更新クエリが遅くなるのか確認してみた
最近 PostgreSQL を学んでいて「インデックスショットガン」という言葉を知りました。
これは、闇雲にインデックスを設定することによって、パフォーマンスが悪化することを表した言葉です。
一般的にインデックスを作成することで検索は速くなりますが、
逆に INSERT/UPDATE/DELETE などの更新系の処理が遅くなると言われています。
今回は以下の2点を実際に検証してみました。
- インデックスを増やすと INSERT がどのくらい遅くなるのか
- 不要なインデックスを削除すると INSERT がどのくらい改善するのか
インデックスが増えると遅くなる理由
インデックスが増えると遅くなる理由はデータ更新時にインデックスも併せて更新する必要が出てくるためです。
インデックスは本の索引をイメージするといいのですが、
本の索引があると読みたいページをすぐに見つけられますが、本の内容を更新するたびに索引も随時更新しなければなりません。
データベースのインデックスも同じで、
INSERT / UPDATE / DELETE などの更新系の処理が実行されるたびに、テーブルのデータだけでなくインデックスも更新する必要が出てきます。
そのため、インデックスが増えれば増えるほど更新のたびに更新しなければならないインデックスも増え、処理が遅くなります。
実行環境
検証のための実行環境は EC2(AL2023)にインストールした PostgreSQL を使用しました。
EC2 に PostgreSQL をインストールする手順としては下記をご参照ください。
PostgreSQL 17.7 を利用し、検証していきます。
[ec2-user@ip-10-0-13-178 ~]$ sudo -u postgres psql
psql (17.7)
Type "help" for help.
postgres=# select * from version();
version
--------------------------------------------------------------------------------------------------------------
PostgreSQL 17.7 on x86_64-amazon-linux-gnu, compiled by gcc (GCC) 11.5.0 20240719 (Red Hat 11.5.0-5), 64-bit
(1 row)
サンプルデータ準備
検証用にサンプルデータベースおよびテーブルを作成します。
テーブル no_index_table がインデックスなしテーブル(プライマリキーのみ存在)、many_index_table が複数のインデックスを追加するためのテーブルです。
-- 検証用 DB の作成
postgres=# CREATE DATABASE blog_sample_db;
CREATE DATABASE
-- DB 切り替え
postgres=# \c blog_sample_db
You are now connected to database "blog_sample_db" as user "postgres".
-- 検証用テーブルの作成
-- 1. 追加インデックスなしのテーブル
-- ※PRIMARY KEYのインデックスは存在する
blog_sample_db=# CREATE TABLE no_index_table (
id SERIAL PRIMARY KEY,
user_id INT,
status VARCHAR(20),
score INT,
category VARCHAR(20),
created_at TIMESTAMP
);
CREATE TABLE
-- 2. 複数インデックスを貼る用のテーブル
blog_sample_db=# CREATE TABLE many_index_table (
id SERIAL PRIMARY KEY,
user_id INT,
status VARCHAR(20),
score INT,
category VARCHAR(20),
created_at TIMESTAMP
);
CREATE TABLE
テーブル作成できたので many_index_table テーブルにインデックスを複数追加します。
-- 複数のインデックスを追加
blog_sample_db=# CREATE INDEX idx_user_id ON many_index_table(user_id);
CREATE INDEX idx_status ON many_index_table(status);
CREATE INDEX idx_score ON many_index_table(score);
CREATE INDEX idx_category ON many_index_table(category);
CREATE INDEX idx_created_at ON many_index_table(created_at);
-- インデックス追加できたか確認
blog_sample_db=# \di
List of relations
Schema | Name | Type | Owner | Table
--------+-----------------------+-------+----------+------------------
public | idx_category | index | postgres | many_index_table
public | idx_created_at | index | postgres | many_index_table
public | idx_score | index | postgres | many_index_table
public | idx_status | index | postgres | many_index_table
public | idx_user_id | index | postgres | many_index_table
public | many_index_table_pkey | index | postgres | many_index_table
public | no_index_table_pkey | index | postgres | no_index_table
(7 rows)
インデックスが多いと更新処理が遅くなるのか検証
前項でサンプルデータが準備できたので、実際にインデックスが多いと INSERT 処理が遅くなるのか検証していきます。
まず、クエリ実行時間計測のために \timing を有効化しておきます。
blog_sample_db=# \timing
Timing is on.
\timing [ on | off ]
パラメータがある場合、各SQL文にかかる時間の表示の有無をonまたはoffに設定します。 パラメータがない場合、表示をonとoffの間で切り替えます。 表示はミリセカンド単位です。
まずは追加インデックスなしのテーブル no_index_table に 10 万件データを 3回 INSERT してみます。
-- 1回目の INSERT
blog_sample_db=# INSERT INTO no_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 330.944 ms
-- 2 回目の INSERT
blog_sample_db=# INSERT INTO no_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 301.468 ms
-- 3 回目の INSERT
blog_sample_db=# INSERT INTO no_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 294.076 ms
その結果 no_index_table の平均 INSERT 時間は以下となりました。
no_index_table への INSERT 時間
1回目: 330ms
2回目: 301ms
3回目: 294ms
-----------
平均: 308ms
続いて、全カラムにインデックスが追加されている many_index_table へも同様に 10 万件のデータを 3 回 INSERT してみます。
-- 1回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 1146.613 ms (00:01.147)
-- 2回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 1353.927 ms (00:01.354)
-- 3回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 1357.887 ms (00:01.358)
実行時間の結果は以下です。
many_index_table への INSERT 時間
1回目: 1146ms
2回目: 1353ms
3回目: 1357ms
-----------
平均: 1285ms
両者を比較すると約 4 倍程度も INSERT 処理のパフォーマンスに差がありました。
| テーブル | インデックス数 | 平均INSERT時間 |
|---|---|---|
| no_index_table | 1個(PK のみ) | 308ms |
| many_index_table | 6個 | 1285ms |
この結果から、闇雲なインデックス作成はなるべく避けた方がいいことがわかりました。
未使用インデックスを見つけて削除する
前項でインデックスが多いと更新処理が遅くなることがわかりました。
そのため本セクションでは、未使用な不要なインデックスを削除して、少しでも INSERT 処理を高速化できるのか検討してみます。
まず、テーブルのインデックス使用状況は pg_stat_user_indexes ビューから確認ができます。
blog_sample_db=# SELECT
indexrelname,
idx_scan,
last_idx_scan
FROM
pg_stat_user_indexes
WHERE
relname = 'many_index_table';
indexrelname | idx_scan | last_idx_scan
-----------------------+----------+---------------
many_index_table_pkey | 0 |
idx_user_id | 0 |
idx_status | 0 |
idx_score | 0 |
idx_category | 0 |
idx_created_at | 0 |
(6 rows)
上記結果の idx_scan カラムはインデックスが使われた回数です。
現在は一度もこのテーブルに SELECT などのクエリを発行していないので 0 回となり、全く使われていないことがわかります。
次に last_idx_scan はインデックスが最後に使われた日時です。
上記結果のように NULL(空欄) の場合はインデックスが一度も使われていないことを意味します。
見方の一例として、idx_scan が 0 ではないが last_idx_scan が数ヶ月以上前など古い場合は、過去には使われていたが現在は使われていないゾンビインデックスの可能性などが考えられます。
pg_stat_user_indexes の詳細は以下ドキュメントを参照してください。
pg_stat_all_indexesビューは、現在のデータベース内のインデックスごとに1行の形式で、特定のインデックスへのアクセスに関する統計情報を表示します。 pg_stat_user_indexesとpg_stat_sys_indexesも同じ情報を保持しますが、ユーザ向けのインデックスとシステム向けのインデックスに対する行のみを保持するようにフィルタ処理されています。
relname
このインデックスに対応するテーブルの名前です。idx_scan
インデックスに対して開始されたインデックススキャンの実行回数です。last_idx_scan
最新のトランザクション停止時刻に基づく、このインデックスの最後のスキャン時刻です。
ここで例えば many_index_table に対し、
クライアントアプリケーションから以下のようなuser_id と status カラムを条件としたクエリのみが発行されていたとします。
SELECT * FROM many_index_table WHERE user_id = 100;
SELECT * FROM many_index_table WHERE status = 'active';
一度実行してみましょう。
-- user_id カラムの検索
blog_sample_db=# SELECT * FROM many_index_table WHERE user_id = 100;
id | user_id | status | score | category | created_at
--------+---------+----------+-------+----------+----------------------------
3841 | 100 | pending | 12 | C | 2025-09-16 16:10:56.117879
12860 | 100 | active | 76 | C | 2026-01-15 07:41:21.074477
...
-- status カラムの検索
blog_sample_db=# SELECT * FROM many_index_table WHERE status = 'active';
id | user_id | status | score | category | created_at
--------+---------+--------+-------+----------+----------------------------
4 | 3048 | active | 42 | A | 2025-10-30 13:44:37.935678
7 | 6906 | active | 83 | A | 2025-06-24 05:40:18.691529
...
再度 pg_stat_user_indexes の内容を見てみます。
以下結果より user_id や status カラムに作成したインデックスは利用されていますが、それ以外のものは使用されていません。
blog_sample_db=# SELECT
indexrelname,
idx_scan,
last_idx_scan
FROM
pg_stat_user_indexes
WHERE
relname = 'many_index_table';
indexrelname | idx_scan | last_idx_scan
-----------------------+----------+-------------------------------
many_index_table_pkey | 0 |
idx_user_id | 1 | 2026-05-06 03:15:08.203719+00
idx_status | 1 | 2026-05-06 03:16:52.332124+00
idx_score | 0 |
idx_category | 0 |
idx_created_at | 0 |
(6 rows)
クライアントアプリからの検索がこれら 2 つのカラム(user_id, status)のみを条件の対象とするなら、現時点で score や category, created_at カラムのインデックスは不要となります。
ということで不要なインデックスを削除します。
-- 不要なインデックスを削除
blog_sample_db=# DROP INDEX idx_score;
DROP INDEX
blog_sample_db=# DROP INDEX idx_category;
DROP INDEX
blog_sample_db=# DROP INDEX idx_created_at;
DROP INDEX
-- 削除できたか確認
blog_sample_db=# \di
List of relations
Schema | Name | Type | Owner | Table
--------+-----------------------+-------+----------+------------------
public | idx_status | index | postgres | many_index_table
public | idx_user_id | index | postgres | many_index_table
public | many_index_table_pkey | index | postgres | many_index_table
public | no_index_table_pkey | index | postgres | no_index_table
(4 rows)
これで不要なインデックス削除が完了です。
不要インデックスを削除後、更新処理が速くなるか検証
不要なインデックスを削除できたので、改めて INSERT 処理をして速度が改善するのか確認していきます。
インデックス削除前と同じ条件で計測するために
TRUNCATE で一旦テーブルを空にしてから再度 INSERT します。
-- テーブルの中身を空にする
blog_sample_db=# TRUNCATE many_index_table;
TRUNCATE TABLE
-- テーブルが空になったか確認
blog_sample_db=# SELECT COUNT(*) FROM many_index_table;
count
-------
0
(1 row)
10 万件のデータを 3 回 INSERT します。
-- 実行時間表示のために有効化
blog_sample_db=# \timing
Timing is on.
-- 1回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 921.305 ms
-- 2回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 632.576 ms
-- 3回目
blog_sample_db=# INSERT INTO many_index_table (user_id, status, score, category, created_at)
SELECT
(random() * 10000)::INT,
(ARRAY['active', 'inactive', 'pending'])[floor(random() * 3 + 1)],
(random() * 100)::INT,
(ARRAY['A', 'B', 'C', 'D'])[floor(random() * 4 + 1)],
NOW() - (random() * INTERVAL '365 days')
FROM generate_series(1, 100000);
INSERT 0 100000
Time: 660.561 ms
処理時間の結果は以下となりました。
1回目: 921ms
2回目: 632ms
3回目: 660ms
-----------
平均: 737ms
これまで行ったインデックス数と平均 INSERT 時間の関係を以下にまとめます。
こう見ると、インデックスの数が多いことでパフォーマンスが悪化していることがよくわかります。
| テーブル | インデックス数 | 平均INSERT時間 |
|---|---|---|
| no_index_table | 1個(PKのみ) | 308ms |
| many_index_table(削除前) | 6個 | 1285ms |
| many_index_table(削除後) | 3個 | 737ms |
インデックスが 1 個の場合と 6 個の場合では約 4 倍もの差があり、不要なインデックスを 3 個削除するだけで今回の検証環境ベースではありますが約 42% ((1285ms - 737ms) / 1285ms = 0.426) の改善が見られました。
終わりに
今回は闇雲にインデックスを作成するインデックスショットガンというアンチパターンについて、実際に検証してみました。
検証の結果、以下の教訓を得ることができました。
- インデックスは増やすほど INSERT / UPDATE / DELETE が遅くなる
- pg_stat_user_indexes の idx_scan を確認することで未使用のインデックスを特定できる
- 不要なインデックスを削除するだけで更新系の処理が改善する
- インデックスは「本当に必要なカラムだけに絞る」ことが大切
インデックスは貼れば貼るほど良いというわけではなく、適切に管理することが大切だと改めて実感しました。
本記事がどなたかのお役に立てば幸いです。
参考情報










