【PostgreSQL】同じクエリなのにインデックスが突然使われなくなる事象を確認してみた

【PostgreSQL】同じクエリなのにインデックスが突然使われなくなる事象を確認してみた

2026.04.30

インデックスを使うことで高速にデータ検索が行えます。

一方で、テーブルに保存されたデータの中身は時間と共に変化します。

サービス開始当初は少数派だったデータが、時間の経過とともに多数派になることがあります。

例えばゲームのランク分布を考えてみます。

サービス開始当初は上位ランク(legend)のプレイヤーは、全体の 10% 程度でした。

しかし1年後、プレイヤーが上達し、legend 帯が 60% を占めるようになったとします。

この時、SELECT * FROM players WHERE rank='legend' などのクエリが、
ある日突然インデックスを使わなくなることがあります。

今回はこのシナリオを実際に検証してみました。

実行環境

実行環境は EC2(AL2023)にインストールした PostgreSQL を使用しました。
EC2 に PostgreSQL をインストールする手順としては下記をご参照ください。

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/

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)

サンプルデータの作成

検証用にサンプルデータを作成します。
具体的には、players テーブルを作り、そこに 10 万行のレコードを投入します。

-- データベースを作成
postgres=# CREATE DATABASE blog_sample_db;
CREATE DATABASE

-- データベース切り替え
postgres=# \c blog_sample_db
You are now connected to database "blog_sample_db" as user "postgres".

-- テーブル作成
blog_sample_db=# CREATE TABLE players (
    id SERIAL PRIMARY KEY,
    username TEXT,
    rank TEXT
);

-- 10万行のデータを投入
blog_sample_db=# INSERT INTO players (username, rank)
SELECT 'user_' || i, 'bronze'
FROM generate_series(1, 70000) i;
INSERT 0 70000

blog_sample_db=# INSERT INTO players (username, rank)
SELECT 'user_' || i, 'silver'
FROM generate_series(70001, 85000) i;
INSERT 0 15000

blog_sample_db=# INSERT INTO players (username, rank)
SELECT 'user_' || i, 'gold'
FROM generate_series(85001, 90000) i;
INSERT 0 5000

blog_sample_db=# INSERT INTO players (username, rank)
SELECT 'user_' || i, 'legend'
FROM generate_series(90001, 100000) i;
INSERT 0 10000

テーブルの中身は以下の形式になっています。

-- テーブルの先頭 5 行を表示
blog_sample_db=# SELECT * FROM players LIMIT 5;
 id | username |  rank  
----+----------+--------
  1 | user_1   | bronze
  2 | user_2   | bronze
  3 | user_3   | bronze
  4 | user_4   | bronze
  5 | user_5   | bronze
(5 rows)

そして 10 万行のデータの内訳は以下です。
bronze が 70%、legend が 10% という分布です。

-- データ件数とrank内訳を確認
blog_sample_db=# SELECT 
    rank,
    COUNT(*) AS count,
    ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS percentage
FROM players
GROUP BY rank
ORDER BY count DESC;

  rank  | count | percentage 
--------+-------+------------
 bronze | 70000 |       70.0
 silver | 15000 |       15.0
 legend | 10000 |       10.0
 gold   |  5000 |        5.0
(4 rows)

最後に rank カラムにインデックスを作っておきます。

-- rank カラムにインデックスを作成
blog_sample_db=# CREATE INDEX idx_players_rank ON players(rank);

-- インデックスが作成できたか確認
blog_sample_db=# \d players
                             Table "public.players"
  Column  |  Type   | Collation | Nullable |               Default               
----------+---------+-----------+----------+-------------------------------------
 id       | integer |           | not null | nextval('players_id_seq'::regclass)
 username | text    |           |          | 
 rank     | text    |           |          | 
Indexes:
    "players_pkey" PRIMARY KEY, btree (id)
    "idx_players_rank" btree (rank)

サービス開始当初の EXPLAIN ANALYZE

サービス開始直後の想定で、データ分布を変える前の状態を確認しておきます。
bronze(70%)と legend(10%)の検索において、それぞれどんなスキャンが選ばれるか見てみます。

-- bronze(70%)検索
blog_sample_db=# EXPLAIN ANALYZE SELECT * FROM players WHERE rank = 'bronze';
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on players  (cost=0.00..1887.00 rows=69880 width=20) (actual time=0.009..9.369 rows=70000 loops=1)
   Filter: (rank = 'bronze'::text)
   Rows Removed by Filter: 30000
 Planning Time: 0.130 ms
 Execution Time: 11.389 ms
(5 rows)

-- legend(10%)検索
blog_sample_db=# EXPLAIN ANALYZE SELECT * FROM players WHERE rank = 'legend';
                                                             QUERY PLAN                                                              
---------------------------------------------------------------------------------------------------------------------
----------------
 Index Scan using idx_players_rank on players  (cost=0.29..481.91 rows=10200 width=20) (actual time=0.078..1.217 rows
=10000 loops=1)
   Index Cond: (rank = 'legend'::text)
 Planning Time: 0.065 ms
 Execution Time: 1.541 ms
(4 rows)

上記より、それぞれスキャン方法に違いがあります。

  • 分布多い bronze(70%)検索 - Seq Scan(全件スキャン)
  • 分布少ない legend(10%)検索 -Index Scan

これが「分布変更前」の状態です。次にデータ分布を変えてみます。

サービス開始から 1 年後の EXPLAIN ANALYZE

サービス開始から 1 年が経過し、
既存の bronze プレイヤーの 5 万人が上位の legend 帯になったとします。
以下クエリで再現します。

-- bronze プレイヤー 5 万人を legend に格上げ
blog_sample_db=# UPDATE players 
SET rank = 'legend' 
WHERE rank = 'bronze' 
AND id IN (
    SELECT id FROM players 
    WHERE rank = 'bronze' 
    LIMIT 50000
);

その結果 rank カラムの分布が以下に示す通り、 legend: 60% に変わりました。

-- rank カラムの分布を確認
blog_sample_db=# SELECT 
    rank,
    COUNT(*) AS count,
    ROUND(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER (), 1) AS percentage
FROM players
GROUP BY rank
ORDER BY count DESC;

  rank  | count | percentage 
--------+-------+------------
 legend | 60000 |       60.0
 bronze | 20000 |       20.0
 silver | 15000 |       15.0
 gold   |  5000 |        5.0
(4 rows)

この状態で、サービス開始当初と同じクエリ(bronze と legend の検索)を実行します。

-- bronze(20%)検索
blog_sample_db=# EXPLAIN ANALYZE SELECT * FROM players WHERE rank = 'bronze';
                                                            QUERY PLAN                                                             
----------------------------------------------------------------------------------------------------------------------
-------------
 Bitmap Heap Scan on players  (cost=260.69..1468.94 rows=20180 width=20) (actual time=0.422..2.006 rows=20000 loops=1)
   Recheck Cond: (rank = 'bronze'::text)
   Heap Blocks: exact=128
   ->  Bitmap Index Scan on idx_players_rank  (cost=0.00..255.64 rows=20180 width=0) (actual time=0.401..0.401 rows=20
000 loops=1)
         Index Cond: (rank = 'bronze'::text)
 Planning Time: 0.088 ms
 Execution Time: 2.605 ms
(7 rows)

-- legend(60%)検索
blog_sample_db=# EXPLAIN ANALYZE SELECT * FROM players WHERE rank = 'legend';
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on players  (cost=0.00..2206.00 rows=59590 width=20) (actual time=3.176..9.345 rows=60000 loops=1)
   Filter: (rank = 'legend'::text)
   Rows Removed by Filter: 40000
 Planning Time: 0.060 ms
 Execution Time: 11.096 ms
(5 rows)

すると、分布変更後は以下のようにスキャン方法が変わりました。

  • 分布が減った bronze(70% → 20%)- Seq Scan -> Bitmap Index Scan
  • 分布が増えた legend(10% → 60%)- Index Scan -> Seq Scan

サービス開始当初は Index Scan だった legend の検索が、
1年後にはインデックスを使用しない Seq Scan に切り替わっています。

同じクエリなのに、データ分布の変化によって
PostgreSQL が自動でスキャン方法を切り替えたことがわかります。

おわりに

今回の検証で、同じクエリでも、データ分布の変化によって実行計画が変わることが確認できました。

なお、インデックスが使用されなくなったからといって、必ずしも悪いというわけではありません。

今回のように 60,000 件を Index Scan で取得するより、Seq Scan で全件取得した方が速いと PostgreSQL が正しく判断した結果です。
PostgreSQL がコストを計算した上で最適な判断をしているため、実行計画が変わること自体は問題ありません。

なお、同じことはゲーム以外でも起こりえます。
例えば年代を扱うカラムでも同様です。
サービス開始当初は 20 代ユーザーが多数派でも、10 年後には 20代ユーザーが減り、30 代・40 代が多数派になることがあります。

ある日突然インデックスが使われなくなったり、本番環境と STG 環境で実行計画に差が出た時に「データ分布が違うから?」と疑えると、不要な心配をしなくても良くなりそうです。

自分自身が DB を触っていてインデックスが使われなくなっていることに疑問に思ったので記事に残してみました。
本記事がどなたかの理解の助けになれば幸いです。

参考文献

https://dev.classmethod.jp/articles/installing-postgresql-on-ec2-al2023/

この記事をシェアする

関連記事