- はじめに — インデックスを作ったのになぜSeq Scanなのか
- 前提 — インデックスを使わないのがたいてい正しい判断である
- 選択度 — インデックスがかえって損になる地点
- sargableでない条件 — 関数、暗黙のキャスト、先頭ワイルドカード
- 複合インデックスの先頭カラム規則、OR、NULL
- 統計情報が古びると、オプティマイザは正確に間違える
- 部分インデックスが答えになる場合、そしてインデックスの裏側のコスト
- おわりに — インデックスを疑う前に計画を読んでください
はじめに — インデックスを作ったのになぜSeq Scanなのか
遅いクエリを見つけてインデックスを作り、もう一度EXPLAINを回したのに計画がそのままです。CREATE INDEXは確かに成功しており、pg_indexesにも見えます。それなのにオプティマイザは相変わらずテーブル全体をなめています。
この状況で検索して見つかる助言はたいてい二つです。SET enable_seqscan = offで強制しろ、あるいはヒントを付けろ。どちらも診断ではなく症状の抑え込みです。強制的にインデックスを使わせるとクエリがかえって遅くなるケースは実際によくあります。
まず前提から正しておきます。オプティマイザがインデックスを使わないのは、たいてい正しい判断です。正しくない少数の場合は原因が決まっていて、その原因は実行計画に痕跡を残します。この記事では、その痕跡を原因別に読む方法を整理します。
前提 — インデックスを使わないのがたいてい正しい判断である
コストベースのオプティマイザは、可能な計画のコストを計算して最も安いものを選びます。インデックススキャンのコストには、インデックスページの読み取りと、マッチした行ごとにヒープページをランダムに訪れるコストが含まれます。シーケンシャルスキャンはページを物理的な順序で読むので、プリフェッチと読み取り併合の利得を得ます。
ですから強制的にインデックスを使わせてみると、オプティマイザが正しかったと確認することになる場合が多いのです。
-- オプティマイザの選択
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
Seq Scan on orders (cost=0.00..20117.00 rows=612430 width=88)
(actual time=0.014..184.221 rows=611884 loops=1)
Filter: (status = 'completed')
Rows Removed by Filter: 188116
Buffers: shared hit=1211 read=10906
Execution Time: 231.774 ms
-- インデックスを強制した結果
SET enable_seqscan = off;
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM orders WHERE status = 'completed';
RESET enable_seqscan;
Index Scan using idx_orders_status on orders
(cost=0.42..68914.33 rows=612430 width=88)
(actual time=0.061..1042.883 rows=611884 loops=1)
Index Cond: (status = 'completed')
Buffers: shared hit=8842 read=598112
Execution Time: 1094.201 ms
231msから1094msへ、4倍以上遅くなりました。読んだブロック数は12117から606954へと50倍になっています。76パーセントの行を返す条件でインデックスが損だということを、オプティマイザはすでに知っていました。
enable_seqscan = offはシーケンシャルスキャンを禁止するのではなく、コストに大きな定数を加えるだけです。診断ツールとしては優秀ですが、運用設定として使ってはいけません。
選択度 — インデックスがかえって損になる地点
境界はどのあたりでしょうか。よく引用される数字は5から10パーセントです。しかしこの数字だけを覚えると判断を誤ります。実際の境界を決める変数は三つあります。
第一に、random_page_costです。デフォルト値の4.0は回転ディスク基準です。SSDやNVMeでこの値をそのままにしておくと、オプティマイザはランダムアクセスを実際より4倍高く評価してインデックスを過小評価します。
-- SSD環境での現実的な値
ALTER SYSTEM SET random_page_cost = 1.1;
SELECT pg_reload_conf();
この一行で計画が変わるケースは思ったより多いです。インデックスが使われないという問い合わせのかなりの部分は、この設定一つで解決します。
第二に、物理的な整列度です。インデックス順とヒープ順が一致すると、ランダムアクセスは事実上シーケンシャルアクセスになります。
SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders'
AND attname IN ('created_at', 'user_id', 'status');
attname | n_distinct | correlation
------------+------------+-------------
created_at | -1 | 0.998304
user_id | 48210 | 0.003911
status | 6 | 0.412008
created_atは相関度が1に近いので、広い範囲を検索してもインデックスが有利です。user_idは0に近いので、同じ選択度でもはるかに不利です。同じ5パーセントでもカラムによって答えが違う理由がここにあります。
第三に、返却カラムです。SELECT *は必ずヒープを訪れなければなりませんが、必要なカラムがすべてインデックスに入っていればIndex Only Scanが可能になります。
-- INCLUDEでカバリングインデックスを作る (PostgreSQL 11以上)
CREATE INDEX CONCURRENTLY idx_orders_status_covering
ON orders (status) INCLUDE (id, total, created_at);
Index Only Scan using idx_orders_status_covering on orders
(actual time=0.038..142.118 rows=611884 loops=1)
Index Cond: (status = 'completed')
Heap Fetches: 1204
Heap Fetchesが小さくてこそ意味があります。この値が大きいなら可視性マップが最新ではないという意味で、VACUUMが必要です。
sargableでない条件 — 関数、暗黙のキャスト、先頭ワイルドカード
インデックスはカラムの値そのもので整列されています。カラムに何かをかぶせた瞬間、その整列は無意味になります。
-- 使われない: カラムに関数がかかっている
SELECT * FROM orders WHERE date(created_at) = '2026-07-26';
-- 使われる: 範囲条件に変えてカラムをそのまま残す
SELECT * FROM orders
WHERE created_at >= '2026-07-26'
AND created_at < '2026-07-27';
-- 使われない: カラム側に演算
SELECT * FROM order_items WHERE price * quantity > 100000;
-- 使われる: 生成カラムか式インデックスへ移す
CREATE INDEX idx_items_amount ON order_items ((price * quantity));
LOWER(email)も同じ部類です。ただしここには、よくある誤解が一つ付いてきます。式インデックスを作ったなら、クエリもまったく同じ式を使わなければなりません。
CREATE INDEX idx_users_email_lower ON users (lower(email));
-- 使われる
SELECT * FROM users WHERE lower(email) = 'a@example.com';
-- 使われない: 式が違う
SELECT * FROM users WHERE lower(trim(email)) = 'a@example.com';
暗黙のキャストはもっと静かに失敗します。PostgreSQLは型が合わなければおおむねエラーを出しますが、キャストが可能な組み合わせでは静かに変換します。
-- codeカラムがvarchar、インデックスもvarchar基準
EXPLAIN SELECT * FROM products WHERE code = 12345;
ERROR: operator does not exist: character varying = integer
PostgreSQLはここでエラーを出します。問題は逆方向です。
-- idはbigintなのにパラメータがnumericで入ってきた場合
EXPLAIN SELECT * FROM orders WHERE id = 1001::numeric;
Seq Scan on orders (cost=0.00..24117.00 rows=4001 width=88)
Filter: ((id)::numeric = '1001'::numeric)
カラム側が(id)::numericに変換され、その瞬間にbigintのインデックスは使えなくなります。計画でカラム名に::のキャストが付いていたら、この問題です。
MySQLでははるかに頻繁に発生します。文字列カラムと数値を比較するとエラーなしで両側を数値に変換し、カラム側が変換されるのでインデックスが死にます。さらに、結合する二つのカラムのコレーションが違えば同じことが起きます。utf8mb4_general_ciとutf8mb4_unicode_ciのカラムを結合しておいて、なぜ遅いのか分からず迷う事例は非常によくあります。
LIKEの先頭ワイルドカードも同じ原理です。
-- 使われる: 接頭辞が固定されるのでインデックスの範囲探索が可能
SELECT * FROM users WHERE name LIKE 'kim%';
-- 使われない: 開始点が分からないので全体を見なければならない
SELECT * FROM users WHERE name LIKE '%kim%';
ここでよくある誤答が「だから全文検索エンジンを導入しよう」です。その前に、PostgreSQLの中で解決できる場合が多いです。
-- pg_trgm: 部分文字列検索をインデックスで処理する
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_users_name_trgm
ON users USING gin (name gin_trgm_ops);
EXPLAIN ANALYZE SELECT * FROM users WHERE name LIKE '%kim%';
Bitmap Heap Scan on users (actual time=2.114..8.902 rows=412 loops=1)
Recheck Cond: (name ~~ '%kim%'::text)
-> Bitmap Index Scan on idx_users_name_trgm (actual time=1.884..1.884 rows=498 loops=1)
Index Cond: (name ~~ '%kim%'::text)
C以外のロケールでは、接頭辞LIKEですら通常のB-Treeインデックスを使えない場合があります。このときはtext_pattern_ops演算子クラスで補助インデックスを作ります。
CREATE INDEX idx_users_name_prefix
ON users (name text_pattern_ops);
複合インデックスの先頭カラム規則、OR、NULL
複合インデックス(a, b, c)は、aで整列し、aが同じもの同士をbで、bも同じならcで整列した構造です。電話帳を姓で整列し、同じ姓の中で名前で整列したのと同じです。姓を知らないまま名前だけでは探せません。
CREATE INDEX idx_orders_multi ON orders (user_id, status, created_at);
WHERE user_id = 1— 探索可能WHERE user_id = 1 AND status = 'paid'— 探索可能WHERE user_id = 1 AND created_at >= '2026-07-01'— user_idだけで絞り、created_atはフィルタで処理WHERE status = 'paid'— 先頭カラムがないので探索不可
最後の場合でも、PostgreSQLがインデックスをまったく使えないわけではありません。テーブルよりインデックスがはるかに小さければ、Index Only Scanでインデックス全体をなめる計画を選ぶことがあります。ただしこれは探索ではなく全体スキャンなので、期待する性能は出ません。
カラム順を決める原則は明確です。等値条件のカラムを前に、範囲条件のカラムを後ろに置きます。範囲条件がかかったカラムより後ろのカラムは探索に使えず、フィルタとしてしか働かないからです。
OR条件は少し違います。各項にインデックスがあれば、PostgreSQLはビットマップで結合できます。
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = 'a@b.com' OR phone = '01012345678';
Bitmap Heap Scan on users (actual time=0.061..0.064 rows=2 loops=1)
Recheck Cond: ((email = 'a@b.com') OR (phone = '01012345678'))
-> BitmapOr (actual time=0.052..0.052 rows=0 loops=1)
-> Bitmap Index Scan on idx_users_email (actual time=0.031..0.031 rows=1 loops=1)
-> Bitmap Index Scan on idx_users_phone (actual time=0.019..0.019 rows=1 loops=1)
BitmapOrノードが見えたなら、うまく処理されたということです。問題はORの片方の項にだけインデックスがないときです。その瞬間、全体がシーケンシャルスキャンに落ちます。一つでもインデックスのない項があれば残りのインデックスは無用の長物になる、という点を覚えておいてください。こういうときはUNION ALLで分割するのが実際に役立ちます。
NULLもよく誤解されます。「インデックスはNULLを格納しない」という言い方が広く出回っていますが、これはOracleのB-Treeインデックスの話です。PostgreSQLのB-TreeはNULLを格納するので、IS NULL条件もインデックスで処理されます。
EXPLAIN ANALYZE
SELECT * FROM orders WHERE cancelled_at IS NULL;
Index Scan using idx_orders_cancelled_at on orders
(actual time=0.022..1.884 rows=304 loops=1)
Index Cond: (cancelled_at IS NULL)
ただしIS NOT NULLがテーブルの大部分を返すなら、先ほど扱った選択度の問題に戻ります。
統計情報が古びると、オプティマイザは正確に間違える
大量ロードや大量削除の直後に計画がおかしくなるのは、ほぼ常に統計情報の問題です。オプティマイザは統計情報をもとに正確に計算しますが、その統計情報が現実と違えば結果も現実と違います。
-- 最後のANALYZE時点とその後の変更量を確認
SELECT relname,
n_live_tup,
n_mod_since_analyze,
last_analyze,
last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'orders';
relname | n_live_tup | n_mod_since_analyze | last_analyze | last_autoanalyze
---------+------------+---------------------+---------------------+------------------
orders | 804112 | 611903 | 2026-07-19 03:12:44 | 2026-07-19 03:12:44
80万行のテーブルに61万件の変更があったのに、統計情報は一週間前のものです。autovacuumのデフォルトのanalyze_scale_factorは0.1、つまり10パーセントが変わって初めて回りますが、大型テーブルではこの閾値が大きすぎます。
-- 大きなテーブルはテーブル単位で閾値を下げる
ALTER TABLE orders SET (
autovacuum_analyze_scale_factor = 0.01,
autovacuum_analyze_threshold = 5000
);
-- バッチロードの直後には明示的に更新する
ANALYZE orders;
注意点が一つ。大量ロードを行うバッチパイプラインなら、ロードが終わったあとにANALYZEを呼ぶほうがautovacuumを待つより常に良いです。autovacuumはロード直後の数分間は回らないことがあり、その間に入ってきた検索クエリが誤った計画で実行されます。
部分インデックスが答えになる場合、そしてインデックスの裏側のコスト
ここまでの原因を一枚にまとめると次のようになります。
| 症状 (計画で見えるもの) | 原因 | 解法 |
|---|---|---|
| Seq Scanに大きなRows Removed by Filter | 選択度が低い | 部分インデックス、カバリングインデックス、random_page_costの調整 |
| Filterに関数呼び出しが見える | sargableでない条件 | 範囲条件への書き換え、または式インデックス |
| カラムにキャスト表記が付いている | 型またはコレーションの不一致 | パラメータの型をカラムに合わせる |
| FilterにLIKEの先頭ワイルドカード | 接頭辞を固定できない | pg_trgmのGINインデックス |
| 先頭カラムのない複合インデックス条件 | 先頭カラム規則 | カラム順の再設計、または別インデックス |
| 予想rowsと実際のrowsが大きく違う | 統計情報の老朽化またはカラム間の相関 | ANALYZE、STATISTICSの引き上げ、拡張統計 |
部分インデックスは、このうち複数の問題を一度に解く道具です。特に全体の数パーセントだけが検索対象になるステータスカラムに強力です。
-- 未処理の注文だけを検索するワークロード
CREATE INDEX CONCURRENTLY idx_orders_pending
ON orders (created_at)
WHERE status = 'pending';
-- サイズの比較
SELECT indexrelname,
pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'orders';
indexrelname | size
-------------------------+---------
idx_orders_status | 42 MB
idx_orders_pending | 312 kB
42MBのインデックスが312KBになりました。サイズが縮むだけでなく、書き込みコストも下がります。条件に合わない行のINSERTとUPDATEはこのインデックスに触れないからです。
ここでインデックスの裏側のコストを押さえておく必要があります。インデックスはただではありません。
- すべてのINSERTとDELETEは、そのテーブルのすべてのインデックスを更新します。
- UPDATEは変更カラムがインデックスになければHOT更新でインデックスを飛ばせますが、インデックスが多くページに余裕がないとHOTが壊れます。
fillfactorを下げて余裕を持たせるのが役立ちます。 - インデックスが増えると計画候補が増え、計画立案の時間も増えます。
ですから定期的に使われていないインデックスを取り除く必要があります。
SELECT s.relname AS table_name,
s.indexrelname AS index_name,
s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary
ORDER BY pg_relation_size(s.indexrelid) DESC;
table_name | index_name | idx_scan | size
------------+----------------------------+----------+--------
orders | idx_orders_updated_at | 0 | 88 MB
events | idx_events_legacy_type | 0 | 41 MB
二つを確認してから消してください。第一に、pg_stat_user_indexesは最後の統計リセット以降の累積値なので、いつリセットされたかを確認しなければなりません。月末バッチでしか使わないインデックスを月初に判断してはいけません。第二に、レプリカで使われるインデックスはプライマリの統計には現れません。レプリカでも同じクエリを回して確認する必要があります。
-- 統計収集の開始時点を確認
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
-- 削除せず計画から外して観察したい場合 (カタログの直接修正。運用では推奨しない)
UPDATE pg_index SET indisvalid = false
WHERE indexrelid = 'idx_orders_updated_at'::regclass;
PostgreSQLにはインデックスを安全に無効化する公式コマンドがありません。上の方法はカタログを直接いじるものなので、運用では避けるべきです。現実的な手順は、消す前にインデックス定義をテキストで残しておき、問題が起きたらそのまま作り直すことです。
SELECT indexdef FROM pg_indexes WHERE indexname = 'idx_orders_updated_at';
DROP INDEX CONCURRENTLY idx_orders_updated_at;
おわりに — インデックスを疑う前に計画を読んでください
インデックスが使われないという問題は、実は二種類あります。オプティマイザが正しくて使わない場合と、自分が書いたクエリがインデックスを使えない形なので使えない場合です。この二つは対応が正反対です。前者はインデックスを変えるか諦めるべきで、後者はクエリを直すべきです。
見分ける方法は簡単です。実行計画のFilterの行を見てください。条件が原文のまま書かれていれば選択度の問題です。条件に関数やキャストがかぶさっていればクエリの問題です。そして予想rowsと実際のrowsが大きく違えば統計情報の問題です。
三つのどれもenable_seqscan = offでは直りません。強制オプションは仮説を検証するときにだけ使い、検証が終わったら本来の原因に戻ってください。
현재 단락 (1/199)
遅いクエリを見つけてインデックスを作り、もう一度`EXPLAIN`を回したのに計画がそのままです。`CREATE INDEX`は確かに成功しており、`pg_indexes`にも見えます。それなのにオプ...