PostgreSQL のクエリが遅い原因は、一度も ANALYZE されていないテーブルだった
本番の集計クエリが返ってこない
担当しているシステムで、ある集計クエリが本番でいつまで待っても返ってこなくなりました。
原因は「一度も 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 の統計を確認します。
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 で推定が正しくなった
統計を取り直します。
ANALYZE public.store;
ANALYZE public.transaction;
ANALYZE public.payment;
store の推定が rows=1 から rows=26 になりました。実際32行なのでかなり近い値です。誤差の起点が消えたので、そこから増幅していた441倍のズレもなくなりました。
同じ原因で遅くなっていた集計クエリは、これだけで1秒台まで落ちました。SQL は1文字も変えていません。
発端のクエリについてはもう一手必要でしたが、そちらは統計の話ではないのでここでは触れません。
再発防止
ANALYZE は「今ズレている統計を今すぐ直す」一過性の処置です。放っておけば同じことが起きます。恒久対策として、実行条件の2つのパラメータを調整しました。
-- 小さいマスタは下限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_* は別のパラメータです。
条件を緩めると自動で収集されるのか
「今この瞬間に統計が無いテーブルも、条件を緩めれば自動で収集されるのか」が気になったので実験しました。
-- 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 されません。心当たりがあれば、これを流してみてください。
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;Anycloudでは一緒に働くメンバーを募集しています!
Anycloudは、ユーザーの心を動かす体験を届けることを大切にしています。フルリモート・フルフレックスの環境のもと、ライフスタイルに合わせた働き方を実現しながら挑戦したい方を歓迎します。詳細はこちらをご覧ください。