遅いノードの見つけ方(4 つのサイン)
実行計画からボトルネックを特定する手順は、(1) 各ノードの自分時間を引き算で出して降順に並べ、(2) loops が 1 より大きいノードは掛け算で総量に戻し、(3) 実測が見積りの 10 倍以上のノードを根本原因の候補にし、(4) Rows Removed by Filter で無駄読みを見る、の 4 つである。
手順にする理由
実行計画を眺めて Seq Scan を探す、という読み方だと当たりません。並んでいる数字が、そのままでは互いに比べられない形になっているからです。 理由は 2 つあります。
- 表示は累計。そのノードの
actual timeには子の時間が全部入っています。 だから大きい数字を探すと、必ずいちばん上のノードに行き着きます loopsが付いていると 1 回あたりの平均。掛け算を戻すまで、他のノードと同じ土俵に乗りません
この計画では、その 2 つがまとめて効いています。表示がいちばん大きいノード(Limit の 2160.379 ミリ秒)は、自分では 0.1 ミリ秒 しか使っていません。逆に表示がいちばん小さいノードが、全体の 58% を持っています。だからここでは、眺めるのをやめて順番の決まった手順にします。
題材はこのセクションの最初のページに出したのと同じ計画です。まずクエリ本体を出しておきます。何をしているか分からないものの内部を読んでも身に付かないためです。
SELECT c.name, count(*), sum(i.price * i.qty) AS total
FROM customers c
JOIN orders o ON o.customer_code = c.code
JOIN order_items i ON i.order_id = o.id
WHERE o.status = 'shipped'
AND o.ordered_at >= '2026-07-01'
AND o.channel = 'web'
AND o.payment = 'card'
AND i.qty = 3
GROUP BY c.name
ORDER BY total DESC
LIMIT 10;やっていることは「7 月以降の web / カード決済で発送済みの注文から、数量 3 の明細を集めて、顧客ごとの売上上位 10 件」。SQL としては素直な部類。
顧客 2 万件(customers)・注文 200 万件(orders)・明細 1200 万件(order_items)に対して実行して、2.16 秒かかっています。この計画です。
Limit (cost=196523.68..196523.70 rows=10 width=30) (actual time=2160.299..2160.379 rows=10.00 loops=1)
-> Sort (cost=196523.68..196526.29 rows=1046 width=30) (actual time=2160.293..2160.320 rows=10.00 loops=1)
Sort Key: (sum((i.price * i.qty))) DESC
Sort Method: top-N heapsort Memory: 26kB
-> GroupAggregate (cost=196477.54..196501.07 rows=1046 width=30) (actual time=2090.929..2158.921 rows=20000.00 loops=1)
Group Key: c.name
-> Sort (cost=196477.54..196480.15 rows=1046 width=22) (actual time=2090.883..2131.767 rows=500000.00 loops=1)
Sort Key: c.name
Sort Method: external merge Disk: 16656kB
-> Nested Loop (cost=597.43..196425.08 rows=1046 width=22) (actual time=3.118..1561.364 rows=500000.00 loops=1)
-> Hash Join (cost=597.00..57141.20 rows=523 width=18) (actual time=3.087..177.650 rows=250000.00 loops=1)
Hash Cond: (o.customer_code = c.code)
-> Seq Scan on orders o (cost=0.00..56537.00 rows=526 width=11) (actual time=0.129..127.002 rows=250000.00 loops=1)
Filter: ((ordered_at >= '2026-07-01'::date) AND (status = 'shipped'::text) AND (channel = 'web'::text) AND (payment = 'card'::text))
Rows Removed by Filter: 1750000
-> Hash (cost=347.00..347.00 rows=20000 width=21) (actual time=2.844..2.847 rows=20000.00 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1309kB
-> Seq Scan on customers c (cost=0.00..347.00 rows=20000 width=21) (actual time=0.053..1.144 rows=20000.00 loops=1)
-> Index Scan using order_items_order_id_idx on order_items i (cost=0.43..266.09 rows=23 width=12) (actual time=0.005..0.005 rows=2.00 loops=250000)
Index Cond: (order_id = o.id)
Filter: (qty = 3)
Rows Removed by Filter: 4
Index Searches: 250000
Planning Time: 3.419 ms
Execution Time: 2162.557 ms手 0: そのまま。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
サイン 1 — 時間を持っているノードを出す
各ノードについて、次を計算します。
actual timeの上端にloopsを掛ける (これがそのノード以下の合計時間)- そこから子のぶんを同じやり方で計算して引く
- 残りがそのノードの自分の時間
まず 3 ノードで手を動かす
いきなり 10 ノードでやると大変なので、3 ノードしかない計画で 1 回やってみます。 こちらは loops が全部 1 なので、引き算だけで済みます。
Nested Loop (cost=0.85..28.26 rows=4 width=17) (actual time=0.041..0.051 rows=4.00 loops=1)
-> Index Scan using members_pkey on members m (cost=0.42..8.44 rows=1 width=17) (actual time=0.022..0.022 rows=1.00 loops=1)
Index Cond: (id = 42)
Index Searches: 1
-> Index Scan using orders_s_member_idx on orders_s o (cost=0.43..19.78 rows=4 width=8) (actual time=0.017..0.024 rows=4.00 loops=1)
Index Cond: (member_id = 42)
Index Searches: 1
Planning Time: 0.311 ms
Execution Time: 0.099 ms3 ノードの小さな計画。loops はすべて 1。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
上から順に、actual time の上端の数字だけ拾います。 いちばん上の数字には子 2 つの時間が全部入っているので、引きます。
Nested Loop 0.051 ← いちばん上
Index Scan using members_pkey on members m 0.022
Index Scan using orders_s_member_idx on orders_s o 0.024Nested Loop の自分の時間 = 0.051 - (0.022 + 0.024) = 0.005いちばん上のノードが、実は自分ではほとんど何もしていません。子は葉なので、引くものがなく、表示がそのまま自分の時間になります。 結果はこうです。
| ノード | 自分の時間 | loops |
|---|---|---|
| Index Scan using orders_s_member_idx on orders_s o | 0.0ms(24.2%) | 1 |
| Index Scan using members_pkey on members m | 0.0ms(22.2%) | 1 |
| Nested Loop | 0.0ms(5.1%) | 1 |
これが手順の全部です。あとはノードが増えるだけ……ではありません。loops が 1 でないノードが混じった瞬間に、話が変わります。
同じことを本物の計画でやる
さっきの引き算の「同じやり方で」が肝です。子に loops が付いていたら、子も掛け算してから引きます。 ここを飛ばすと答えが変わります。実際に両方やって並べます。
| ノード | 自分の時間 |
|---|---|
| Nested Loop | 1383.7ms(64.0%) |
| Sort | 570.4ms(26.4%) |
| Seq Scan on orders o | 127.0ms(5.9%) |
| Hash Join | 47.8ms(2.2%) |
| GroupAggregate | 27.2ms(1.3%) |
| ⋮ | |
| Index Scan using order_items_order_id_idx on order_items i(10 / 10 位) | 0.0ms(0.0%) |
| ノード | 自分の時間 | loops |
|---|---|---|
| Index Scan using order_items_order_id_idx on order_items i | 1250.0ms(57.8%) | 250000 |
| Sort | 570.4ms(26.4%) | 1 |
| Nested Loop | 133.7ms(6.2%) | 1 |
| Seq Scan on orders o | 127.0ms(5.9%) | 1 |
| Hash Join | 47.8ms(2.2%) | 1 |
1 回の掛け算で、1 位と最下位が入れ替わりました。
- 掛けないと
Nested Loopが 1 位に見える。表示上いちばん大きい数字(1561.364)を持っているのがこのノードだからです - 掛けると
Nested Loopは 134ms まで落ち、内側のIndex Scanが 1250ms・全体の 58% で 1 位になります
内側の表示は actual time=0.005..0.005 です。1 回あたり 0.005 ミリ秒で終わっているので、素朴に読むといちばん軽く見えます。 ところが loops=250000 なので、0.005 × 250000 = 1.25 秒。これが 2.16 秒の内訳の大半です。
サイン 2 — loops を掛ける
サイン 1 と 2 は独立していません。サイン 1 の引き算にサイン 2 が要るので、 この順で並べています。
loops が 1 より大きいノードでは、actual time も rows も1 回あたりの平均です。 詳しくはEXPLAIN ANALYZE の見方にあります。
引き算は「上限」も教えてくれる。actual time はミリ秒 3 桁で丸められるので、loops が大きいノードは掛けると上振れすることがあります。 そのときは親のサブツリー時間から、外側のぶんを引いた値が内側の上限です。 外側は loops=1 で丸めが増幅されないので、そちらは正確に読めます。
サイン 3 — 見積りと実測がずれているノードを探す
rows= は 2 か所に出ます。前半(cost=… の側)が見積り、後半(actual … の側)が実測です。 この計画では、外側の Seq Scan on orders がこうなっています。
- 見積り
rows=526に対して、実測rows=250000。475 倍の外れ
これが Nested Loop が選ばれた理由です。外側が 526 行だと思っているので、「内側を 526 回まわす」計画が最安に見えます。 実際は 250,000 回まわります。loops=250000 の出どころはここです。
見るのは「見積りが小さすぎる」側だけです。多めに見積もっているノードは候補に入れません。 Nested Loop の暴発は必ず過小見積りで起きる(少なく見積もるから 何度もまわす計画が安く見える)ためで、多めに見積もったときは プランナが安全側の計画を選ぶので事故になりにくいからです。
この計画にちょうど反例があります。内側の Index Scan はrows=23(見積り)に対して rows=2.00(実測)で11.5 倍ずれていますが、ずれの向きが逆なので候補には入りません。 「10 倍以上ずれたら候補」とだけ覚えると、真犯人ではないノードが混じります。
サイン 4 — 読んで捨てている行を見る
Rows Removed by Filter は、読んだあとに条件に合わなくて捨てた行数です。この計画では 2 か所に出ます。
- 外側の
Seq Scan on orders: 1,750,000 行を捨てている。 200 万行読んで 25 万行しか使っていません - 内側の
Index Scan: ループ 1 回ごとに 4 行捨てている。 6 行読んで 2 行だけ使っています
条件が Index Cond 側にあれば読む前に効き、Filter 側にあると読んでから捨てます。違いはIndex Cond と Filter の違いに。
直して、順位表を作り直す
犯人が分かったので直します。ここから先は全部実測です。
手 1: 内側にテーブル本体を読ませない
内側が遅いのは、インデックスを引いたあとテーブル本体を読みに行っているからです。 必要な列をインデックスに含めてしまえば、テーブルを読まずに済みます。
CREATE INDEX order_items_covering
ON order_items (order_id) INCLUDE (price, qty); Limit (cost=60395.21..60395.23 rows=10 width=30) (actual time=934.953..934.974 rows=10.00 loops=1)
-> Sort (cost=60395.21..60397.87 rows=1064 width=30) (actual time=934.949..934.956 rows=10.00 loops=1)
Sort Key: (sum((i.price * i.qty))) DESC
Sort Method: top-N heapsort Memory: 26kB
-> GroupAggregate (cost=60348.28..60372.22 rows=1064 width=30) (actual time=878.864..933.942 rows=20000.00 loops=1)
Group Key: c.name
-> Sort (cost=60348.28..60350.94 rows=1064 width=22) (actual time=878.847..910.373 rows=500000.00 loops=1)
Sort Key: c.name
Sort Method: external merge Disk: 16656kB
-> Nested Loop (cost=597.43..60294.78 rows=1064 width=22) (actual time=8.830..404.174 rows=500000.00 loops=1)
-> Hash Join (cost=597.00..57141.20 rows=523 width=18) (actual time=8.802..109.528 rows=250000.00 loops=1)
Hash Cond: (o.customer_code = c.code)
-> Seq Scan on orders o (cost=0.00..56537.00 rows=526 width=11) (actual time=0.086..69.011 rows=250000.00 loops=1)
Filter: ((ordered_at >= '2026-07-01'::date) AND (status = 'shipped'::text) AND (channel = 'web'::text) AND (payment = 'card'::text))
Rows Removed by Filter: 1750000
-> Hash (cost=347.00..347.00 rows=20000 width=21) (actual time=8.554..8.554 rows=20000.00 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1309kB
-> Seq Scan on customers c (cost=0.00..347.00 rows=20000 width=21) (actual time=0.048..1.306 rows=20000.00 loops=1)
-> Index Only Scan using order_items_covering on order_items i (cost=0.43..5.80 rows=23 width=12) (actual time=0.001..0.001 rows=2.00 loops=250000)
Index Cond: (order_id = o.id)
Filter: (qty = 3)
Rows Removed by Filter: 4
Heap Fetches: 0
Index Searches: 250000
Planning Time: 1.754 ms
Execution Time: 935.912 ms手 1: Index Only Scan に変わり、Heap Fetches: 0(テーブル本体を 1 回も触っていない)。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
2.16 秒 → 0.94 秒。内側は Index Only Scan になり、Heap Fetches: 0 が出ています(Heapはテーブル本体のこと。インデックスから本体を取りに行った回数が 0 という意味)。 カバリングインデックスの話はカバリングインデックスに。
| ノード | 自分の時間 | loops |
|---|---|---|
| Sort | 506.2ms(54.1%) | 1 |
| Index Only Scan using order_items_covering on order_items i | 250.0ms(26.7%) | 250000 |
| Seq Scan on orders o | 69.0ms(7.4%) | 1 |
| Nested Loop | 44.6ms(4.8%) | 1 |
1 位が入れ替わりました。内側を直したので、今度は Sort が 1 位です。Sort Method: external merge Disk: 16656kB と出ていて、並べ替えがメモリに収まらず一時ファイルに書いています。
手 2: ソートをメモリに載せる
SET work_mem = '128MB';
Limit (cost=60395.21..60395.23 rows=10 width=30) (actual time=834.246..834.271 rows=10.00 loops=1)
-> Sort (cost=60395.21..60397.87 rows=1064 width=30) (actual time=834.242..834.250 rows=10.00 loops=1)
Sort Key: (sum((i.price * i.qty))) DESC
Sort Method: top-N heapsort Memory: 26kB
-> GroupAggregate (cost=60348.28..60372.22 rows=1064 width=30) (actual time=795.088..832.844 rows=20000.00 loops=1)
Group Key: c.name
-> Sort (cost=60348.28..60350.94 rows=1064 width=22) (actual time=795.064..807.410 rows=500000.00 loops=1)
Sort Key: c.name
Sort Method: quicksort Memory: 31820kB
-> Nested Loop (cost=597.43..60294.78 rows=1064 width=22) (actual time=5.537..390.310 rows=500000.00 loops=1)
-> Hash Join (cost=597.00..57141.20 rows=523 width=18) (actual time=5.513..113.784 rows=250000.00 loops=1)
Hash Cond: (o.customer_code = c.code)
-> Seq Scan on orders o (cost=0.00..56537.00 rows=526 width=11) (actual time=0.202..74.525 rows=250000.00 loops=1)
Filter: ((ordered_at >= '2026-07-01'::date) AND (status = 'shipped'::text) AND (channel = 'web'::text) AND (payment = 'card'::text))
Rows Removed by Filter: 1750000
-> Hash (cost=347.00..347.00 rows=20000 width=21) (actual time=5.180..5.183 rows=20000.00 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1309kB
-> Seq Scan on customers c (cost=0.00..347.00 rows=20000 width=21) (actual time=0.098..2.167 rows=20000.00 loops=1)
-> Index Only Scan using order_items_covering on order_items i (cost=0.43..5.80 rows=23 width=12) (actual time=0.001..0.001 rows=2.00 loops=250000)
Index Cond: (order_id = o.id)
Filter: (qty = 3)
Rows Removed by Filter: 4
Heap Fetches: 0
Index Searches: 250000
Planning Time: 1.372 ms
Execution Time: 836.672 ms手 2: Sort Method が quicksort に変わり、一時ファイルが消えた。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
0.94 秒 → 0.84 秒。変わったのは Sort Method の 1 行だけです。external merge Disk: 16656kB がquicksort Memory: 31820kB になりました。 この行の読み方はソートとメモリに。
ここでもう一度、順位表を作り直します。
| ノード | 自分の時間 | loops |
|---|---|---|
| Sort | 417.1ms(49.9%) | 1 |
| Index Only Scan using order_items_covering on order_items i | 250.0ms(29.9%) | 250000 |
| Seq Scan on orders o | 74.5ms(8.9%) | 1 |
| Hash Join | 34.1ms(4.1%) | 1 |
潰したはずの Sort が、まだ 1 位のままです。一時ファイルへの書き出しは消えましたが、並べ替えそのものは残っているためです。「1 位を直せば 2 位が上がってくる」とは限りません。同じノードが 1 位に残ることもあるので、直したら必ず作り直して確かめます。
やってはいけない直し方
サイン 3 で「見積りが 475 倍外れている」と分かったので、統計を直せば速くなりそうに見えます。4 つの条件に相関があることを DB に教えてみます。
CREATE STATISTICS orders_corr (mcv, ndistinct)
ON status, ordered_at, channel, payment FROM orders;
ANALYZE orders; Limit (cost=675525.13..675525.16 rows=10 width=30) (actual time=3643.530..3643.561 rows=10.00 loops=1)
-> Sort (cost=675525.13..675575.13 rows=20000 width=30) (actual time=3643.527..3643.541 rows=10.00 loops=1)
Sort Key: (sum((i.price * i.qty))) DESC
Sort Method: top-N heapsort Memory: 26kB
-> HashAggregate (cost=674892.94..675092.94 rows=20000 width=30) (actual time=3640.755..3642.266 rows=20000.00 loops=1)
Group Key: c.name
-> Hash Join (cost=61405.16..669894.56 rows=499838 width=22) (actual time=177.490..3572.437 rows=500000.00 loops=1)
Hash Cond: (o.customer_code = c.code)
-> Hash Join (cost=60808.16..662424.78 rows=499838 width=15) (actual time=173.815..3496.492 rows=500000.00 loops=1)
Hash Cond: (i.order_id = o.id)
-> Seq Scan on order_items i (cost=0.00..550000.00 rows=4068800 width=12) (actual time=0.302..2510.303 rows=4000000.00 loops=1)
Filter: (qty = 3)
Rows Removed by Filter: 8000000
-> Hash (cost=56537.00..56537.00 rows=245693 width=11) (actual time=169.596..169.597 rows=250000.00 loops=1)
Buckets: 262144 Batches: 2 Memory Usage: 7431kB
-> Seq Scan on orders o (cost=0.00..56537.00 rows=245693 width=11) (actual time=0.325..131.356 rows=250000.00 loops=1)
Filter: ((ordered_at >= '2026-07-01'::date) AND (status = 'shipped'::text) AND (channel = 'web'::text) AND (payment = 'card'::text))
Rows Removed by Filter: 1750000
-> Hash (cost=347.00..347.00 rows=20000 width=21) (actual time=3.599..3.599 rows=20000.00 loops=1)
Buckets: 32768 Batches: 1 Memory Usage: 1309kB
-> Seq Scan on customers c (cost=0.00..347.00 rows=20000 width=21) (actual time=0.234..1.421 rows=20000.00 loops=1)
Planning Time: 6.770 ms
Execution Time: 3644.138 ms別枝: 見積りは直った(rows=245693)が、計画がまるごと入れ替わった。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
見積りは直りました。ところが 3.64 秒に伸びています。計画を見ると、Nested Loop が消えてHash Join が 2 つと、明細テーブル 1200 万行の全表スキャンに 変わっています。
プランナは正確な見積りをもとに「25 万回のインデックス参照より 1200 万行を順に読む方が安い」と 判断しました。コストモデル上はその判断で正しく、実測では間違っています。バラバラに読む手間を順に読む手間の 4 倍と仮定した既定値(random_page_cost = 4.0) が、キャッシュの効いた環境ではインデックスを使う側を高く見積もりすぎるためです。
統計の更新は「計画が変わる」操作であって「速くする」操作ではありません。確実に言えるのは計画が別物になることだけで、速いか遅いかは環境で変わります。 見積りが外れる仕組みそのものは見積り行数の内訳にあります。
まとめ
- 全ノードの自分の時間を出して降順に並べる(
loopsを掛けてから引く) - 1 位が分かったら直す
- 順位表を作り直す。2 位が 1 位に上がってくる
- 見積りが小さすぎるノードは根本原因の候補だが、統計を直せば速くなるとは限らない
PostgreSQL 18.6(postgres:18 公式イメージ / 設定は既定値)。2026-08-22 採取。計画ごとにコンテナを再起動して 5 回まわし、中央値の run を載せています。
次の 2 つだけ既定から変えています。
SET max_parallel_workers_per_gather = 0;
SET jit = off;- 並列を切っている理由 — 既定のままだと
Gather/Parallel Seq Scanが入り、loops=2がワーカー間の平均という別の意味で現れます。このセクションが教える 「loopsは繰り返し回数」と衝突するので切っています - JIT を切っている理由 — 計画の末尾に JIT のブロックが増えるだけで、読み方の話には要らないためです
手元で同じ計画を出すときも、この 2 行を先に打ってください。打たないと形が変わります。実行時間の絶対値はマシンとキャッシュの状態で 何倍も動くので、比率で読むのが前提です。
よくある疑問
関連トピック
もっと学びたい方へ(おすすめ書籍)
「なぜこの書き方が速いのか」を実行計画から説明する一冊。条件分岐・集約・結合・更新のそれぞれで、良い書き方と悪い書き方を対比しながら読める。
テーブル設計と正規化、パフォーマンス考慮のインデックス設計まで実務レベルで学べる定番書。第2版ではクラウド対応も強化。
ER 図をどう「使える設計」に落とすか、実務の判断まで踏み込んだ入門書。エンティティの切り出しから多対多の扱いまで具体例が豊富。
ドリル 256 問を実際に打ちながら進める SQL の入門書。付属のブラウザ環境で演習できるので、SELECT から結合・集約までを環境構築で止まらずに通せる。
SQLの本質的な使い方と、インデックスが効くクエリの書き方を学べる。ウィンドウ関数など現代SQLも網羅。
実務でやりがちなSQL・DB設計のアンチパターンとその回避策を体系的に学べる。
PostgreSQLの内部構造・ストレージ・インデックス機構を丁寧に解説。設計と運用計画の鉄則が学べる。
IPAデータベーススペシャリスト試験の総合対策書。インデックス関連は本サイトと合わせて学ぶと理解が深まる。
リレーショナルモデルの理論から、インデックス設計を含む実務で使えるSQLまで解説。
本セクションはAmazonアソシエイトのリンクを含みます。
もっと深くDBを学びたい方へ。
たいてっくが、SQL・データベース設計・パフォーマンスチューニング・IPAデータベーススペシャリスト対策まで、1対1で学習をサポートします。まずは無料相談から。
「教え方も上手で、お人柄も良いメンターです。DB周りの知識はもちろん、何より、しっかり教えてあげようという姿勢がとてもありがたかったです。データベース、SQLの学習を考えている方にはおススメです。」
— H 様(DB・SQL コース受講)「体系的に知識を教えてくださり、実際の業務でも大変役立っております。特に短い時間で効率よく知識の習得や、練習をできているのは期待以上でした。」
— M 様(DB・SQL コース受講)「大変充実したコンテンツでわかりやすいご説明をありがとうございました。基本的な質問にも丁寧にご説明いただき、また業務のご相談にも乗って頂き大変有意義な時間でした。」
— K 様(DB・SQL コース受講)