EXPLAIN ANALYZE を読んで重いノードが見つかっても、次に何を直すかは別の難しさがあります。Sort を見てすぐインデックス追加に走る、Nested Loop を見て node 名だけで悪いと決める、Aggregate を見て集計関数ばかり見直す。こうした迷い方は珍しくないものです。この記事の焦点は、前回の 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む) と同じ customers / orders デモを使い、Sort / Join / Aggregate ごとに「まず何を疑うか」を整理するところにあります。
前提環境
- Windows 11
- WSL2(Ubuntu)
- VS Code(Remote - WSL)
- Docker Desktop
- PostgreSQL 17
psql
前回記事のデモをそのまま使います。まだ作っていない場合は、先に 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む) のセットアップまで進めてください。
作業ディレクトリへ移動し、psql を起動します。
cd ~/projects/postgresql-slow-query-explain-demo
docker compose exec -it db psql -U app -d app
以降の SQL はこのセッション内で実行します。
1. 問題設定
到達する状態:
Sort/Join/Aggregateが重いとき、最初に見る場所を固定できる- ノード名だけで善し悪しを決めず、入力行数や反復回数から改善策を選べる
- インデックス追加、行数削減、SQL の切り方のどれを先に疑うか判断できる
扱わない内容:
EXPLAINの全ノード一覧work_memや統計情報の詳細チューニングpg_stat_statementsを使った本番監視Merge JoinやBitmap Heap Scanの個別辞典
焦点は「設定項目を網羅すること」ではありません。重いノードを見つけたあとの「次の一手」をどう絞るか、その判断順です。
2. 改善策は「重いノードの 1 つ手前」から選ぶ
EXPLAIN ANALYZE の結果から改善策を選ぶときは、重いノードそのものより、その 1 つ手前で何行流れてきているかを先に見たほうが整理しやすくなります。
flowchart TD
A[Execution Time を確認] --> B[重い node を特定]
B --> C{どの種類か}
C -->|Sort| D[何行を並べているか]
C -->|Join| E[どちらが何回繰り返されるか]
C -->|Aggregate| F[集計前に何行届いているか]
D --> G[ORDER BY に合う index / 行数削減]
E --> H[outer 側削減 / join 条件に合う index]
F --> I[filter を前へ / 集計前に絞る]
前回記事で確認した rows / actual time / loops を、今回は次のように使います。
| 観点 | まず見る意味 |
|---|---|
rows | 重い node に何行届いているかを把握する |
actual time | SQL 全体の時間をどの node が持っていったかを見る |
loops | 同じ処理が何回繰り返されたかを把握する |
3. Sort から打ち手を選ぶ
ORDER BY を含む SQL です。LIMIT 50 が付いていても、Sort の前段に大量の行が届いていれば、sort 自体は重くなります。
次の SQL を実行します。
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 50;
実行計画の例は次のとおりです。
Limit (cost=10296.86..10296.98 rows=50 width=28) (actual time=37.829..37.835 rows=50 loops=1)
Buffers: shared hit=1656
-> Sort (cost=10296.86..10796.86 rows=200000 width=28) (actual time=37.827..37.830 rows=50 loops=1)
Sort Key: created_at DESC
Sort Method: top-N heapsort Memory: 32kB
Buffers: shared hit=1656
-> Seq Scan on orders (cost=0.00..3653.00 rows=200000 width=28) (actual time=0.006..12.576 rows=200000 loops=1)
Planning Time: 0.315 ms
Execution Time: 37.859 ms
ここで見る順番は次の 3 点です。
Execution Timeが約 37.9 ms で、全体の時間の多くをSortが持っているSortの入力がrows=200000で、全行を並べているLimitは 50 行しか返していないが、その前に sort が終わっている必要がある
この結果から先に疑うべきなのは work_mem ではありません。「並び順に合う access path がない」「sort 前に行数が減っていない」の 2 点です。今回は created_at に合うインデックスがなく、全行を読み切ってから並べる形です。
ORDER BY に合わせたインデックスを追加します。
CREATE INDEX idx_orders_created_at_desc ON orders (created_at DESC);
ANALYZE orders;
実行計画は次のとおりです。
Limit (cost=0.42..2.14 rows=50 width=28) (actual time=0.065..0.072 rows=50 loops=1)
-> Index Scan using idx_orders_created_at_desc on orders (cost=0.42..6860.42 rows=200000 width=28) (actual time=0.064..0.069 rows=50 loops=1)
Planning Time: 0.091 ms
Execution Time: 0.081 ms
Sort node 自体が消え、必要な順番で先頭 50 行だけ拾えるようになりました。ここで重要なのは、「sort が重いから sort をいじった」のではなく、「sort に大量の行を渡さない access path を作った」という見方です。
Sort が重いときの最初の問いは次の表にまとめられます。
| まず見ること | そうだったら何を疑うか |
|---|---|
sort 入力の rows が大きい | filter を前に出せないか、ORDER BY に合うインデックスを作れないか |
LIMIT があるのに Sort が残る | 上位 N 件をそのまま取れる access path がない |
sort の手前が Seq Scan | 並び順の前に全表を読んでいないか |
設定調整はそのあとです。先に「何行を並べているか」を見ないまま work_mem から入ると、根本原因を外しやすくなります。
4. Join から打ち手を選ぶ
join で大切なのは、node 名よりも「どちらの入力がどれだけ大きいか」「内側の処理が何回繰り返されるか」です。まず、vip 顧客と new 注文を組み合わせる query を見ます。
CREATE INDEX idx_orders_customer_id_status ON orders (customer_id, status);
ANALYZE orders;
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.id, c.name, o.id
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE c.segment = 'vip'
AND o.status = 'new';
実行計画の例は次のとおりです。
Nested Loop (cost=0.29..4254.91 rows=996 width=31) (actual time=0.011..5.798 rows=10000 loops=1)
-> Seq Scan on customers c (cost=0.00..199.00 rows=500 width=23) (actual time=0.007..0.628 rows=500 loops=1)
Filter: (segment = 'vip'::text)
Rows Removed by Filter: 9500
-> Index Scan using idx_orders_customer_id_status on orders o (cost=0.29..8.09 rows=2 width=16) (actual time=0.004..0.009 rows=20 loops=500)
Index Cond: ((customer_id = c.id) AND (status = 'new'::text))
Planning Time: 0.404 ms
Execution Time: 6.072 ms
この plan の読みどころは loops=500 です。vip 顧客 500 行を outer 側にし、そのたびに inner 側の index probe が 500 回動いています。ただし、1 回ごとの probe は actual time=0.004..0.009 と軽く、全体でも約 6 ms。つまりノード名だけで良し悪しは判断できません。
join で先に見るべき問いは次の 3 つです。
- outer 側は十分に絞れているか
- inner 側は join 条件と filter 条件の両方で絞れているか
loopsに対して、1 回あたりの inner probe が重すぎないか
今回の query では、idx_orders_customer_id_status という orders (customer_id, status) の複合インデックスが inner 側の probe を小さく保っています。もし join キーだけのインデックスしかなく、filter を別で落としているなら、1 回ごとの probe は太くなりがちです。
同じ join でも planner が別の shape を選ぶことがある。status 側から 2 万行をまとめて拾う access path が有利な場合、次のように Hash Join が選ばれます。比較用の前提として、ここでは先に次のインデックスを追加します。
CREATE INDEX IF NOT EXISTS idx_orders_status_created_at ON orders (status, created_at);
ANALYZE orders;
Hash Join (cost=667.43..2620.53 rows=992 width=31) (actual time=3.145..7.284 rows=10000 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Bitmap Heap Scan on orders o (cost=462.18..2363.18 rows=19840 width=16) (actual time=2.374..4.767 rows=20000 loops=1)
Recheck Cond: (status = 'new'::text)
-> Bitmap Index Scan on idx_orders_status_created_at (cost=0.00..457.22 rows=19840 width=0) (actual time=2.208..2.209 rows=20000 loops=1)
Index Cond: (status = 'new'::text)
-> Hash (cost=199.00..199.00 rows=500 width=23) (actual time=0.763..0.764 rows=500 loops=1)
-> Seq Scan on customers c (cost=0.00..199.00 rows=500 width=23) (actual time=0.012..0.715 rows=500 loops=1)
Execution Time: 7.571 ms
この比較から分かるのは、目標が Nested Loop か Hash Join かではないということです。目標は join に渡る入力行数と反復回数を小さくすることにあります。node 名はその結果にすぎない。
join が重いときの最初の打ち手は、次の表で切り分けると整理しやすくなります。
| まず見ること | そうだったら何を疑うか |
|---|---|
outer 側の rows が大きい | join 前に filter できないか、先に小さくできる集合はどちらか |
inner 側の loops が多い | join キーと filter 条件を同時に使える複合インデックスがあるか |
見積もり rows と実測 rows が大きくずれる | selectivity の見積もりや統計情報が外れていないか |
Join を見た瞬間に SQL 全体を書き換えるより、「どちらが大きいか」と「何回 probe しているか」を押さえるほうが、次の一手を選びやすくなります。
5. Aggregate から打ち手を選ぶ
aggregate では、集計 node 自体よりも「集計前に何行届いているか」を見るのが先です。status = 'paid' の日別件数を集計する query を例にします。
CREATE INDEX IF NOT EXISTS idx_orders_status_created_at ON orders (status, created_at);
ANALYZE orders;
SET max_parallel_workers_per_gather = 0;
EXPLAIN (ANALYZE, BUFFERS)
SELECT date_trunc('day', created_at) AS order_day, COUNT(*)
FROM orders
WHERE status = 'paid'
GROUP BY order_day
ORDER BY order_day;
実行計画の例は次のとおりです。
GroupAggregate (cost=14342.55..16736.55 rows=119700 width=16) (actual time=29.939..34.930 rows=3 loops=1)
Group Key: (date_trunc('day'::text, created_at))
Buffers: shared hit=7 read=460
-> Sort (cost=14342.55..14641.80 rows=119700 width=8) (actual time=25.966..29.666 rows=120000 loops=1)
Sort Key: (date_trunc('day'::text, created_at))
-> Index Only Scan using idx_orders_status_created_at on orders (cost=0.42..4246.42 rows=119700 width=8) (actual time=0.031..21.041 rows=120000 loops=1)
Execution Time: 35.148 ms
ここで先に見るべきなのは GroupAggregate の名前ではなく、手前の Sort と Index Only Scan が 12 万行を運んでいることです。集計関数を COUNT(*) から別の書き方へ変えても、この入力行数が大きいままなら改善は限られます。
同じ query に期間条件を足し、集計前の行数を減らします。
EXPLAIN (ANALYZE, BUFFERS)
SELECT date_trunc('day', created_at) AS order_day, COUNT(*)
FROM orders
WHERE status = 'paid'
AND created_at >= TIMESTAMPTZ '2026-01-03 00:00:00+00'
GROUP BY order_day
ORDER BY order_day;
実行計画は次のとおりです。
GroupAggregate (cost=1743.13..2065.83 rows=16135 width=16) (actual time=4.316..4.317 rows=1 loops=1)
Group Key: (date_trunc('day'::text, created_at))
Buffers: shared hit=66 read=1
-> Sort (cost=1743.13..1783.46 rows=16135 width=8) (actual time=3.103..3.618 rows=16320 loops=1)
Sort Key: (date_trunc('day'::text, created_at))
-> Index Only Scan using idx_orders_status_created_at on orders (cost=0.42..615.46 rows=16135 width=8) (actual time=0.030..2.465 rows=16320 loops=1)
Execution Time: 4.341 ms
12 万行だった入力が 16320 行まで減り、全体時間も約 35.1 ms から約 4.3 ms まで下がりました。改善の主因は aggregate node の変更ではなく、集計前の入力行数を減らしたことです。
aggregate が重いときに最初に疑うことは次のとおりです。
| まず見ること | そうだったら何を疑うか |
|---|---|
aggregate 手前の rows が多い | filter を前に出せないか、期間や状態で先に絞れないか |
Sort が手前にいる | GROUP BY / ORDER BY の前に行数削減できないか |
DISTINCT や GROUP BY が join 後に大きく膨らむ | join で行を増やしすぎていないか、先に集約できないか |
Aggregate を見て「集計が重い」とまとめると、改善の焦点がぼやけます。実際には、集計の前にどれだけの行を持ち込んでいるかが本題になりやすいからです。
6. 最初の打ち手を 1 枚で整理する
最後に、node ごとの最初の一手を表にまとめます。
| node | 最初に見る場所 | 最初に試す打ち手 |
|---|---|---|
Sort | sort 入力の rows と手前の scan | ORDER BY に合うインデックス、sort 前の行数削減 |
Join | outer / inner の rows、inner 側の loops | join 前の絞り込み、join キーと filter をまとめた複合インデックス |
Aggregate | aggregate 手前の rows と sort の有無 | 集計前に filter、join の前後順見直し、先に小さく集約 |
補足として、設定変更や planner の挙動確認はこのあとです。最初の段階では、Sort / Join / Aggregate の node 名を変えることを目標にせず、そこへ届く行数と反復回数を減らすことを優先したほうが、原因と対策がつながりやすくなります。
7. まとめ
EXPLAIN ANALYZE の改善策は、node 名から逆引きするより、「何行が届いたか」「何回繰り返したか」から選ぶほうが外しにくくなります。Sort なら sort 前の行数と順序、Join なら outer / inner の大きさと loops、Aggregate なら集計前の入力行数。まずここを見る流れを固定しておけば、インデックス追加、SQL の切り方、filter の位置のどれを先に試すべきかが見えやすくなります。
前回の 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む) で「どこが重いか」を読めるようになったら、次は「その重さに対して何を疑うか」を整える段階です。