※この記事にはアフィリエイト広告(プロモーション)が含まれます。
SQL が遅いとき、PostgreSQL がどう実行するつもりかは EXPLAIN で、実際にどう実行したかは EXPLAIN ANALYZE で見ます。この記事の出力は PostgreSQL 18.6(Ubuntu 26.04)で、30万行の orders 表を使って採りました。
まず見る(結論)
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
Seq Scanは表を全部読んでいる。Rows Removed by Filterが大きければ、索引で絞れていないrows=が2つ出る。左の(cost=... rows=30)が推定、右の(actual ... rows=30.00)が実際。桁がずれていたら統計が古いEXPLAIN ANALYZEは SQL を本当に実行する。UPDATE/DELETEはBEGIN〜ROLLBACKで囲む
EXPLAIN と EXPLAIN ANALYZE
EXPLAIN は計画だけを表示し、SQL は実行しません。
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
QUERY PLAN
--------------------------------------------------------------------------
Gather (cost=1000.00..5414.88 rows=30 width=30)
Workers Planned: 1
-> Parallel Seq Scan on orders (cost=0.00..4411.88 rows=18 width=30)
Filter: (customer_id = 42)
EXPLAIN ANALYZE は実際に実行し、かかった時間と行数を加えて表示します。
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;
QUERY PLAN
------------------------------------------------------------------------------------------------------------------------
Gather (cost=1000.00..5414.88 rows=30 width=30) (actual time=0.464..9.080 rows=30.00 loops=1)
Workers Planned: 1
Workers Launched: 1
Buffers: shared hit=2206
-> Parallel Seq Scan on orders (cost=0.00..4411.88 rows=18 width=30) (actual time=0.147..5.651 rows=15.00 loops=2)
Filter: (customer_id = 42)
Rows Removed by Filter: 149985
Buffers: shared hit=2206
Planning Time: 0.433 ms
Execution Time: 9.121 ms
| 項目 | 意味 |
|---|---|
cost=0.00..4411.88 |
推定コスト(最初の1行まで..全部まで)。単位は時間ではない |
rows=18(cost 側) |
推定行数 |
actual time=0.147..5.651 |
実際の時間(ミリ秒) |
rows=15.00 loops=2 |
実際の行数。1回あたりの値なので、合計は rows × loops(ここでは 30 行) |
Rows Removed by Filter |
読んだが条件に合わず捨てた行数 |
Buffers: shared hit |
読んだページ数(hit はメモリ上にあった分、read はディスクから読んだ分) |
Execution Time |
実行にかかった時間の合計 |
30行を返すために約30万行を読んで捨てています。Gather と Parallel Seq Scan は並列実行で、本体とワーカー1つで分けて読んでいます。
PostgreSQL 18 では EXPLAIN ANALYZE と書くだけで Buffers 行が出ます。消すには EXPLAIN (ANALYZE, BUFFERS OFF) と書きます。時間やコストを省いて計画の形だけを見たいときは EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF) も使えます。
読み取り方の種類
customer_id に索引を作ってから同じ SQL を流しました。
Bitmap Heap Scan on orders (cost=4.53..114.41 rows=30 width=30) (actual time=0.034..0.173 rows=30.00 loops=1)
Recheck Cond: (customer_id = 42)
Heap Blocks: exact=30
-> Bitmap Index Scan on idx_orders_customer (cost=0.00..4.52 rows=30 width=0) (actual time=0.022..0.022 rows=30.00 loops=1)
Index Cond: (customer_id = 42)
Execution Time: 0.216 ms
9.121 ms が 0.216 ms になりました。Index Cond は索引で絞った条件、Filter は読んでから捨てた条件です。
| 表示 | 読み方 | 出た場面(この検証) |
|---|---|---|
Seq Scan |
表を先頭から全部読む | 索引が無い列での絞り込み、表の9割が当たる条件 |
Index Scan |
索引をたどって該当行だけ読む | 一意な値の検索、時刻の範囲検索 |
Bitmap Heap Scan |
索引で該当ページを集めてからまとめて読む | 30行〜3万行が当たる条件 |
Index Scan Backward |
索引を逆順にたどる | ORDER BY ... DESC LIMIT |
索引があっても、大半の行を返す条件では全部読むほうが速いと判断されて Seq Scan になります。
結合・集計・並べ替え
EXPLAIN ANALYZE SELECT c.name, o.amount FROM orders o JOIN customers c ON c.id = o.customer_id
WHERE c.name = 'cust42';
Gather (cost=1208.01..5645.16 rows=30 width=14) (actual time=1.434..16.664 rows=30.00 loops=1)
-> Hash Join (cost=208.01..4642.16 rows=18 width=14) (actual time=0.788..12.677 rows=15.00 loops=2)
Hash Cond: (o.customer_id = c.id)
-> Parallel Seq Scan on orders o (...) (actual time=0.005..6.207 rows=150000.00 loops=2)
-> Hash (...) (actual time=0.394..0.395 rows=1.00 loops=2)
-> Seq Scan on customers c (...) (actual time=0.011..0.389 rows=1.00 loops=2)
Filter: (name = 'cust42'::text)
Execution Time: 16.716 ms
字下げが深い行から先に実行されます。customers から1行を探してハッシュ表にし、orders を全部読みながら突き合わせています。時間がかかっているのは orders の全件読みです。
推定行数と実際の行数がずれる
自動 ANALYZE を止めた表に、status = 'hold' の行を5万件追加し、その直後に流しました。
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'hold';
Gather (cost=1000.00..6147.96 rows=1 width=30) (actual time=8.172..13.429 rows=50000.00 loops=1)
-> Parallel Seq Scan on orders (cost=0.00..5147.86 rows=1 width=30) (actual time=6.626..8.352 rows=25000.00 loops=2)
推定 1 行に対して実際は 50000 行です。統計にまだ hold が無いためで、pg_stat_user_tables の n_mod_since_analyze(前回の ANALYZE 以降に変わった行数)は 50002 でした。
ANALYZE orders;
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'hold';
Seq Scan on orders (cost=0.00..6949.00 rows=50365 width=30) (actual time=12.714..15.497 rows=50000.00 loops=1)
推定がずれると、結合の方法や索引を使うかどうかの判断を誤ります。大量に投入・削除した直後に遅くなったら、先に ANALYZE を流して計画が変わるかを見ます。
EXPLAIN ANALYZE は更新を実行する
SELECT amount FROM orders WHERE id = 1; -- 1.50
EXPLAIN ANALYZE UPDATE orders SET amount = amount + 100 WHERE id = 1;
SELECT amount FROM orders WHERE id = 1; -- 101.50
更新文の実際の動きを見るときはトランザクションで囲みます。
BEGIN;
EXPLAIN ANALYZE UPDATE orders SET amount = amount + 100 WHERE id = 1;
ROLLBACK;
この形では ROLLBACK の後も 101.50 のままでした。
JSON で出す
EXPLAIN (FORMAT JSON) は "Node Type": "Seq Scan"、"Plan Rows": 18 のような形で出ます。可視化ツールに貼るときや、プログラムで読むときに使います。
関連: PostgreSQLのインデックス作成(CREATE INDEX) / PostgreSQLのVACUUM / MySQLのEXPLAINの見方