実行計画の読み方
この実行計画の、どこが遅いか指せますか。
まず、これがそのクエリです。読めると思います。
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.16 秒かかっています。遅い理由はこの中に全部書いてあります。ただし読み方を知らないと、1 行も取り出せません。
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採取環境と再現用 SQL はこのページの末尾にあります。
PostgreSQL 18.6 / 2026-08-22 採取 / 並列・JIT は OFF
このセクションを読み終えると、この計画が読めます。さらに、2.16 秒のうち 1.25 秒を持っているノードがどれかを、 自分で指せるようになります。
先に答えだけ言っておくと、真犯人は上から 19 行目にいます。 そして素朴に読むと、そのノードは全ノード中いちばん軽く見えます(actual time=0.005..0.005 と書いてあるので)。 なぜそう見えてしまうのかが、このセクションの中心にある話です。
読み方を知らないと詰まるのは、記事が想定しているより手前
実行計画の解説はたくさんありますが、多くは「Seq Scan は遅い」から始まります。実際に読者が詰まるのはその手前です。
- 形式そのものが未知。インデントが木構造だと知らなければ、 どこから読むのかすら分かりません(「上から順に実行される」が最頻出の誤読です)
- 数字の単位が独特。
costは秒ではありません。actual timeは 2 つの数字で、 しかもloopsがあるときは 1 回あたりの平均です - そもそも打ち方が分からない。psql を触ったことがない場合、GUI クライアントや ORM からどう出すのかで止まります
このセクションはそこから始めます。
実行計画とは、SQL が「何が欲しいか」しか書いていないのに対して、データベースが「どうやって取るか」を決めた手順書であり、木構造で表現される。同じ結果を返す取り方が複数あるとき、オプティマイザが統計情報をもとにコストを見積もって 1 つを選ぶ。
読めるようになるまでの地図
読めるようになる
記号の意味も読む順番も分からない状態から、木を内側から読めるところまで。
原因を指せるようになる
読めるようになった人が、どのノードが遅いのかを手順で特定できるところまで。
- 発展ボトルネックの特定
全ノードの自分時間を出して並べる。loops の掛け算、見積りとの乖離、無駄読みの 4 つで犯人を指す。
- 発展スキャンの種類
4 種類の表記が何を意味するか。何 % を超えると全表スキャンに切り替わるかを実測で見る。
- 発展Index Cond と Filter
同じ WHERE 句でも、インデックスの張り方で Index Cond 側にも Filter 側にも出る。
- 発展結合の種類
3 つの結合方式がどう選ばれるか。Nested Loop が事故るのは見積りが外れたとき。
- 発展ソートとメモリ
external merge はディスクに書いている印。work_mem を超えたときに何が起きるか。
数字の出どころが分かる
犯人が分かった人が、なぜ DB がその数字を出したのかまで遡る。
遅いクエリを渡されたときの手順
結論だけ先に置いておきます。詳しい実演は遅いノードの見つけ方にあります。
EXPLAIN (ANALYZE)を打つ(SELECT 以外で打つと本当に更新されるので注意)- 各ノードの
actual timeの上端にloopsを掛けて、 そこから子の合計を引く。これがそのノードの自分の時間 - 全ノードを自分の時間で降順に並べる
- 見積り
rowsの 10 倍以上の行が実際に返っているノードを探す。 そこが根本原因の候補 Rows Removed by Filterが大きければ、その分は読む必要のなかった行
この手順は絶対値ではなく比率で読むようにできています。 同じクエリでも実行時間はキャッシュの状態で何倍も動くので、 「何 ms かかったか」より「どのノードが何割を持っているか」のほうが安定するためです。
付録: 手元で同じ計画を出す
このセクションが出している計画はすべて実出力です。 題材のデータを作る SQL を全文載せておくので、同じものを手元で再現できます。Docker があれば 2 分で始められます。
docker run -d --name qp -e POSTGRES_PASSWORD=qp -p 55432:5432 postgres:18
psql -h localhost -p 55432 -U postgres題材のデータを作る SQL(全文・約 1 分 20 秒 / 3.5GB)
-- 実行計画の題材を手元に作る。PostgreSQL 18 で確認。
-- 所要時間: 約 1 分 20 秒 / ディスク使用量: 約 3.5 GB
--
-- このデータが「遅いクエリ」になるように仕込んである点は 4 つ。
-- 1. orders の 4 条件を完全に相関させる
-- → プランナは選択率を独立と仮定して掛けるので、25 万行を 500 行前後と見積もる
-- → その結果 Hash Join ではなく Nested Loop が選ばれ、内側が 25 万回まわる
-- 2. order_items を「商品順」に物理配置する
-- → 同じ注文の明細がページ上に散らばる
-- → 1 回あたりのヒープ取得が効かなくなり、per-loop が 1 桁上がる
-- 3. 1 注文あたり明細 6 行 + クエリ側で qty = 3 に絞る
-- → 内側は 6 行ぶん働くのに、上に流れるのは 2 行
-- 4. 明細行に product_name / note を持たせて現実的な幅にする
-- → テーブルが 3.5GB になり、メモリに載り切らない
--
-- 1 と 2 が無いと Execution Time が 100ms 台になり、教材として成立しない。
DROP TABLE IF EXISTS order_items, orders, customers;
CREATE TABLE customers (
id int PRIMARY KEY,
code text NOT NULL, -- 業務キー。あえてインデックスを張らない
name text NOT NULL
);
INSERT INTO customers
SELECT g, 'C-' || g, 'customer-' || g FROM generate_series(1, 20000) g;
CREATE TABLE orders (
id int PRIMARY KEY,
customer_code text NOT NULL,
status text NOT NULL,
channel text NOT NULL,
payment text NOT NULL,
ordered_at date NOT NULL
);
-- id <= 250000 が「4 条件すべてを満たす行」。4 条件は完全に相関している
INSERT INTO orders
SELECT g,
'C-' || (1 + (g % 20000)),
CASE WHEN g <= 250000 THEN 'shipped' ELSE (ARRAY['pending','cancelled','returned'])[1 + (g % 3)] END,
CASE WHEN g <= 250000 THEN 'web' ELSE (ARRAY['store','phone','partner'])[1 + (g % 3)] END,
CASE WHEN g <= 250000 THEN 'card' ELSE 'cash' END,
-- 条件を満たさない行は必ず 2026-06-30 以前に収める
CASE WHEN g <= 250000 THEN DATE '2026-07-01' + (g % 31) ELSE DATE '2025-08-01' + (g % 334) END
FROM generate_series(1, 2000000) g;
CREATE TABLE order_items (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
order_id int NOT NULL,
product_name text NOT NULL,
note text NOT NULL,
price int NOT NULL,
qty int NOT NULL
);
-- ORDER BY で物理順を「商品順」にする(移行やバルクロードでよくある形)
INSERT INTO order_items (order_id, product_name, note, price, qty)
SELECT o.id, 'product-' || (o.id % 5000), repeat('note ', 40), 100 + (o.id % 900), 1 + (s % 3)
FROM orders o, generate_series(1, 6) s
ORDER BY (o.id % 5000), o.id;
CREATE INDEX order_items_order_id_idx ON order_items (order_id);
ANALYZE customers;
ANALYZE orders;
ANALYZE order_items;
-- ここから計画を採る。この 2 つの SET を先に打つこと(打たないと形が変わる)
SET max_parallel_workers_per_gather = 0;
SET jit = off;
EXPLAIN (ANALYZE)
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;
-- Execution Time はコールド単発でも 1.8〜4.5 秒に振れる。1 回だけ見て判断しないこと。
-- 安定しているのは「構造」と、内側の Index Scan が 1 位で 2 位を大きく引き離すこと。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 コース受講)