同じクエリが6回目から100倍遅くなる。PostgreSQL の generic plan を止める

古川

エンジニア

古川

同じクエリが6回目から100倍遅くなる。PostgreSQL の generic plan を止める

5回目までは速いのに、6回目だけ40秒

前回、統計情報が一度も収集されていなかったせいでクエリが遅かった話を書きました。ANALYZE を実行して推定は正しくなり、いくつかのクエリは1秒台まで落ちました。

ところが1本だけ、まだ様子がおかしいものがありました。同じ接続で同じクエリを9回続けて実行すると、こうなります。

1回目     404ms
2回目     400ms
3回目     407ms
4回目     426ms
5回目     394ms
6回目  40,243ms   ← 100倍
7回目  37,855ms
8回目  37,884ms
9回目  39,185ms

6回目で切り替わって、そのまま戻りません。 データも SQL も何ひとつ変えていません。

原因は PostgreSQL がプランをキャッシュする仕組みでした。この記事はその記録です。

なおシステムの中身がわかると具体的すぎるので、テーブル名は一般化しています。

store       店舗マスタ(40行)
transaction 取引履歴(30万行)

前提: プランは毎回作られるわけではない

SQL を実行するとき、PostgreSQL は「どう実行するか」を決める**実行計画(プラン)**を作ります。インデックスを使うか、結合をどの方式でやるか、といった判断です。

このプラン作成には時間がかかります。そこで同じ形のクエリなら、作ったプランを再利用したいという発想が出てきます。

再利用の対象になるのは、値を後から差し込む形のクエリです。

-- 値が直接書かれている → 使い回せない
SELECT * FROM transaction WHERE store_id = 5;

-- 値をパラメータにしている → 使い回せる
PREPARE p AS SELECT * FROM transaction WHERE store_id = $1;
EXECUTE p(5);

「自分は PREPARE なんて書いていない」と思うかもしれませんが、知らないうちに使っています

経路プランがキャッシュされるか
ORM やドライバのプレースホルダ(?$1される
plpgsql 関数の中の SQLされる
アプリからの単純な文字列連結されない

関数の中に書いた SQL は対象になる、というのが今回の落とし穴でした。

前提: custom plan と generic plan

キャッシュの対象になるとき、PostgreSQL はプランを2種類作れます。

プランの作り方精度作成コスト
custom planパラメータの実際の値を当てはめて作る高い毎回かかる
generic planパラメータを値が不明なままにして作る低い1回だけ

generic plan は使い回せるので、プラン作成のコストを毎回払わずに済みます。これは本来ありがたい最適化です。

問題は精度です。値が分からないと、統計情報を引けません。

統計が最新でも推定を外す

ここが一番の勘所です。前回の記事で ANALYZE を実行して統計は最新にしました。それでも generic plan は推定を外します。

統計情報は「データがどう散らばっているか」の地図です。でも見積もりには、もう一つ必要なものがあります。今回どこを探すのかです。

実際にローカルで比べてみます。30万行のテーブルから3ヶ月分を取り出すクエリです。

-- ① 値が分かっている場合
EXPLAIN SELECT count(*) FROM transaction
WHERE created_at >= '2026-05-01' AND created_at < '2026-08-01';
Index Only Scan on transaction  (cost=0.30..1418.14 rows=51692 width=0)

推定 51,692行。実際は51,925行なので、ほぼ正確です。

-- ② 値が未知の場合(generic plan を強制)
PREPARE p AS SELECT count(*) FROM transaction
  WHERE created_at >= $1 AND created_at < $2;
SET plan_cache_mode = 'force_generic_plan';
EXPLAIN EXECUTE p('2026-05-01', '2026-08-01');
Index Only Scan on transaction  (cost=0.30..90.74 rows=1522 width=0)

推定 1,522行同じ統計・同じ値なのに、推定が34倍違います。

なぜ1,522行になるのか

このテーブルは30万行あります。

303,359 × 0.005 = 1,517

総行数の0.5%とほぼ一致します。 つまりヒストグラムを引くのをやめて、決め打ちの割合を使っています。

PostgreSQL は値が分からない条件について、こうした固定値で当てます。

条件の形未知のときの扱い
col >= $1 AND col < $2(範囲)テーブルの約 0.5%
col = ANY($1)(配列との一致)配列の要素数を 10個 と仮定

地図をいくら最新にしても、目的地を教えてもらえなければ平均値で答えるしかないわけです。

そして過小評価すると「取ってくる行は少ないから、繰り返し引いても安い」という判断になり、Nested Loop が選ばれます。実際には大量の行が返るので、繰り返しの回数が積み上がって破滅します。

6回目に何が起きているのか

では、なぜ5回目までは速かったのか。

PostgreSQL はこういう順序で動きます。

  1. 1〜5回目は必ず custom plan を作り、そのコストを記録する
  2. 6回目に generic plan を1つ作り、記録しておいた custom plan の平均コストと比べる
  3. generic の方が安ければ、以降ずっと generic plan を使う

この「5回」は plancache.cCHOOSE_GENERIC_PLAN_THRESHOLD としてハードコードされています。設定では変えられません。

ここで問題になるのが、**比較に使われるのが「実際の実行時間」ではなく「見積もりコスト」**だという点です。

上で見たとおり、generic plan は行数を34分の1に見誤ります。行数が少なければコストも小さく計算されるので、見積もりの上では generic plan が勝ってしまいます。実行してみれば100倍遅いのに、比較の段階ではそれが分かりません。

実際に切り替わる瞬間を見てみます。

PREPARE p AS SELECT count(*) FROM store WHERE org_id = $1;
EXPLAIN (COSTS OFF) EXECUTE p(1);   -- これを6回繰り返す
Filter: (org_id = '1'::bigint)   ← 1回目
Filter: (org_id = '1'::bigint)   ← 2回目
Filter: (org_id = '1'::bigint)   ← 3回目
Filter: (org_id = '1'::bigint)   ← 4回目
Filter: (org_id = '1'::bigint)   ← 5回目
Filter: (org_id = $1)            ← 6回目:値がパラメータのまま

5回目までは '1' という実際の値が埋まっています。6回目だけ $1 のままです。これが generic plan に切り替わった証拠です。

見つけられたのは運が良かった

正直に書くと、危うく見逃すところでした。

最初は docker exec でクエリを7回叩いて「安定して速い」と判断していました。毎回新しい接続を張っていたので、常に1回目を測っていただけでした。

プランキャッシュは接続ごとに持たれます。接続を張り直せばカウントもリセットされるので、何度測っても custom plan のままです。

このままリリースしていたら、こういう症状になっていたはずです。

  • デプロイ直後は速いのに、しばらく使うと急に遅くなる
  • サーバーを再起動すると直る
  • 接続プールが入れ替わるタイミングでまた速くなる
  • 手元で再現しようとしても速い

再現性が低くて原因を掴めない、追いかけるのが一番しんどいタイプです。

プランキャッシュを疑うなら、同じ接続で6回以上測る。 これだけ知っていれば防げます。

plan_cache_mode で止める

対処は plan_cache_modeforce_custom_plan にすることです。「generic plan に切り替えるな、毎回実際の値でプランを作れ」という指示になります。

関数単位で指定できます。

CREATE OR REPLACE FUNCTION aggregate_by_store(...)
RETURNS TABLE (...)
LANGUAGE plpgsql
STABLE
SET plan_cache_mode TO 'force_custom_plan'   -- ← これ
AS $$
BEGIN
  RETURN QUERY
  ...
END;
$$;

効いているか確認します。先ほどと同じ手順を、設定を付けた状態で8回実行しました。

実値が埋まっている(custom plan): 8回
パラメータのまま(generic plan)  : 0回

6回目以降も切り替わらなくなりました。本番のクエリも 1,471ms で安定するようになりました。

毎回プランを作るコストは問題ないのか

force_custom_plan にすると、プラン作成のコストを毎回払うことになります。

とはいえプラン作成は数ミリ秒です。一方で誤ったプランの実行は38秒でした。桁が違うので、この規模のクエリでは考える必要がありません。

判断の目安はこうなります。

状況向いている設定
集計・検索など重いクエリ。パラメータで対象範囲が大きく変わるforce_custom_plan
主キー1件取得のような軽いクエリを高頻度で叩くデフォルトのまま

後者は「値によって最適なプランが変わらない」ので、generic plan の使い回しがそのまま利益になります。generic plan 自体が悪いわけではないという点は押さえておきたいところです。

まとめ

  • パラメータ付きのクエリはプランがキャッシュされる。plpgsql 関数の中の SQL も対象
  • 6回目に generic plan へ切り替わる判定が走る。閾値5はハードコードで変更不可
  • generic plan は値が分からないので統計を引けず、範囲条件を「約0.5%」などの固定値で当てる
  • 統計を最新にしても直らない。統計は地図であって、目的地は別の情報
  • 判定は見積もりコストの比較なので、実行時間が100倍遅くても勝ててしまう
  • 同じ接続で6回以上測らないと気づけない
  • 重いクエリなら SET plan_cache_mode TO 'force_custom_plan' で止める

推定行数と実測行数が桁違いなのに統計は最新、という状況に出会ったら、プランキャッシュを疑ってみてください。

記事を書いた人

古川

エンジニア

古川