DBC Tech Academy / PostgreSQL / 中級

PostgreSQL
実行計画の読み方遅いクエリの原因を、見積もりのずれから探す

なぜ、このクエリは遅いのか。プランナーは、実際の速さを確かめているわけではありません。候補ごとにコストを見積もり、その値が最も小さい方法を選びます。ここでは、コストをどう見積もるのかをひも解き、実行計画から遅さの原因を突き止める手順まで解説します。

explain (analyze, buffers)
Nested Loop (cost=0.86..8.91 rows=1 width=64) (actual time=0.04..1893.22 rows=48210 loops=1) -> Index Scan using idx_orders_status on orders (cost=0.43..4.45 rows=1 actual rows=48210) -> Index Scan using users_pkey on users (cost=0.43..4.45 rows=1 loops=48210) Execution Time: 1912.7 ms

見積もりはわずか 1 行。ところが、実際には 48,210 行ありました。この大きなずれが、内側を 48,210 回も繰り返す計画につながっています。

Cost, not speed

プランナーが見るのは、
速さではなくコスト。

プランナーが選ぶのは、実測で最も速かった方法ではなく、見積もったコストが最も小さい方法です。コストは秒数ではありません。ページの読み出しや行の処理に重みを付けて算出する、比較のための数値です。

安い方を選ぶ

コストは、読むページ数や処理する行数をもとに算出します。

プランナーは複数の実行方法についてコストを計算し、最も小さいものを選びます。その時点では実測値を見られないため、見積もりが外れると、適切な計画を選べません。

基準となるのは、「1 ページを先頭から順に読み出す処理」を 1.0 とした相対値です。既定では次の値が使われますが、設定で変更できます。

seq_page_cost = 1.0 順次読み出し 1 ページ random_page_cost = 4.0 ランダム読み出し 1 ページ cpu_tuple_cost = 0.01 行 1 件の処理 cpu_index_tuple_cost = 0.005 インデックス項目 1 件の処理 cpu_operator_cost = 0.0025 演算子 1 回の評価

既定では、ランダム読み出しの重みは順次読み出しの 4 倍です。この差が、インデックスを使うか、どの結合方式を選ぶかという判断に大きく影響します。

Anatomy of a node

1 行に、
6 つの数字がある。

実行計画は木構造で表されます。各行をクリックして、ノードの役割と注目すべき数値を確認してみましょう。

行をクリックすると解説が切り替わります。

  1. まず、右に深く字下げされた内側の行から読み始めます。実行時のデータも、内側から外側へ流れます。
  2. 各ノードの actual time は、配下の処理も含めた累計値です。そのノードだけにかかった時間は、子ノードの時間を差し引いて求めます。
  3. loops が 1 より大きい場合、actual time と rows はループ 1 回あたりの平均です。処理全体の量を見るときは、loops を掛け戻します。
  4. すべてのノードで、見積もりの rows と実測の rows を比べます。最も大きくずれ始めたノードが、原因を探る手掛かりです。

Where the plan flips

取り出す割合で、
スキャン方法が変わる。

100 万行、1 万ページの表から、条件に合う行を取り出す場面を考えます。選択率(条件に合う行の割合)を動かして、3 つのスキャン方法のコストがどう変わるかを確かめてみましょう。

CHOSEN PLAN
方式読むページ評価する行コスト

相関は、インデックスの順序と行の物理的な並びがどれだけ一致しているかを表します。1.0 に近いほど、必要な行が近いページにまとまります。SSD の特性に合わせて random_page_cost を下げると、インデックスが選ばれやすくなります。

How cost is computed

コストは、
この式で決まる。

コストの正体は、5 つの重みと、読むページ数・処理する行数の掛け算です。式が分かれば、実行計画に並ぶ数字を自分で検算でき、どこを削れば下がるのかも見えてきます。

重みは、5 つしかありません。

基準は seq_page_cost = 1.0、つまりページを 1 枚、順番に読む手間です。他のすべては、その何倍かで表されます。コストが秒ではないというのは、この意味です。

パラメータ既定値数える対象
seq_page_cost1.0順番に読むページ 1 枚
random_page_cost4.0飛び飛びに読むページ 1 枚
cpu_tuple_cost0.01表から行を 1 件取り出す
cpu_index_tuple_cost0.005インデックスの項目を 1 件たどる
cpu_operator_cost0.0025演算子や関数を 1 回評価する

ページの重みは、行の重みの 100 倍以上です。コストの大小を決めるのは、ほとんどの場合、何行を処理するかではなく何ページ読むかです。

Seq Scan を、検算してみます。

1 万ページ・100 万行の表を、条件 1 つで絞り込む場合です。

ページ 10,000 × 1.0 = 10,000 + 行 1,000,000 × 0.01 = 10,000 + 条件 1,000,000 × 0.0025 = 2,500 ───────── total cost 22,500

実行計画には cost=0.00..22500.00 と現れます。条件を 2 つに増やせば最後の項が 2 倍になり、25,000 です。

絞り込んだ結果が 1 行でも、この値は変わりません。条件は全行に対して評価されるからです。Rows Removed by Filter が大きいクエリが遅いのは、まさにこの部分を払っているためです。

2 つの数字は、最初の 1 行と、最後の 1 行。

cost=0.29..22500.00 の左は最初の 1 行を返せるまで、右はすべて返し終わるまでの見積もりです。Seq Scan や Index Scan は読んだ順に返せるので、左はほぼ 0 になります。

一方、Sort やハッシュ表の構築は、入力を最後まで読み切らないと 1 行も返せません。こうしたノードでは、コストのほぼ全部が左側に乗ります。実行計画で cost=18452.11..18702.11 のように左右が近い値になっていたら、そのノードは先に全部読むタイプだと判断できます。

この区別が効いてくるのが LIMIT です。10 行だけ必要なら、プランナーは「最初の 1 行までのコスト+残りを 10 行分だけ按分した額」で候補を比べます。左側の大きい計画はここで不利になり、Sort より Index Scan が選ばれやすくなります。LIMIT を付けた途端に計画が変わるのは、これが理由です。

インデックスのコストは、相関で動きます。

Index Scan の手間は、インデックスをたどる分と、該当行が載っている表のページを読む分に分かれます。後者が、相関によって大きく変わります。

相関が 1.0 なら、必要な行は表の中で連続して並んでいます。触るページは少なく、しかも前から順に読めるため、seq_page_cost に近い重みで数えられます。相関が 0 なら、行はばらばらの位置にあり、ほぼ行数と同じ枚数のページを random_page_cost で読むことになります。

プランナーは、この 2 つの端の値を相関の 2 乗で補間します。相関 0.5 は真ん中ではなく、ランダム側に寄った見積もりになります。相関 0.9 でようやく 8 割方が連続読み扱いです。

相関は、設定ではなく観測値です。pg_stats の correlation 列に、列ごとの値が入っています。追記中心の表の created_at は 1.0 に近く、更新の多い列では時間とともに下がります。CLUSTER で並べ替えれば上がりますが、その後の更新でまた崩れていきます。

2 度目に読むページは、割り引かれます。

Nested Loop の内側のように、同じインデックスを何度もたどる場合、2 回目以降はすでにキャッシュへ載っている可能性が高くなります。プランナーは effective_cache_size を手掛かりに、繰り返しのうち何割がキャッシュから読めるかを推定し、その分コストを割り引きます。

この設定はメモリを確保しません。「OS のキャッシュも含めれば、これくらいは載るはずだ」とプランナーへ伝えるだけの数字です。実際より小さく設定していると、繰り返しの割引が効かずインデックスが不利に見積もられ、Seq Scan が選ばれやすくなります。

Bitmap Scan は、その中間にあります。

Bitmap Index Scan は、条件に合う行が載っているページ番号だけを先に集めます。続く Bitmap Heap Scan は、その一覧をページ番号の順に読むため、同じページを何度も読み直さずに済みます。コストも、順読みとランダム読みの中間の重みで数えられます。選択率が中くらいのときにこの方式が選ばれるのは、そのためです。

実行計画の Heap Blocks: exact=... は行の位置まで記録できたページ、lossy=... はビットマップが work_mem に収まらずページ単位まで粗くした分です。lossy に入ったページは丸ごと読み直し、条件を全行に対して評価し直します。work_mem を上げれば解消します。

ひとつ上のシミュレーターは、この式をそのまま使っています。実際のプランナーはさらに、インデックスの階層の深さ、部分インデックスの選択率、行外に追い出された大きな列の読み出しなどを積み上げます。ただし桁を動かすのはページ数と相関なので、どこで方式が入れ替わるかは、この式のままで追えます。

Choosing a join

行数が、
結合方式を変える。

次は、2 つの表を結合する場面です。外側の行数と作業メモリを動かし、3 つの結合方式のコストがどう変わるかを比べます。

CHOSEN JOIN

Hash Join では、内側の表からメモリ上にハッシュ表を作ります。work_mem に収まらなければ複数のバッチに分け、中間結果をディスクへ書き出すため、コストが大きく増えます。一方、Merge Join は両側の並びをそろえる必要があるので、結合キーの順に読める場合に有利です。

それぞれが得意な場面

Nested Loop: 外側の行数が少ない場面に向いています。内側にインデックスがあれば、外側の 1 行ごとに数ページを読むだけで済みます。ただし、外側の行数が増えるにつれて処理量も増えていきます。

Hash Join: 等値結合で、内側のハッシュ表が work_mem に収まる場面に向いています。両方の表を一度ずつ読めばよいため、大量の行を結合するときに強い方式です。

Merge Join: 両側を結合キーの順に読める場面に向いています。並びをそろえるためのソートが必要になると、その分のコストが加わります。

実行計画で見る危険信号

Nested Loop の内側に大きな loops が付いていたら、まず外側の行数の見積もりを疑います。1 行しかないと見積もっていた外側から実際には数万行が流れると、内側の処理も数万回繰り返されます。

Sort Method: external merge Disk: ...kB と表示されていれば、work_mem に収まらなかったデータがディスクへ書き出されています。

Hash Batches が 1 より大きい場合も、ハッシュ表がメモリに収まっていないと判断できます。

Parallel execution

ワーカーが分担すると、
rows の意味が変わる。

表がある程度の大きさを超えると、プランナーは処理を複数のワーカーに分担させます。このとき実行計画に現れる行数は、これまでの読み方のままでは合いません。

Gather (cost=1000.00..27893.51 rows=48210 width=64) (actual time=0.42..612.33 rows=48210 loops=1) Workers Planned: 2 Workers Launched: 2 -> Parallel Seq Scan on orders (cost=0.00..21072.51 rows=20088 width=64) (actual time=0.03..584.11 rows=16070 loops=3) Filter: (status = 'shipped'::text) Rows Removed by Filter: 317263

16,070 行ではなく、48,210 行です。

Parallel Seq Scan の rows=16070 は、ワーカー 1 本あたりの平均です。loops=3 が示すとおり、実際に読んだのは 3 本分。16,070 × 3 = 48,210 行が全体の量で、それが Gather の行に現れています。

loops が 1 より大きければ 1 回あたりの平均、という原則はここでも同じです。違うのは、ループの正体が繰り返しではなく並列だという点だけです。

ワーカーの本数は、リーダーを含めて数えます。Workers Launched が 2 でも loops は 3 です。問い合わせを起こしたプロセス自身も、待つのではなく分担に加わるためです。

  1. Workers Planned と Workers Launched を見比べます。Launched のほうが少ないことがあります。同時に走る他のクエリが max_parallel_workers を使い切っていると、計画どおりの本数を確保できません。この場合、同じクエリなのに実行のたびに速さが変わります。
  2. 見積もりと実測を比べるときは、どちらもワーカー 1 本あたりの値どうしで比べます。rows=20088 と rows=16070 の比較であり、48,210 と比べるのではありません。
  3. 並列が原因かを確かめるときは SET max_parallel_workers_per_gather = 0; で切り、同じクエリを実行します。速さが変わらなければ、原因は別にあります。
  4. 並列が使われない場合は表の大きさを疑います。既定では min_parallel_table_scan_size(8MB)に満たない表は分担の対象になりません。max_parallel_workers_per_gather の既定は 2 で、これを 0 にすると並列は起きません。

The rest of the nodes

スキャンと結合の、
その先のノード。

実行計画には、ここまでで扱った以外にも多くのノードが現れます。読み方の原則は変わりません。内側から読み、見積もりと実測を比べます。それぞれが何をしていて、どこに危険信号が出るのかを一覧にします。

ノード現れる場面読むときの注目点
AppendUNION ALL、パーティション表子の数。never executed の子は実際には読まれていません
Merge Append子を並び順のまま束ねるとき子ごとに Sort が付いていれば、その分の作業メモリが要ります
Bitmap Index Scan選択率が中くらいの絞り込み集めるのはページ番号だけです。行はまだ読んでいません
Bitmap Heap Scan上のページ番号から実際に行を読むHeap Blocks の lossy。出ていれば work_mem 不足です
BitmapAnd / BitmapOr複数のインデックスを組み合わせるどのインデックスが実際に使われたか
MemoizeNested Loop の内側で同じキーが繰り返し引かれるHits / Misses / Evictions
Materialize内側を一度作って何度も走査する元の行数。大きいまま実体化していないか
HashAggregateGROUP BY(並び順を問わない)Batches と Disk Usage。2 以上ならメモリ不足です
GroupAggregate入力がすでにキー順に並んでいる GROUP BY直下に Sort が付いていないか。付いていれば本体はそちらです
WindowAggウィンドウ関数PARTITION BY / ORDER BY の組ごとに Sort が必要になります
Incremental Sort並べ替えキーの前半だけがすでに並んでいるPresorted Groups。まとめて並べ替えるより省メモリです
UniqueDISTINCT、UNION直下の Sort が本体のコストです
SetOpINTERSECT、EXCEPT両側を読み切ってから判定します
CTE Scan実体化された WITH 句loops。何度も走査されていないか
Subquery Scan上位へ展開できなかった副問い合わせ上の条件が中へ押し込まれていない兆候です
Function Scan集合を返す関数の呼び出し行数が決め打ちで見積もられていないか
Result定数だけの評価、条件が常に偽のときOne-Time Filter: false なら配下は一度も実行されません
ProjectSetSELECT 句に集合を返す関数を書いたとき1 行の入力から複数行が出るため、上位の行数が跳ねます
LimitLIMIT / OFFSET配下が途中で止まっているか。OFFSET の分は捨てるために読んでいます
LockRowsSELECT ... FOR UPDATE行ロックの取得です。待ち時間はここに現れます
ModifyTableINSERT / UPDATE / DELETE本体は配下のスキャンです。トリガーの時間は末尾に別行で出ます

Memoize は、内側の結果を覚えます。

Nested Loop の内側で同じキーが何度も引かれる場合、PostgreSQL は結果をハッシュ表に覚えておき、2 回目以降はそこから返します。実行計画では次のように出ます。

-> Memoize (actual rows=1 loops=48210) Cache Key: o.user_id Hits: 47102 Misses: 1108 Evictions: 0 Overflows: 0 Memory Usage: 129kB

Hits が大きければ、内側の走査はその回数だけ省けています。逆に Evictions が増えていれば、覚えきれずに捨てているということです。この表の大きさは work_mem × hash_mem_multiplier で決まります。

Misses が Hits と同じくらいなら、効いていません。キーがほとんど重複していないということなので、この場合に見るべきは Memoize ではなく、外側の行数の見積もりです。

WITH 句は、いつも実体化されるわけではありません。

PostgreSQL 12 より前は、WITH は必ず先に実行され、結果を丸ごと保持していました。上位の条件は中へ入らず、これを利用して意図的に評価順を固定する書き方も広く使われていました。

12 以降は、参照が 1 回だけで副作用がなく、再帰でもない WITH は本体へ展開されます。展開されれば上位の条件が中へ押し込まれ、インデックスも使われます。このとき実行計画に CTE Scan は現れません。

WITH recent AS MATERIALIZED (...) -- 必ず実体化 WITH recent AS NOT MATERIALIZED (...) -- 必ず展開

同じ結果を何度も参照するなら実体化、1 度しか使わず絞り込みを効かせたいなら展開が有利です。CTE Scan の loops が大きければ、実体化した結果を繰り返し走査しています。

  1. never executed は、時間が 0 だという意味ではありません。そのノードが一度も呼ばれなかったという意味です。Limit で途中で止まった場合や、条件が常に偽で One-Time Filter: false が付いた場合に現れます。
  2. 集合を返す関数の見積もりは、決め打ちです。ROWS を指定していない関数は 1000 行と見積もられます。実際が 5 行でも 100 万行でも同じ 1000 行です。関数の結果を結合に使うと、この決め打ちがそのまま計画の判断材料になります。CREATE FUNCTION ... ROWS 5 で実態に近い値を教えられます。
  3. Sort は、単独では現れません。必ず何かの前処理です。GroupAggregate、Unique、Merge Join、WindowAgg の直下に付きます。Sort が重いときに直すべきは、多くの場合その上のノードの選択のほうです。
  4. ノードの種類が分からなくても、読み方は変わりません。見慣れないノードが出てきたら、まず見積もりの rows と実測の rows を比べます。そこがずれていなければ、そのノードは原因ではありません。

Partition pruning

読まなかったパーティションは、
実行計画から消える。

パーティション表では、条件から不要なパーティションを除く判断が入ります。効いているかどうかは、実行計画に何が現れていないかで読み取ります。

Append (cost=0.00..1421.00 rows=4210 width=64) (actual time=0.02..3.11 rows=4210 loops=1) Subplans Removed: 11 -> Seq Scan on orders_2026_08 orders_1 (actual time=0.02..1.44 rows=1980 loops=1) Filter: (created_at >= $1) -> Seq Scan on orders_2026_09 orders_2 (actual time=0.01..1.51 rows=2230 loops=1) Filter: (created_at >= $1)

13 個あるうち、2 個しか読んでいません。

月ごとに 13 個のパーティションを持つ表ですが、実行計画に並んでいるのは 2 個だけです。残りは条件に合う行がないと判断され、実行計画から取り除かれました。

枝刈りには 2 つの時点があります。条件の値が計画時に分かっていれば、そのとき除かれます。この場合、除かれたパーティションは実行計画に痕跡すら残しません。

値が実行時にしか決まらない場合は、実行しながら除きます。その結果が Subplans Removed: 11 です。プレースホルダを使ったクエリや、副問い合わせの結果で絞る場合がこれにあたります。

ANALYZE なしの EXPLAIN では、全パーティションが並びます。実行時の枝刈りは実行しないと起きないためです。枝刈りを確かめたいときは、必ず EXPLAIN (ANALYZE, BUFFERS) で見てください。

  1. パーティションキーを条件に含めます。キー以外の列だけで絞ると、すべてのパーティションを読むことになります。Append の子が全部並んでいたら、まずこれを疑います。
  2. キーを関数で包むと、枝刈りは効きません。WHERE date_trunc('month', created_at) = '2026-09-01' ではなく、created_at >= '2026-09-01' AND created_at < '2026-10-01' と範囲で書きます。統計が使えるという意味でも、こちらが有利です。
  3. プレースホルダを使うクエリでは、汎用計画に注意します。同じ文を繰り返し実行すると、PostgreSQL は 6 回目あたりから値を見ない汎用計画に切り替えることがあります。このとき計画時の枝刈りは働かず、実行時の枝刈りだけが頼りです。SET plan_cache_mode = 'force_custom_plan'; で毎回値を見た計画に固定できます。
  4. パーティション同士の結合は、既定では組にしません。境界のそろった表どうしなら、パーティション単位で結合するほうが速くなります。enable_partitionwise_join と enable_partitionwise_aggregate は既定で無効なので、必要なら明示的に有効化します。計画時間とメモリは増えます。
  5. パーティションを増やしすぎないでください。枝刈りの前に、プランナーは全パーティションを候補として検討します。数千個に分割すると、実行そのものより計画に時間がかかる状態になります。

Estimation drift

見積もりのずれは、
結合するたびに大きくなる。

行数の見積もりを外すと、その誤差は上位のノードへ伝わり、結合を重ねるたびに大きくなります。まずは、実行計画の深い位置でずれ始めたノードを探しましょう。

なぜ 1 段目の誤差が致命的になるのか

プランナーは、条件ごとの選択率を掛け合わせて行数を見積もります。しかし、2 つの条件に相関があると、それぞれが独立しているという前提が崩れ、実際よりも小さく見積もることがあります。

例えば、「都道府県 = 東京」と「市区町村 = 渋谷区」は独立した条件ではありません。それぞれの選択率を単純に掛けると、該当する行数を実際よりもはるかに少なく見積もってしまいます。その結果、Nested Loop が選ばれ、内側の処理を数万回も繰り返すことになります。

下のスライダーでは、最初の見積もりが実測値とどれだけずれたとき、2 回の結合を経て誤差がどこまで膨らむかを確認できます。

1 段目

単一表の絞り込み

2 段目

1 回目の結合の後

3 段目

2 回目の結合の後

結合のたびに誤差が重なるため、1 段目で 10 倍ずれると、3 段目では桁そのものが変わります。

乖離を見つけたら

まずは ANALYZE を実行し、統計情報を最新の状態にします。それでも見積もりが改善しなければ、相関のある列に対して拡張統計を作成します。

CREATE STATISTICS stat_addr (dependencies, ndistinct) ON pref, city FROM addresses; ANALYZE addresses;

特定の列だけ値の分布が偏っている場合は、その列の統計目標を上げ、より細かなヒストグラムを作らせます。

ALTER TABLE addresses ALTER COLUMN city SET STATISTICS 500;

Fixing the estimate

見積もりを直すのは、
統計情報。

乖離を見つけたら、次は直す番です。プランナーは表そのものではなく、統計情報を見て行数を見積もります。ずれの原因は、多くの場合そちらにあります。

まず、統計が古くないかを確かめます。

大量の投入や削除の直後は、自動処理が追いつく前に統計が実態から離れます。pg_stat_user_tables の last_analyze と last_autoanalyze を見て、最後に集計された時刻を確認してください。

ANALYZE orders; SELECT last_analyze, last_autoanalyze, n_live_tup FROM pg_stat_user_tables WHERE relname = 'orders';

ANALYZE を実行しただけで見積もりが直り、計画が変わることは珍しくありません。設定に手を付ける前に、ここを確かめます。

統計の細かさを、列ごとに上げます。

既定では、1 列につき 100 個の代表値と度数分布を保持します(default_statistics_target)。値の種類が極端に多い列や、偏りの大きい列では、この粒度で足りません。

ALTER TABLE orders ALTER COLUMN customer_id SET STATISTICS 1000; ANALYZE orders;

全体設定を上げると、すべての表の集計時間と保持量が増えます。問題の列だけを上げるのが基本です。効いたかどうかは pg_stats の n_distinct と most_common_vals で確かめられます。

列どうしの関係は、単独の統計では表せません。

プランナーは既定で、条件どうしが互いに独立だと仮定します。都道府県が「神奈川県」である行が全体の 7%、市区町村が「横浜市」である行が 3% なら、両方を満たす行は 0.21% と見積もります。実際には横浜市はすべて神奈川県にあるので、正しくは 3% です。14 倍の過小見積もりです。

この関係を教えるのが拡張統計です。

CREATE STATISTICS stat_addr (dependencies, ndistinct, mcv) ON pref, city FROM addresses; ANALYZE addresses;

dependencies は「片方が決まれば他方も絞られる」関係、ndistinct は組み合わせの種類数、mcv はよく出る組み合わせを保持します。作成後は必ず ANALYZE を実行してください。

条件を式で書くと、統計から外れます。

WHERE date_trunc('month', created_at) = '2026-09-01' のように列を加工すると、created_at の統計は使えません。プランナーは決め打ちの推定値を使うため、大きく外れます。

式インデックスを作ると、その式そのものの統計が集められます。

CREATE INDEX idx_orders_month ON orders (date_trunc('month', created_at)); ANALYZE orders;

より確実なのは、列を加工せずに範囲で書くことです。created_at >= '2026-09-01' AND created_at < '2026-10-01' と書けば、元の列の統計がそのまま使えます。

直したあとは、もう一度 EXPLAIN ANALYZE を取ります。見積もりが実測に近づいたか、そして計画そのものが変わったかを確認してください。行数が合っても計画が変わらない場合、原因は統計ではありません。

Where the time goes

行数が合っているのに
遅いときは、ページ数。

見積もりと実測が一致しているのに遅い場合、原因は行数ではなく読み書きの量にあります。BUFFERS を付けると、各ノードが何ページ触ったかが表示されます。

Sort (cost=18452.11..18702.11 rows=100000 width=48) (actual time=982.14..1104.77 rows=100000 loops=1) Sort Key: created_at DESC Sort Method: external merge Disk: 5416kB Buffers: shared hit=210 read=8124, temp read=677 written=679 -> Seq Scan on orders (actual time=0.02..312.44 rows=100000 loops=1) Buffers: shared hit=210 read=8124

4 つの数字を読み分けます。

shared hit は共有バッファから読めたページ、read はそこになくディスク(または OS のキャッシュ)へ取りに行ったページです。同じクエリの 1 回目と 2 回目で時間が違う理由は、ここに現れます。

dirtied は内容を変更したページ、written は書き出したページです。SELECT でも、行の可視性の記録を更新して dirtied が増えることがあります。

temp が出たら、メモリ不足です。上の例では Sort Method: external merge と temp read/written が同時に出ています。ソートがメモリに収まらず、一時ファイルへ書き出しています。

  1. read が大きいなら、読み出しの量そのものが問題です。インデックスで触るページを減らせないか、必要な列だけを選んでいるかを見直します。
  2. hit ばかりで遅いなら、ページはメモリにあります。原因は読み出しではなく、行の処理そのものです。Rows Removed by Filter が大きくないか確かめてください。
  3. temp が出ているなら、work_mem が足りていません。Sort Method が quicksort になれば解消です。Hash Batches が 2 以上のときも同じ原因です。
  4. ページ数は 1 ページ 8KB で換算できます。read=8124 はおよそ 63MB です。所要時間と突き合わせると、読み出しの速度が妥当かどうかを判断できます。

Compiled at run time

実行の前に、
コンパイルが走ることがある。

見積もりコストが一定の値を超えると、PostgreSQL は条件式や集計の評価を機械語へコンパイルしてから実行します。速くするための仕組みですが、短いクエリでは、この準備そのものが所要時間の大半を占めることがあります。

HashAggregate (cost=248301.22..248312.55 rows=12 width=40) (actual time=241.08..241.11 rows=12 loops=1) -> Seq Scan on orders (actual time=0.03..38.44 rows=41022 loops=1) Filter: (status <> 'draft'::text) Planning Time: 0.211 ms JIT: Functions: 34 Options: Inlining true, Optimization true, Expressions true, Deforming true Timing: Generation 4.1 ms, Inlining 18.7 ms, Optimization 121.6 ms, Emission 58.2 ms, Total 202.6 ms Execution Time: 243.9 ms

243 ms のうち、202 ms が準備です。

実際にデータを読んで集計したのは 41 ms ほどで、残りはコンパイルに費やされています。この時間は Execution Time に含まれます。Planning Time ではありません。実行計画の下だけを見て「集計が遅い」と判断すると、原因を取り違えます。

4 つの内訳のうち、時間を食いやすいのは Optimization と Emission です。Deforming は行から列を取り出す処理、Expressions は WHERE 句や集計式の評価を指します。

並列実行では、ワーカーごとにコンパイルされます。表示される時間はその合計なので、ワーカー 3 本なら 3 倍の値が並びます。実際に待たされる時間は 1 本分ですが、CPU はその分使われています。

判断の材料は、実測ではなく見積もりです。

JIT が動くかどうかは、プランナーが出した総コストだけで決まります。実際に何行処理するかは関係ありません。

パラメータ既定値超えたときに起きること
jit_above_cost100,000コンパイルを行う
jit_inline_above_cost500,000関数の呼び出しを展開する
jit_optimize_above_cost500,000最適化をかける(いちばん重い)

ここに、見積もりの誤差が直結します。実際は 100 行しか返らないのに、統計が古くて 500 万行と見積もっていれば、コストは閾値を軽く超え、100 行のためにコンパイルが走ります。JIT の無駄打ちは、多くの場合、統計の問題として現れています。

疑ったら、まず切って比べます。

SET jit = off; EXPLAIN (ANALYZE, BUFFERS) SELECT ...; RESET jit;

これで速くなるなら、原因は JIT です。SET jit_above_cost = 500000; のように閾値を上げれば、本当に重いクエリだけを対象にできます。判断の目安は、コンパイルに費やす時間を、実行時間の短縮で取り返せるかです。

取り返せるのは、数百万行を読みながら式を繰り返し評価するような分析系のクエリです。数十ミリ秒で終わる問い合わせが大量に飛ぶ用途では、閾値を上げるか無効にしたほうが、全体としては速くなります。

列とパーティションが多いほど、Functions が増えます。数百の列を扱うクエリでは生成される関数が数百個に達し、コンパイルだけで秒単位になることがあります。実行計画に JIT: の節を見つけたら、まず Functions の数と Total を確かめてください。

Read the symptom

実行計画から、
遅さの原因を見抜く。

現場でよく目にする 3 つの実行計画を用意しました。それぞれ、どこに原因があるのかを考えてみましょう。

Settings, last

設定を変えるのは、
いちばん最後。

設定値はサーバー全体に効きます。1 本のクエリのために全体を動かす前に、そのクエリの中で解けることを尽くします。

  1. 統計を直す。ANALYZE、列ごとの粒度、拡張統計。ここで直れば、他のクエリの見積もりも同時に良くなります。
  2. インデックスを見直す。Filter で捨てている条件をインデックスへ移せないか。複合インデックスの列順は合っているか。
  3. クエリの書き方を直す。列を加工していないか、不要な列を選んでいないか。
  4. それでも残るときだけ、設定を検討する。まずセッション単位で試し、効果を確かめてから全体設定の変更を判断します。

判断の目安

  • work_mem:ソートやハッシュ表 1 つあたりの上限です。クエリ 1 本あたりではありません。並列ワーカーと複数のノードがそれぞれ確保するため、接続数を掛けた最悪値で見積もってください。特定のクエリのためなら SET work_mem = '64MB'; をそのセッションだけに適用します。
  • random_page_cost:既定の 4.0 は回転式ディスクを前提とした値です。SSD では 1.1 前後が実態に近く、下げるとインデックスが選ばれやすくなります。
  • effective_cache_size:実際にメモリを確保する設定ではなく、見積もりにだけ影響する目安です。OS のキャッシュを含めて使える量を伝えるもので、実メモリの 50〜75% 程度を設定します。
  • max_parallel_workers_per_gather:既定は 2 です。上げると 1 本のクエリは速くなりますが、同時実行数の多いサーバーでは全体の処理量が落ちることがあります。

効果の確かめ方

  • セッション単位で試します。SET はその接続だけに効き、切断で元に戻ります。RESET work_mem; でいつでも戻せます。
  • 1 つずつ変えます。同時に 2 つ動かすと、どちらが効いたのか判断できません。
  • 変更のたびに EXPLAIN (ANALYZE, BUFFERS) を取り直します。時間だけでなく、計画そのものが変わったかを見ます。計画が同じなら、その設定は効いていません。
  • キャッシュの状態をそろえます。1 回目と 2 回目では shared hit の割合が違います。比較するなら、同じ条件で複数回実行した値どうしで比べてください。

In production

本番で使うときの、
注意と手順。

実行計画を取る操作そのものにも、気を付ける点があります。あわせて、数あるクエリのどれから手を付けるかの決め方を整理します。

実行する前に

  • EXPLAIN ANALYZE はクエリを実際に実行します。UPDATE や DELETE に付けると、データが変わります。更新系を調べるときは BEGIN; と ROLLBACK; で囲んでください。
  • BUFFERS を必ず付けます。EXPLAIN (ANALYZE, BUFFERS) が既定の形だと考えてください。読み方はバッファの節で扱っています。
  • 実行時間の計測自体が、負荷になることがあります。ノードごとの時刻取得が遅い環境では、EXPLAIN ANALYZE を付けるだけで数倍遅くなることがあります。時間ではなく行数だけを見たい場合は EXPLAIN (ANALYZE, TIMING OFF, BUFFERS) で計測を省けます。
  • 本番で長時間かかるクエリに、そのまま付けないでください。EXPLAIN ANALYZE は最後まで実行しないと結果を返しません。5 分かかるクエリの調査は 5 分かかり、その間の資源も使われます。

検証環境の実行計画は、本番と一致しません。データ量も統計情報も違うため、プランナーの判断が変わります。判断材料にするなら、本番と同等のデータ量で確かめてください。

どのクエリから手を付けるか

  • 選ぶ基準は、1 回の遅さではなく総時間です。pg_stat_statements で total_exec_time の大きい順に並べ、上位から着手します。3 秒かかるクエリが 1 日 10 回より、80 ミリ秒のクエリが 100 万回のほうが、サーバー全体への影響ははるかに大きくなります。
  • ばらつきも見ます。同じクエリの min_exec_time と max_exec_time が桁で違うなら、原因は計画そのものではなく、キャッシュの状態やロック待ち、並列ワーカーを確保できたかどうかにあります。
  • 本番で再現しない場合は記録します。auto_explain を設定し、auto_explain.log_min_duration を超えたクエリの実行計画だけをログへ残します。log_analyze と log_buffers も有効にしておくと、実測値まで残ります。
  • 再現できたら、まず統計を疑います。手順は統計を直す節のとおりです。設定に手を付けるのは、そのあとです。

Myth vs. reality

実行計画で、
よくある 3 つの勘違い。

実行計画に慣れている人でも、判断を誤りやすいポイントを整理します。

MISCONCEPTION 01

「Seq Scan は必ず悪い」

表の大部分を返すなら、先頭から順に読んだほうが効率的です。例えば、1 ページに 100 行ある表から全体の 10% の行を取り出そうとすると、結局はほぼすべてのページに触れます。インデックス経由でページをばらばらに読むよりも、Seq Scan を選ぶほうが合理的です。

MISCONCEPTION 02

「cost の数字は実行時間に比例する」

コストは計画同士を比べるための相対値であり、単位は秒ではありません。実行時にデータがキャッシュへ載っているかどうかも、そのまま反映されるわけではありません。所要時間を知りたい場合は、ANALYZE を付けて実測します。

MISCONCEPTION 03

「actual time が大きいノードが犯人」

actual time は配下の処理も含めた累計値で、loops が 1 より大きければ、ループ 1 回あたりの平均です。原因は、時間の大きなノードではなく、実行計画の深い位置で行数の見積もりを外したノードにあることが少なくありません。時間だけで判断せず、見積もりと実測のずれを追いましょう。

Knowledge check

10問で、理解を
確かめよう。

答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。

YOUR PROGRESS 01 / 10

選択肢を1つ選んでください。

Where to look first

まず探すのは、
時間ではなく行数のずれ。

内側から読む
rows の乖離を探す
loops を掛け戻す
統計を直す