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

> 400ms で返っていたクエリが6回目だけ40秒になりました。統計情報は最新なのに推定を外す。PostgreSQL がプランをキャッシュする仕組みと、5回目までと6回目で何が変わるのか、plan_cache_mode で止める方法までまとめます。

- 公開日: 2026-07-30
- 著者: 古川
- タグ: パフォーマンス, DB, SQL, Supabase
- URL: https://tech.anycloud.co.jp/articles/postgres-generic-plan-slowdown

---

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

前回、[統計情報が一度も収集されていなかったせいでクエリが遅かった話](/articles/postgres-missing-statistics-slow-query/)を書きました。`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 は「どう実行するか」を決める**実行計画（プラン）**を作ります。インデックスを使うか、結合をどの方式でやるか、といった判断です。

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

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

```sql
-- 値が直接書かれている → 使い回せない
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ヶ月分を取り出すクエリです。

```sql
-- ① 値が分かっている場合
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行なので、ほぼ正確です。

```sql
-- ② 値が未知の場合（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.c`](https://github.com/postgres/postgres/blob/master/src/backend/utils/cache/plancache.c) に `CHOOSE_GENERIC_PLAN_THRESHOLD` としてハードコードされています。設定では変えられません。

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

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

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

```sql
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_mode`](https://www.postgresql.org/docs/current/runtime-config-query.html#GUC-PLAN-CACHE-MODE) を `force_custom_plan` にすることです。「generic plan に切り替えるな、毎回実際の値でプランを作れ」という指示になります。

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

```sql
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'` で止める

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