N+1 クエリ問題 — 症状、検出、解決まで

読了 4分

「DB が遅い」という報告のかなりの部分は、DB ではなくクエリ回数の問題、その中でも N+1 です。 一覧 1 回(1)と、一覧の各行ごとに 1 回ずつ(N)、合わせて N+1 回のクエリが飛ぶパターンです。クエリ 1 つ 1 つはミリ秒なので開発環境では平気に見え、行数とトラフィックが増えると DB コネクションと往復時間が掛け算で膨らみます。この記事は、できる原理から検出、解決、予防までの全体の流れです。

できる原理: 遅延ロード #

app.py
# ORM のコードは無害に見えます
orders = session.query(Order).limit(100).all()   # クエリ 1 回
for order in orders:
    print(order.user.name)                        # 毎回クエリ 1 回ずつ、計 100 回

ORM のリレーションフィールドは、既定が遅延ロード(lazy loading)の場合が多いです。order.user に触れた瞬間にそのユーザーを SELECT する方式で、コードにはクエリが見えないのにランタイムにはループの回数だけ飛びます。問題の本質は ORM ではなく、「コレクションを回しながらリレーションに触る」というコードの形で、ORM なしの手書きコードでも同じ形なら同じ問題が起きます。

検出: ログと pg_stat_statements #

開発段階の検出はアプリケーション側が速いです。ORM のクエリログ(SQLAlchemy の echo=True、Django の connection.queries など)を有効にして、リクエスト 1 つで飛ぶクエリ数を数えれば十分です。「リクエストあたりのクエリ数」の上限をテストで強制する方法(クエリカウントの assert)は、リグレッション防止までしてくれます。

運用段階の検出は DB 側の累積統計が正確です。pg_stat_statements で calls が異常に大きいクエリを探すことです。

calls 上位の照会
SELECT calls, round(mean_exec_time::numeric, 2) AS mean_ms,
       left(query, 60) AS query
FROM pg_stat_statements
ORDER BY calls DESC LIMIT 10;

WHERE id = $1 の形の 1 件取得が呼び出し数の上位を独占しているなら、典型的な N+1 の痕跡です。平均は 1ms なので mean 基準では見えず、calls と total の基準でだけ見える犯人というのが N+1 検出の要点です。

解決: 3 つの形 #

① JOIN で一発。 一覧とリレーションを 1 つのクエリで取ります。結合の基本の領域で、1:1・N:1 のリレーション(注文→ユーザー)によく合います。ただし 1:N のコレクション(ユーザー→注文たち)を JOIN で引くと行が掛け算で膨らむ問題があり、次の形のほうが良いです。

② IN で一括ロード。 クエリ 2 回で終える形です。一覧を取った後、リレーションを WHERE user_id IN (...) の 1 回でまとめて取り、アプリケーションで貼り合わせます。N+1 が 2 に減り、1:N のリレーションでも行の爆発がありません。

IN 一括ロード
SELECT * FROM orders LIMIT 100;                          -- 1 回目
SELECT * FROM users WHERE id IN (…一覧の user_id たち…);    -- 2 回目

③ ORM の即時ロードオプション。 上の 2 つの形を ORM に代わりに書かせることです。SQLAlchemy なら joinedload(JOIN 型)と selectinload(IN 型)、Django なら select_related と prefetch_related がそれぞれ対応します。SQLAlchemy 講座で扱ったロード戦略が、まさにこの問題の道具箱です。実務感覚では N:1 は JOIN 型、1:N のコレクションは IN 型が基本の組み合わせです。

予防: クエリ数ではなくデータの形で設計する #

解決より重要なのは、また作らない習慣です。原則は 1 つです。「この画面(エンドポイント)が必要とするデータの形」を先に決めて、その形を最小回数のクエリで取るコードを書きます。 ループの中でリレーションフィールドに触るコードが見えたら、そこが警報ポイントです。加えて、リクエストあたりのクエリ数の上限を CI のテストにかけておくと、リファクタリング中に静かに復活する N+1 を機械的に捕まえられます。

ちなみに N+1 を全部捕まえた後にも残る「呼び出し数は正常なのに遅い」問題は別の系統で、そのときは性能診断ルーチンの mean 基準と EXPLAIN に進みます。

まとめ #

  • N+1 は一覧 1 回 + 行ごとに 1 回のクエリパターンです。原因はたいてい ORM の遅延ロードと「ループ内のリレーションアクセス」というコードの形です。
  • 開発段階はクエリログとクエリカウントのテストで、運用段階は pg_stat_statements の calls 上位で検出します。
  • 解法は JOIN(N:1)、IN の一括ロード(1:N)、そしてこれらを代わりに書いてくれる ORM の即時ロードオプションです。
  • mean 基準では見えません。calls・total の基準で見てこそ見える犯人、というのが検出の要点です。
  • 予防は画面が必要とするデータの形を先に決める設計の習慣、そしてリクエストあたりのクエリ数上限のテストです。
X