PostgreSQL | SQLite+MySQL経験者向け、30日で習得するPostgreSQL:設計とパフォーマンス - Day18 パフォーマンスチューニング

SQL PostgreSQL
スポンサーリンク

Day18 後半のゴール

「“どこが遅いか”から一歩進んで、“なぜ遅いか”まで言えるようになる」

前半で「どのノードが重いか」を特定するところまでは来ました。
後半では、そこからもう一歩踏み込んで「なぜそこが重くなっているのか」を分解し、
“原因のタイプ”まで言えるようになることを目指します。


ボトルネックの“原因タイプ”をざっくり分類する

「設計・インデックス・統計・呼び出し方」

まず、ボトルネックの原因をざっくり4つに分けておきます。

設計の問題

テーブルが巨大なのに期間条件がない。
正規化されておらず、同じ情報を何度も読んでいる。

インデックスの問題

必要なインデックスがない。
インデックスはあるが、クエリの書き方のせいで使われていない。

統計情報・プランナーの問題

EXPLAIN の rows と actual rows が大きくズレている。
そのせいで、プランナーが「間違った実行計画」を選んでいる。

呼び出し方の問題

1回のクエリは速いが、同じクエリを何十回も呼んでいる。
N+1 クエリになっていて、アプリ側のループがボトルネック。

Day18 後半では、「特定したボトルネックが、このどれに近いか」を考える癖をつけます。


例題1:設計+インデックスの問題を切り分ける

「“ordersのSeq Scanが重い”をもう一歩分解する」

前半の例をもう一度使います。

Seq Scan on orders o  (actual time=0.050..250.000 rows=800000 loops=1)
  Filter: (ordered_at >= ...)

ここから分かるのは、「orders を全件なめて、期間条件で絞っている」ということでした。
これを原因タイプに落とすと、こう分解できます。

設計側の問い

本当に「全期間の orders」から絞る必要があるのか。
アプリの仕様として、「直近1年だけ見ればいい」などの制約はないか。

もし「直近1年だけでいい」なら、テーブルを年ごとに分ける(パーティション)という設計の話になります。

インデックス側の問い

ordered_at にインデックスはあるか。
あるなら、WHERE ordered_at >= ... でちゃんと使われているか。

EXPLAIN ANALYZE で、Index Scan ではなく Seq Scan になっているなら、
インデックスがないか、統計情報のせいで使われていないかを疑えます。

このように、「Seq Scan が重い」という事実を、
「設計の問題か」「インデックスの問題か」に分けて考えることで、
次の一手が具体的になります。


例題2:インデックスが“使われていない”パターン

「クエリの書き方が原因になっていないかを見る」

インデックスがあるのに使われていない、というパターンもよくあります。

例えば、こういうインデックスがあるとします。

CREATE INDEX idx_orders_ordered_at
ON orders (ordered_at);
SQL

なのに、クエリがこうなっている。

SELECT *
FROM orders
WHERE DATE(ordered_at) = CURRENT_DATE;
SQL

これを EXPLAIN ANALYZE すると、Seq Scan になりがちです。

理由は、「DATE(ordered_at) という関数をかけてしまっているから」です。
インデックスは「生の ordered_at」に対して張られているので、
関数を通した値にはそのまま使えません。

この場合の“原因タイプ”はこう言えます。

「インデックスはあるが、クエリの書き方(関数をかけているせい)で使われていない」

ボトルネック特定の観点では、

インデックスがないのか
インデックスがあるのに使われていないのか

を分けて考えることが重要です。


例題3:統計情報・プランナーの問題

「rowsの見積もりと実測がズレているとき」

EXPLAIN ANALYZE の出力で、こういうのを見たとします。

Index Scan on orders  (cost=0.43..500.00 rows=1000 width=...)
                      (actual time=0.050..300.000 rows=500000 loops=1)

ここで注目したいのは、

コスト側 rows = 1000
actual rows = 500000

という大きなズレです。

これは、「プランナーが orders の行数や分布を過小評価している」可能性を示しています。
その結果、本当は別の実行計画(例えば別のJOIN順序)を選ぶべきなのに、
間違ったプランを選んでいるかもしれません。

この場合の“原因タイプ”はこう言えます。

「統計情報が古く、プランナーの見積もりが外れているせいで、実行計画の選択が最適でなくなっている」

ボトルネック特定の観点では、「rows の見積もりと実測が桁違いにズレていないか」を見るだけでも、
「これは統計の問題かもしれない」という当たりが付けられます。


例題4:呼び出し方の問題(N+1クエリ)

「SQL自体は速いのに、全体として遅い」

ボトルネックは、必ずしも1本のSQLの中にあるとは限りません。
アプリ側の呼び出し方が原因になることも多いです。

例えば、こういうコードを想像してください。

orders = SELECT * FROM orders WHERE user_id = 123;

for each order in orders:
    items = SELECT * FROM order_items WHERE order_id = order.id;
SQL

ここで、order_items を取るクエリは1回あたりは速いかもしれません。
でも、注文が100件あれば、100回同じようなクエリが走ります。

この場合の“原因タイプ”はこう言えます。

「SQL自体は軽いが、呼び出し方がN+1になっていて、全体として遅くなっている」

ボトルネック特定の観点では、

1回のクエリが遅いのか
同じクエリが何度も呼ばれているのか

を分けて見ることが重要です。

EXPLAIN ANALYZE だけでは分からないので、
アプリ側のログやプロファイラもセットで見る必要があります。


ボトルネック特定の“言語化テンプレ”を持つ

「原因タイプ+具体的な場所+証拠」

最後に、「どう言語化するか」の型を1つ持っておくと便利です。

例えば、こういう形です。

「このクエリが遅い主な原因は、orders テーブルに対する Seq Scan が 250ms かかっていて 80万行流れていることであり、EXPLAIN ANALYZE の結果から、WHERE ordered_at >= ... に対応するインデックスが存在しない(または使われていない)ことが分かるので、“インデックス設計の問題”がボトルネックタイプだと判断できる。」

あるいは、

「このAPIが遅い主な原因は、1リクエスト内で SELECT * FROM order_items WHERE order_id = ? が 100回以上呼ばれていることであり、各クエリ自体は 5ms 程度だが、合計すると 500ms 以上になっているので、“呼び出し方(N+1)の問題”がボトルネックタイプだと判断できる。」

Day18 後半では、このレベルで「原因タイプ+具体的な場所+証拠」をセットで言えるようになることを目標にしてください。


Day18 後半のまとめ

ボトルネック特定を一歩進めるには、「どのノードが重いか」だけでなく、「それが設計の問題なのか(期間条件やパーティションの不足)」「インデックスの問題なのか(ない/使われていない)」「統計情報・プランナーの問題なのか(rows の見積もりと実測のズレ)」「呼び出し方の問題なのか(N+1クエリなど)」という“原因タイプ”まで落とし込んで考えることが重要になる。
EXPLAIN ANALYZE で Seq Scan・Sort・Index Scan の time と rows を見ながら、「orders の Seq Scan が250msで80万行流れている」「order_items で Seq Scan+Sort のあとに LIMIT している」「インデックスはあるが関数をかけていて使われていない」「rows の見積もりが1000なのに実際は50万」といった具体的な証拠を拾い、それを「インデックス設計の問題」「統計の問題」などのタイプに結びつけて言語化することが、Day18 後半の着地点になる。

タイトルとURLをコピーしました