# PostgreSQL のクエリが遅い原因は、一度も ANALYZE されていないテーブルだった

> 推定58行に対して実測25,586行。EXPLAIN の乖離を追ったら、作成以来一度も統計情報が収集されていないテーブルが見つかりました。統計情報とコスト計算の基礎、autovacuum が analyze を実行しない2つの条件、再発防止の設定までまとめます。

- 公開日: 2026-07-27
- 著者: 古川
- タグ: パフォーマンス, DB, SQL, Supabase
- URL: https://tech.anycloud.co.jp/articles/postgres-missing-statistics-slow-query

---

## 本番の集計クエリが返ってこない

担当しているシステムで、ある集計クエリが本番でいつまで待っても返ってこなくなりました。

原因は「**一度も `ANALYZE` されていないテーブル**」でした。統計情報が空だったせいでプランナが見積もりを441倍外し、間違った実行計画を選び続けていました。

先に「重いクエリなんだから書き方が悪いんだろう」と考えて何パターンも書き換えたのですが、全部無駄でした。

統計情報やコスト計算は普段まったく意識しない部分なので、**前提から説明します**。結論だけ知りたい方は最後の「反省」まで飛んでも大丈夫です。

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

```
org         組織マスタ（数十行）
store       店舗マスタ（40行）
transaction 取引履歴（42万行）
payment     会計（39万行）
```

`org` > `store` > `transaction` という、よくあるマルチテナント構成です。

## 前提: 同じ SQL でも実行方法は何通りもある

「店舗ごとの取引を集計する」ような SQL を書くと、`store` と `transaction` を結合することになります。このとき **PostgreSQL は結合のやり方を選べます**。代表的なものが2つあります。

### Nested Loop（入れ子ループ）

片方のテーブルから1行取り出し、そのたびにもう片方から一致する行を引きます。これを行数ぶん繰り返します。

```
store の1行目（店舗A） → transaction から店舗Aの取引を引く
store の2行目（店舗B） → transaction から店舗Bの取引を引く
store の3行目（店舗C） → transaction から店舗Cの取引を引く
...
```

繰り返しの**起点になる側を「外側」、毎回引かれる側を「内側」**と呼びます。上の例では `store` が外側、`transaction` が内側です。

ポイントは、**内側を引く回数が外側の行数と同じになる**ことです。

| 外側の行数 | 内側を引く回数 |
| --- | --- |
| 32行 | 32回 → 速い |
| 45,745行 | 45,745回 → 破滅的 |

**外側が少ないときだけ速い**やり方です。

### Hash Join

先に片方をまとめて**ハッシュ表**（値を渡すと一発で対応する行が返ってくる索引のようなもの）に作ります。そのうえで、もう片方を1回スキャンして突き合わせます。

```
1. transaction 全体をハッシュ表にする（ここは1回だけ）
2. store を上から1回スキャンして、ハッシュ表に問い合わせる
```

ハッシュ表を作る手間はかかりますが、**何行あっても作るのは1回**です。大量のデータならこちらが圧倒的に速くなります。

### 選択の分かれ目

つまり **「外側が何行になるか」で有利なやり方が変わります**。数行なら Nested Loop、大量なら Hash Join です。

PostgreSQL はどちらが速いかを**実行前に見積もって**決めます。このとき使う指標が **コスト**です。お金ではなく「ディスクを何ページ読むか、何行処理するか」を係数付きで足し合わせた**相対的な重さの数値**で、単位はありません。コストが小さいプランが選ばれます。

そしてコスト計算の入力になるのが **統計情報**です。

## 前提: 統計情報とは何か

`ANALYZE` を実行すると、テーブルの中身を統計的に要約したものが保存されます。データそのものはコピーしません。保存されるのは主にこういう情報です。

| 統計 | 意味 |
| --- | --- |
| 行数 | だいたい何行あるか |
| `n_distinct` | 異なる値がいくつあるか |
| 頻出値（MCV） | よく出てくる値とその割合 |
| ヒストグラム | 値がどう散らばっているか |

これがあると `WHERE org_id = 5` で何行返るかを事前に見積もれます。`n_distinct` が10なら「全体の10分の1くらい」、頻出値リストに `5` が「全体の60%」と載っていればそれを使う、という具合です。

テーブル全体を読むのではなく**ランダムサンプリング**するので、大きいテーブルでも数秒で終わります。

**統計が間違っていると見積もりが外れ、結合方法の選択を間違えます。** ここが今回の本題です。

## EXPLAIN をちゃんと読む

行き詰まったので `EXPLAIN (ANALYZE, BUFFERS)` を取って、上から順に読みました。

```
CTE Scan on transactions  (cost=0.00..1.16 rows=58 width=16)
                          (actual time=0.000..2.756 rows=25586 loops=45745)
```

`EXPLAIN ANALYZE` は各ノードについて**推定と実測を並べて**出してくれます。`rows=58` が推定、`actual ... rows=25586` が実測です。

推定 **58行**に対して実測 **25,586行**。**441倍**の誤りでした。

さらに `loops=45745` が付いています。**このノードが45,745回繰り返し実行された**という意味です。Nested Loop の内側に置かれたためです。25,586 × 45,745 で約11.7億行。返ってこない正体はこれでした。

プランナは悪くありません。「外側は数行しかない」と見積もったから Nested Loop を選んだのであって、**その見積もりが間違っていた**だけです。

問題は「なぜ58行だと思ったのか」です。プランを遡ると起点が見つかりました。

```
Index Only Scan using idx_store_org_id_id on store s
  (cost=0.14..2.40 rows=1 width=8) (actual time=0.045..0.116 rows=32 loops=1)
  Index Cond: (org_id = 2)
```

`store` テーブルを **rows=1**（実際32行）と推定しています。ここから誤差が増幅していました。

```
推定 : store 1行 × transaction 61行 ≒ 58行
実際 : store 32行 × transaction 803行 = 25,586行
```

**最初の小さなズレが、結合を重ねるたびに掛け算で広がっていく**わけです。40行しかないマスタテーブルの見積もりミスが、最終的に441倍になりました。

## 統計が空だった

`store` の統計を確認します。

```sql
SELECT relname, n_live_tup, n_mod_since_analyze, last_analyze, last_autoanalyze
FROM pg_stat_user_tables WHERE relname = 'store';
```

```
 relname | n_live_tup | n_mod_since_analyze | last_analyze | last_autoanalyze
---------+------------+---------------------+--------------+------------------
 store   |          6 |                  24 | null         | null
```

`last_analyze` も `last_autoanalyze` も **null**。手動でも自動でも、**テーブル作成以来一度も統計が取られていませんでした**。統計上の行数も6行（実際は40行）です。

念のため `pg_stats` も見ましたが `store` の行は0件。ヒストグラムも頻出値リストも存在しませんでした。統計が「古い」のではなく「無い」状態です。

## なぜ analyze されなかったのか

PostgreSQL には autovacuum という仕組みがあります。名前から VACUUM（削除済み行の領域回収）だけを想像しがちですが、**この仕組みは ANALYZE も担当しています**。

ただし**いつでも実行するわけではありません**。「前回の統計収集から、テーブルがある程度変更されたら実行する」という条件になっています。

その「ある程度」を決めているパラメータが2つあります。

| パラメータ | デフォルト | 意味 |
| --- | --- | --- |
| `autovacuum_analyze_threshold` | 50 | 最低これだけ変更されたら、という**下限の行数** |
| `autovacuum_analyze_scale_factor` | 0.1 | テーブルの行数に対する**割合**（0.1 = 10%） |

この2つを足したものが実行の目安になります。

```
変更された行数 > 50 + 0.1 × テーブルの行数
```

「変更された行数」は `INSERT` / `UPDATE` / `DELETE` された行の累計で、統計を収集するとリセットされます。`pg_stat_user_tables` の `n_mod_since_analyze` で見られます。

### 小さいテーブルは下限50に届かない

`store` は40行しかありません。条件に当てはめるとこうなります。

```
必要な変更行数 = 50 + 0.1 × 40 = 54行
実際の変更行数 = 24行
```

**40行のテーブルが54行も変更されることは、まず起きません。** 初期に40行を投入した時点でも40件で足りていません。その後の店舗追加は年に数件なので、**永久に条件を満たさないまま放置され続けた**わけです。

行数が少ないテーブルでは `0.1 ×` の部分がほぼゼロになるので、**下限の50がそのまま壁になります**。マスタテーブルは行数が少なく更新も少ないので、まさに条件が揃います。

### 大きいテーブルは割合が緩すぎる

逆方向の穴も踏んでいました。`payment` は3ヶ月間 analyze されていません。

```
必要な変更行数 = 50 + 0.1 × 395,265 = 39,576行
実際の変更行数 = 32,708行
```

39万行のテーブルだと、**約4万行も変更されないと実行されません**。1日240件のペースなら半年近くかかります。こちらは下限50が無視できるほど小さく、**割合0.1が緩すぎる**ことが問題です。

**小さい方は下限、大きい方は割合。両側で引っかかっていました。**

## ANALYZE で推定が正しくなった

統計を取り直します。

```sql
ANALYZE public.store;
ANALYZE public.transaction;
ANALYZE public.payment;
```

`store` の推定が `rows=1` から `rows=26` になりました。実際32行なのでかなり近い値です。誤差の起点が消えたので、そこから増幅していた441倍のズレもなくなりました。

同じ原因で遅くなっていた集計クエリは、**これだけで1秒台まで落ちました**。SQL は1文字も変えていません。

発端のクエリについてはもう一手必要でしたが、そちらは統計の話ではないのでここでは触れません。

## 再発防止

`ANALYZE` は「今ズレている統計を今すぐ直す」一過性の処置です。放っておけば同じことが起きます。恒久対策として、実行条件の2つのパラメータを調整しました。

```sql
-- 小さいマスタは下限50に到達しないので、下限そのものを下げる
ALTER TABLE public.org   SET (autovacuum_analyze_threshold = 5);
ALTER TABLE public.store SET (autovacuum_analyze_threshold = 5);

-- 大きいテーブルは割合0.1だと数ヶ月放置されるので、割合を下げる
ALTER TABLE public.payment     SET (autovacuum_analyze_scale_factor = 0.02);
ALTER TABLE public.transaction SET (autovacuum_analyze_scale_factor = 0.01);
```

実測の更新ペースから逆算すると、実行される間隔はこう変わります。

| テーブル | 更新ペース | 変更前 | 変更後 |
| --- | --- | --- | --- |
| `store` | ほぼ0 | 54件（一度も実行されず） | 9件 |
| `payment` | 約240件/日 | 約166日 | 約33日 |
| `transaction` | 約690件/日 | 約61日 | 約6日 |

`payment` の「約166日」は、実際に3ヶ月間 analyze されていなかった事実とおおむね一致します。計算が実態と合っていたので、この見積もりは信頼できると判断しました。

`transaction` だけ他より短くしています。追記が主で日付範囲で絞られるテーブルなので、統計のヒストグラムが古いと**「直近のデータは存在しない」と見なされて当月の行数を過小評価する**からです。今回と同じ失敗パターンなので余裕を取りました。`ANALYZE` はサンプリングなのでコストはほぼ変わりません。

なお変更したのは analyze 系のパラメータだけなので、**VACUUM（領域回収）の挙動には影響しません**。`autovacuum_vacuum_*` は別のパラメータです。

### 条件を緩めると自動で収集されるのか

「今この瞬間に統計が無いテーブルも、条件を緩めれば自動で収集されるのか」が気になったので実験しました。

```sql
-- A: デフォルト設定
CREATE TABLE t_default(id int);
-- B: 下限を下げた設定
CREATE TABLE t_tuned(id int) WITH (autovacuum_analyze_threshold = 5);

-- 両方に同じく40行入れる
INSERT INTO t_default SELECT generate_series(1,40);
INSERT INTO t_tuned   SELECT generate_series(1,40);
```

1分ほど待って確認します。

```
 relname   | n_mod_since_analyze | last_autoanalyze
-----------+---------------------+-------------------------------
 t_default |                  40 | null                          ← 実行されず
 t_tuned   |                   0 | 2026-07-29 05:45:26+00        ← 自動で実行された
```

**`INSERT` も更新行数に数えられる**ので、データ投入済みのテーブルなら設定変更後に自動で走ります。デフォルトでは40件が必要な54件に足りず実行されない、という今回の症状もそのまま再現しました。

ただし注意点があります。**条件は「変更された行数」なので、まったく変更されないテーブルは条件をいくら緩めても永久に実行されません。**

もっとも、変更されないなら統計も古くなりません。**問題は「古くなること」ではなく「一度も取られていないこと」だった**わけです。一度取ってしまえば、静的なテーブルの統計は正しいままです。

## 反省

`EXPLAIN` に441倍の乖離が最初から出ていました。それを見ていながら、クエリの書き方ばかり疑って統計を確認していませんでした。

**推定行数と実測行数が桁違いなら、まず統計を見る。** これだけです。

統計情報は普段まったく意識しない部分ですが、**一度も収集されていないテーブルが本番に存在し得る**というのは覚えておく価値がありました。とくにマスタテーブルは行数が少なく更新も少ないので、デフォルト設定のままだと構造的に analyze されません。心当たりがあれば、これを流してみてください。

```sql
SELECT relname, n_live_tup, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE last_analyze IS NULL AND last_autoanalyze IS NULL
ORDER BY n_live_tup DESC;
```
