公開日 2026-05-14

EXPLAIN ANALYZEの結果から改善策を選ぶ(Sort / Join / Aggregate ごとの打ち手)

EXPLAIN ANALYZE の Sort / Join / Aggregate を見て、まずどこを疑い、どの改善策から当たるかを SQL と実行計画の例で整理する。

目次

  1. 前提環境
  2. 1. 問題設定
  3. 2. 改善策は「重いノードの 1 つ手前」から選ぶ
  4. 3. Sort から打ち手を選ぶ
  5. 4. Join から打ち手を選ぶ
  6. 5. Aggregate から打ち手を選ぶ
  7. 6. 最初の打ち手を 1 枚で整理する
  8. 7. まとめ

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 JoinBitmap 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 timeSQL 全体の時間をどの 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 つです。

  1. outer 側は十分に絞れているか
  2. inner 側は join 条件と filter 条件の両方で絞れているか
  3. 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 LoopHash 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 の名前ではなく、手前の SortIndex 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 の前に行数削減できないか
DISTINCTGROUP BY が join 後に大きく膨らむjoin で行を増やしすぎていないか、先に集約できないか

Aggregate を見て「集計が重い」とまとめると、改善の焦点がぼやけます。実際には、集計の前にどれだけの行を持ち込んでいるかが本題になりやすいからです。

6. 最初の打ち手を 1 枚で整理する

最後に、node ごとの最初の一手を表にまとめます。

node最初に見る場所最初に試す打ち手
Sortsort 入力の rows と手前の scanORDER BY に合うインデックス、sort 前の行数削減
Joinouter / inner の rows、inner 側の loopsjoin 前の絞り込み、join キーと filter をまとめた複合インデックス
Aggregateaggregate 手前の rows と sort の有無集計前に filter、join の前後順見直し、先に小さく集約

補足として、設定変更や planner の挙動確認はこのあとです。最初の段階では、Sort / Join / Aggregate の node 名を変えることを目標にせず、そこへ届く行数と反復回数を減らすことを優先したほうが、原因と対策がつながりやすくなります。

7. まとめ

EXPLAIN ANALYZE の改善策は、node 名から逆引きするより、「何行が届いたか」「何回繰り返したか」から選ぶほうが外しにくくなります。Sort なら sort 前の行数と順序、Join なら outer / inner の大きさと loopsAggregate なら集計前の入力行数。まずここを見る流れを固定しておけば、インデックス追加、SQL の切り方、filter の位置のどれを先に試すべきかが見えやすくなります。

前回の 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む) で「どこが重いか」を読めるようになったら、次は「その重さに対して何を疑うか」を整える段階です。

シリーズ 7/7

このシリーズ

PostgreSQL基礎

  1. 1. PostgreSQLでER図からテーブル設計する(Mermaid + CREATE TABLE最小構成)
  2. 2. PostgreSQLの制約入門(PRIMARY KEY / UNIQUE / CHECK / FOREIGN KEY)
  3. 3. PostgreSQLの外部キーと削除ルールを整理する(CASCADE / RESTRICT / SET NULL)
  4. 4. PostgreSQLのトランザクション入門(BEGIN / COMMIT / ROLLBACK と同時更新)
  5. 5. PostgreSQLのインデックス入門(B-tree と EXPLAIN ANALYZE の最小例)
  6. 6. 遅いSQLをどう見るか(EXPLAIN ANALYZEでボトルネックを読む)
  7. 7. EXPLAIN ANALYZEの結果から改善策を選ぶ(Sort / Join / Aggregate ごとの打ち手) 現在の記事