Tech Learning Daily

2026-08-31 (Mon) — 第 46 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
DB 🌿 基礎 ⏱ 約 8 分

インデックスを貼ったのに、なぜ EXPLAIN は Seq Scan のままなのか?

遅いクエリに CREATE INDEX を張り、満を持して EXPLAIN を打ったら結果は Seq Scan のまま——そんな経験はないだろうか。インデックスが無視されたように見えて焦るが、実はこれ、PostgreSQL が「今回はインデックスを使わない方が速い」と正しく判断しただけのことが多い。何を根拠にそう判断しているのかを覗いてみる。

🎯 3 行まとめ
  • プランナーは「インデックスがあれば使う」という単純なルールでは動かず、Seq ScanIndex Scan など候補プランごとに見積りコストを計算し、最小のものを採用する
  • コストの見積りは実データを読まず、ANALYZE が事前に集めた統計情報(最頻値・ヒストグラム)から「条件に何割の行が一致するか(選択率)」を推定して計算する。単位はミリ秒ではなく相対的な重みである
  • 統計情報が古いと見積りが実態とズレ、EXPLAIN の見積り行数と EXPLAIN ANALYZE の実測行数が大きく乖離する。乖離が大きい時はまず統計の鮮度を疑う

プランナーは「安いプラン」を選んでいるだけ

PostgreSQL は 1 つの SELECT に対して Seq Scan(テーブルを先頭から順に全部読む)や Index Scan(インデックスをたどって該当行だけ読む)など複数の実行方法を候補として組み立て、それぞれに見積りコストを付けて、最も安いものを実行計画として採用する。これがクエリプランナー(オプティマイザ)の役割だ。

Query
  │
  ▼
Planner(pg_stats を参照)
  │
  ├─ Seq Scan の見積り   cost=850
  └─ Index Scan の見積り cost=120
  │
  ▼
cost 最小のプランを採用 → Executor

つまり「インデックスがあるのに使われない」のはバグではなく、その条件では Index Scan の見積りコストの方が Seq Scan より高いとプランナーが判断した結果であることが多い。なぜ順番に読むだけの Seq Scan の方が安く見積もられることがあるのか、その理由はコストの計算方法にある。

コストはミリ秒ではなく「重み付きの相対値」

PostgreSQL 公式ドキュメントによれば、EXPLAINcost は実行時間そのものではなく、seq_page_cost(デフォルト 1)・random_page_cost(デフォルト 4)・cpu_tuple_cost(デフォルト 0.01)といったパラメータを使って計算した相対的な見積り値にすぎない。Seq Scan のコストはおおよそ「読むページ数 × seq_page_cost + 読む行数 × cpu_tuple_cost」で決まり、Index Scan のコストにはこれに加えてインデックスをたどった後にテーブル本体(ヒープ)へ飛び飛びにアクセスするコストが乗る。

Seq Scan(順次読み取り)
┌─┬─┬─┬─┬─┬─┬─┬─┐
│1│2│3│4│5│6│7│8│→ 先頭から順に全ページ
└─┴─┴─┴─┴─┴─┴─┴─┘

Index Scan(低選択率で有利)
┌─┬─┬─┬─┬─┬─┬─┬─┐
│1│2│3│4│5│6│7│8│
└─┴↑┴─┴↑┴─┴─┴↑┴─┘
  該当ページだけ拾う(乱読み)

この「飛び飛みアクセス」のコストが random_page_cost(デフォルト 4 = seq_page_cost の 4 倍)で表現されている。条件に一致する行が少なければ Index Scan の飛び飛み読み取りは少数で済み安く上がるが、一致する行が増えるほど飛び飛み読み取りの回数が増え、いずれ「最初から順に全部読んでしまった方が安い」という逆転が起きる。この境目を決める「条件に一致する行の割合」を選択率(selectivity)と呼ぶ。SSD が主流の今は物理的なランダム読み取りの不利がHDD ほど大きくないため、random_page_cost を 1.1 程度まで下げて運用する構成も一般的で、その場合は同じテーブルでも Index Scan がより広い選択率まで選ばれやすくなる。

🍱 たとえるなら

引っ越し業者の見積りに近い。運び出す荷物が家の 2〜3 部屋だけなら、台車でその部屋だけピンポイントに何度も往復した方が速い(Index Scan)。だが家じゅうほぼ全部の荷物を運ぶなら、いちいち部屋を選んで往復するより、玄関からトラックまで一直線の動線で全部屋を順番に運び出した方が結局速い(Seq Scan)。どちらが得かは「運ぶ荷物の割合」次第であり、業者は毎回その割合を見てから搬出方法を決める。

選択率をどこから知るのか — 統計情報(pg_stats)

プランナーはクエリを投げる前に実データを数えたりはしない。代わりに ANALYZE コマンドが事前にテーブルをサンプリングして作った統計情報を pg_statistic システムカタログ(人が読みやすいビューが pg_stats)に保存しておき、プランナーはそれを参照して選択率を推定する。列ごとに記録されるのは、出現頻度が高い値とその頻度(most_common_vals / most_common_freqs)、値の分布を区切ったヒストグラム(histogram_bounds)、物理的な並び順との相関(correlation)などだ。

SELECT attname, n_distinct, correlation
FROM pg_stats
WHERE tablename = 'orders' AND attname = 'status';

この統計の詳細度は default_statistics_target(デフォルト 100)で決まり、値を大きくするほどヒストグラムの区切りが細かくなって歪んだ分布(一部の値だけ極端に多い列など)の見積り精度が上がるが、その分 ANALYZE にかかる時間と pg_statistic の消費領域が増える。列単位で ALTER TABLE ... ALTER COLUMN ... SET STATISTICS を使えば、検索条件によく使う列だけ詳細度を上げるといった調整もできる。

統計が古いと見積りが外れる

ANALYZE は自動実行にも任せられる。autovacuum はテーブルの変更行数が autovacuum_analyze_threshold(デフォルト 50 行)+ autovacuum_analyze_scale_factor(デフォルト 0.1 = テーブル行数の 10%)を超えるたびに、自動で ANALYZE を走らせて統計を更新する。ここで注意したいのは、大量の INSERT を 1 つのトランザクション内で行い、コミット前に同じトランザクションでクエリを投げても、autovacuum はコミット後の状態しか見に行かないため、直近で増えた分の統計は反映されないという点だ。バッチ投入直後に急にクエリが遅くなったら、まず統計の鮮度を疑ってよい。

統計が古いかどうかは EXPLAIN (ANALYZE, BUFFERS) で確認できる。この実行モードは実際にクエリを実行しながら、プランナーの見積り行数(rows=)と実測の行数を並べて表示するため、両者が桁違いに乖離していれば統計が実態を反映していない強いサインになる。乖離が確認できたら ANALYZE テーブル名; を手動で実行し、統計を作り直してから計画を見直すのが定石だ。

💼 実務でどう出会うか

ステージング環境で速かったクエリが本番で急に遅くなる典型パターンの一つが、この統計のズレだ。テストデータは件数が少なく分布も偏っていないため Index Scan が選ばれるが、本番データは特定の値(例えば status = 'pending')に極端に偏っていて選択率の前提が崩れ、プランナーが Seq Scan を選んで急に遅くなる、といった形で表面化する。RDS や Cloud SQL のようなマネージド DB でも autovacuum の挙動自体は変わらないため、大量移行・大量削除の直後は手動 ANALYZE を運用手順に組み込んでおくと事故を防ぎやすい。

⌨️ 手を動かす(5 分)

同じテーブル・同じインデックスに対して、条件の選択率だけを変えてプランナーの判断が切り替わる様子を確認する。

docker run -d --name pgdemo -e POSTGRES_PASSWORD=postgres -p 5544:5432 postgres:16 >/dev/null
until docker exec pgdemo pg_isready -U postgres >/dev/null 2>&1; do sleep 1; done

docker exec -i pgdemo psql -U postgres <<'EOF'
CREATE TABLE t (id serial PRIMARY KEY, k int);
INSERT INTO t (k) SELECT (random() * 100000)::int FROM generate_series(1, 500000);
CREATE INDEX t_k_idx ON t (k);
ANALYZE t;
EXPLAIN SELECT * FROM t WHERE k < 100;     -- 選択率 低 → Index Scan のはず
EXPLAIN SELECT * FROM t WHERE k < 50000;   -- 選択率 高 → Seq Scan のはず
EOF

docker rm -f pgdemo >/dev/null

1 本目の EXPLAINIndex Scan using t_k_idx を、2 本目は Seq Scan on t を選ぶはずだ。WHERE の閾値を 100 から 50000 まで少しずつ動かすと、どこかで採用プランが切り替わる境目(選択率の損益分岐点)を自分の環境で観察できる。

🙅 よくある誤解
  • インデックスを貼れば必ず速くなる — 条件に一致する行の割合(選択率)が高いクエリでは、プランナーが意図的に Seq Scan を選ぶ方が速いことがある。使われないインデックスは無駄ではなく、正しい判断の結果であることが多い。
  • EXPLAINcost はミリ秒を表しているcostseq_page_cost などの設定値から作った相対的な見積りにすぎず、実行時間の単位ではない。実測時間を見たいなら EXPLAIN ANALYZEactual time を見る必要がある。
  • 一度 ANALYZE すれば統計はずっと正しい — データの分布は書き込みが進むにつれて変化する。autovacuum が変更量に応じて自動更新するが、大量投入の直後などは反映が追いつかず古い統計のまま見積もられることがある。
📖 用語ミニ辞典
EXPLAIN
クエリを実行せず(または ANALYZE オプション付きで実行しながら)、採用された実行計画と見積りコストを表示する PostgreSQL のコマンド。
コスト(cost)
プランナーが候補プランを比較するために計算する相対的な見積り値。実行時間の単位ではない。
選択率(selectivity)
ある条件に一致する行が、テーブル全体に対して占める割合。Seq ScanIndex Scan のどちらが安いかを左右する主要因。
pg_stats(統計情報)
ANALYZE が集めた列ごとの分布情報を人が読みやすい形で公開するシステムビュー。最頻値・ヒストグラム・相関などを含む。
ANALYZE / autovacuum
テーブルをサンプリングして統計情報を更新するコマンドと、その自動実行を担うバックグラウンドプロセス。
🔗 もっと深く