はじめに
EXPLAIN ANALYZE を見ていると、見積りと実測が桁違いにズレているノードに出会うことがあります。
Seq Scan on orders (cost=... rows=526 ...) (actual ... rows=250000.00 loops=1)
526 行のつもりで 25 万行。475 倍です。
こういうものを見つけたとき、私は「統計情報が古いから直せばいい」と考えていました。実際に直してみたら、同じクエリが 2.16 秒から 3.64 秒に伸びました。
この記事は、その実測と、プランナが何と何を比べて計画を捨てたのかを、コストの数字で追いかけた記録です。結論だけ先に書くと、統計情報の更新は計画を変える操作であって、速くする操作ではありません。
計画はすべて PostgreSQL 18.6 の公式イメージで実際に採ったものです。この記事に関係するノードだけを抜き出して貼っているので、木として欠けている枝があります(Buffers: の行も削っています)。
題材
顧客 2 万件・注文 200 万件・明細 1200 万件に対する、集計して上位 10 件を出すだけのクエリです。
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;
SQL としては素直な部類だと思います。これが 2.16 秒かかります。関係するのは計画の下半分です。
-> 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)
-> 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
-> 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)
犯人は内側の Index Scan でした。表示は actual time=0.005..0.005 で、素朴に眺めるといちばん軽く見えます。ところが loops=250000 が付いていて、0.005 × 250000 = 1.25 秒。これが 2.16 秒の 58% を占めます。
各ノードの actual time は子の時間を含んだ累計なので、比べるには子のぶんを引いて loops を掛け戻す必要があります。その手順は遅いノードの見つけ方に小さい計画から順を追って書いたので、ここでは結果だけにします。この記事の本題はここから先です。
なぜ 25 万回もループしているのか
loops=250000 の出どころは、外側の Seq Scan on orders です。見積り 526 行に対して、実測 25 万行。
プランナは「外側が 526 行なら、内側を 526 回まわす計画がいちばん安い」と判断して Nested Loop を選びました。実際には 25 万回まわります。
ズレた理由もはっきりしています。WHERE に条件が 4 つあり、PostgreSQL は既定ではそれぞれの選択率を独立とみなして掛け算します。ところがこのデータでは 4 つが連動しているので、掛けると小さくなりすぎます。
ここまでは診断として正しかったと思っています。問題は次の一手です。
相関を教えてみる
PostgreSQL には複数列の相関を統計として持たせる仕組みがあります。まさにこのケースのためのものです。
CREATE STATISTICS orders_corr (mcv, ndistinct)
ON status, ordered_at, channel, payment FROM orders;
ANALYZE orders;
見積りは狙いどおり直りました。rows=526 が rows=245693 になり、実測の 25 万行にほぼ一致しています。
そして実行時間は 3.64 秒になりました。1.7 倍遅くなっています。
-> 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)
-> 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
Nested Loop が消えて Hash Join になり、order_items へのインデックス参照が 1200 万行の Seq Scan に変わりました。Rows Removed by Filter: 8000000 のとおり、1200 万行読んで 800 万行を捨てて 400 万行だけ使っています。この Seq Scan 単体で 2.5 秒です。
プランナは何と何を比べたのか
ここが面白いところで、コストの数字だけで再現できます。手を入れたのは orders の統計だけなので、order_items 側の見積りは前後で変わっていません。
まず、元の Nested Loop 計画の 196425.08 がどこから来ているか。
Hash Join の総コスト 57141.20
内側 Index Scan の総コスト 266.09 ← これを外側の行数ぶんまわす
外側の見積り行数 526
57141.20 + 526 × 266.09 = 197104.54
表示は 196425.08 なので、だいたい合っています。ぴったりにならないのは、2 回目以降の内側スキャンをプランナが少し安く見積もるためです。とはいえ内訳としてはこれで読めます。
次に、統計を直したあと。外側の見積りが 526 から 245693 になったので、同じ Nested Loop 計画を維持した場合のコストはこうなります。
245693 × 266.09 ≒ 65,376,000
**約 6540 万。**一方、実際に選ばれた Hash Join 計画は、いちばん外側の Limit まで含めて 675525.16 です。
Nested Loop を維持 約 65,000,000
Hash Join に変更 675,525
桁が 2 つ違います。プランナは迷いません。見積りが正確になった結果、正確な入力にもとづいて計画を捨てたわけです。
なぜコストモデルが外したか
実測で負けたのは、そのコストモデルの前提のほうです。
266.09 という内側 1 回あたりのコストには、既定の random_page_cost = 4.0、つまり「バラバラに読む手間は順に読む手間の 4 倍」という仮定が入っています。ところがデータがキャッシュに乗っている環境では、この差はほとんどありません。実測で内側 1 回が 0.005 ミリ秒で終わっていたのはそのためです。
インデックスを使う側だけが 4 倍のペナルティを受けた見積りになっていて、そのペナルティ込みの数字で 6540 万と出た。実際には 25 万回まわしても 1.25 秒でした。
つまりこの事故は、統計が原因ではなく、統計が正確になったことで初めてコストモデルの歪みが表に出たという形をしています。ズレた統計が、たまたま歪みを打ち消していた。
実際に効いた直し方
同じクエリを速くしたのは、統計ではなくインデックスのほうでした。内側が遅いのはインデックスを引いたあとテーブル本体を読みに行っているからなので、必要な列をインデックスに含めます。
CREATE INDEX order_items_covering
ON order_items (order_id) INCLUDE (price, qty);
2.16 秒が 0.94 秒になりました。内側は Index Only Scan になり、Heap Fetches: 0、1 回あたり 0.005 ms が 0.001 ms です。手法自体はカバリングインデックスとして知られているものです。
注目したいのは、この計画では Seq Scan on orders の見積りが rows=526 のまま、つまり 475 倍ズレたままだということです。ズレを放置したほうが速い、という結果になりました。
持ち帰ったこと
- 統計情報の更新は「計画が変わる」操作であって「速くする」操作ではない。確実に言えるのは計画が別物になることだけで、速いか遅いかは環境で決まる
- 見積りのズレは、遅い原因を説明する材料としては強力だが、直す対象としては最優先ではない。実際に読んでいる量を減らすほうが結果に直結した
- 統計を直す前に、
random_page_costが自分の環境の実態と合っているかを見ておく。合っていないまま見積りだけ正確にすると、歪みが素通しで計画に効く
「ズレていたら直す」とだけ覚えていたので、直した先で何が比較されるかを考えていませんでした。
採取環境
PostgreSQL 18.6(postgres:18 公式イメージ、設定は既定値)。計画ごとにコンテナを再起動して 5 回まわし、中央値の run を載せています。
既定から変えたのは次の 2 つだけです。
SET max_parallel_workers_per_gather = 0;
SET jit = off;
並列を切っているのは、既定のままだと Gather / Parallel Seq Scan が入り、loops が「ワーカー間の平均」という別の意味で現れて話がややこしくなるためです。手元で試すときも、この 2 行を先に打たないと計画の形が変わります。実行時間の絶対値はマシンとキャッシュの状態で何倍も動くので、比率で読んでください。
もっと詳しく
この記事で結果だけ書いた部分は、実出力を並べて解説したページがあります。
-
遅いノードの見つけ方(4 つのサイン) — 自分の時間を出す引き算と、
loopsの掛け算で 1 位と最下位が入れ替わる過程 -
EXPLAIN ANALYZE の見方 —
actual timeが累計であること、loopsが 1 回あたりの平均であること -
cost / rows / width の意味 — この記事で足し算した
costが何の単位なのか -
見積り行数はどこから来るのか — 3 行しかないテーブルで
rows=850と出る理由を、計算で最後まで再現する