一覧画面が遅いのに、スロークエリログには何も出ていない。こうしたときによくある原因が N+1 問題です。この記事では、N+1 問題がどういうものか、なぜ気づきにくいのかを SQL のログで説明し、見つけ方と直し方を ORM ごとの例とあわせて紹介します。
N+1 問題とは
N+1 問題は一覧のデータを 1 回のクエリで取ったあと、一覧の 1 件ごとに関連するデータを取りに行くため、クエリが合計で N+1 回発行されてしまう問題です。
たとえば、注文の一覧を 50 件表示し、それぞれに顧客の名前を出す画面を考えます。Rails で素直に書くと次のようになります。
@orders = Order.order(created_at: :desc).limit(50)
<% @orders.each do |order| %>
<%= order.customer.name %>
<% end %>
このときに発行される SQL をログで見ると、こうなっています。
Order Load (0.8ms) SELECT "orders".* FROM "orders" ORDER BY "orders"."created_at" DESC LIMIT $1 [["LIMIT", 50]]
Customer Load (0.3ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = $1 LIMIT $2 [["id", 812], ["LIMIT", 1]]
Customer Load (0.2ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = $1 LIMIT $2 [["id", 37], ["LIMIT", 1]]
Customer Load (0.3ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" = $1 LIMIT $2 [["id", 1290], ["LIMIT", 1]]
...(あと 47 回続く)
注文を取る 1 回と、顧客を 1 人ずつ取る 50 回で、合計 51 回です。一覧の件数を N とすると N+1 回になるので、N+1 問題と呼ばれます。本来は顧客を WHERE id IN (...) でまとめて取れば 2 回、JOIN すれば 1 回で済みます。
N+1 問題が起きる原因
多くの ORM は関連するデータを使う瞬間まで取りに行きません。この動きを遅延読み込み (lazy loading) と呼びます。order.customer と書いた時点で初めて顧客の SELECT が走るので、ループの中で書けば、ループの回数だけ SELECT が走ります。
遅延読み込みそのものは便利な仕組みです。使わない関連まで最初に読み込まずに済むからです。ただ、コードからはクエリが見えないため、N+1 が起きていることに気づきにくくなります。特に次のような場所で起きやすくなります。
- 画面のテンプレートやビューの中のループ
- API のレスポンスを組み立てるシリアライザ
- 関連をたどるメソッドを、ループの中から呼んでいる場合
- 一覧の 1 件ごとに件数や合計を出す処理 (
order.items.countなど)
最後の例のように、関連の件数を数える処理でも、1 件ごとに COUNT のクエリが走れば同じ問題になります。
N+1 問題で遅くなる仕組み
N+1 のクエリは、1 回ずつはとても速いのが普通です。上のログでも 1 回 0.3 ミリ秒程度です。それでも遅くなるのは、クエリごとにアプリと DB のあいだを往復する時間がかかるからです。
仮に 1 回の往復に 1 ミリ秒かかるとすると、50 件の一覧なら 51 ミリ秒ですみます。ところが一覧が 1,000 件になれば 1 秒を超えます。開発環境ではデータが 10 件しかなく気づかなかったのに、本番でデータが増えてから急に遅くなる、という形で表に出ることがよくあります。
DB の側にも負担がかかります。ページを 1 回開くたびに数百のクエリが走ると、アクセスが増えたときに DB の CPU や接続数を圧迫します。接続が足りなくなる問題は Too many connections の記事 で扱っています。
そして、1 回ずつは速いので、スロークエリログにはまず出てきません。遅い画面を調べるときにスロークエリログだけを見ていると、この問題を見落とします。
N+1 問題の見つけ方
開発環境で SQL のログを見る
いちばん確実なのは、画面を 1 回開いたときに発行される SQL をログで見ることです。同じ形の SELECT が id の値だけ変えて何十回も並んでいれば N+1 です。
フレームワークごとの SQL ログの出し方は次のとおりです。
| フレームワーク | SQL ログの出し方 |
|---|---|
| Rails | 開発環境では標準でログに出る |
| Django | DEBUG = True のとき、ロガー django.db.backends を DEBUG にする |
| Laravel | DB::listen() でクエリを受け取ってログに書く |
| SQLAlchemy | create_engine(..., echo=True) にする |
遅延読み込みを禁止して、起きたら知らせる
ログを目で追うのは見落としが出ます。ORM の多くは、遅延読み込みが起きたら例外を出したりログに書いたりする機能を持っています。開発環境とテストで有効にしておくと、N+1 を書いた時点で気づけます。
Rails (6.1 以降) では strict_loading を使います。
# config/environments/development.rb と test.rb
config.active_record.strict_loading_by_default = true
# 例外ではなくログに書くだけにする場合
config.active_record.action_on_strict_loading_violation = :log
Laravel では preventLazyLoading を使います。本番では例外を出さないように、本番以外だけで有効にするのが公式ドキュメントの例です。
// app/Providers/AppServiceProvider.php
public function boot(): void
{
Model::preventLazyLoading(! $this->app->isProduction());
}
SQLAlchemy では関連の定義に lazy="raise" を付けるか、クエリに raiseload() を付けると、遅延読み込みのときに例外になります。
Django には同じ働きの標準機能はありませんが、テストで assertNumQueries を使えば、画面や API が発行するクエリの数を固定できます。
def test_order_list_queries(self):
with self.assertNumQueries(2):
self.client.get("/orders/")
一覧の件数を増やしてもクエリの数が変わらないことを確かめるテストにしておくと、あとから N+1 が入り込んだときに気づけます。Rails には Bullet、Django には nplusone のように、N+1 を見つけて知らせるライブラリもあります。
本番の DB の統計から見つける
すでに本番で動いているアプリの N+1 は、DB の統計から探せます。目印は、1 回あたりの時間が短いのに実行回数がとても多いことです。
PostgreSQL で pg_stat_statements を使っている場合は、次のように出せます。
SELECT calls,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(total_exec_time::numeric, 0) AS total_ms,
left(query, 100) AS query
FROM pg_stat_statements
WHERE mean_exec_time < 5
ORDER BY calls DESC
LIMIT 20;
MySQL では sys スキーマのビューを使います。
SELECT exec_count, avg_latency, total_latency, LEFT(query, 100) AS query
FROM sys.statement_analysis
ORDER BY exec_count DESC
LIMIT 20;
上位に SELECT ... FROM customers WHERE id = $1 (MySQL では id = ?) のような、主キーで 1 行だけ取るクエリが、ほかより桁違いの回数で並んでいれば、N+1 の候補です。ページの表示回数とくらべて実行回数が何十倍もあるなら、1 回の表示で何十回も呼ばれていると考えられます。そのクエリを発行しているコードを探し、ループの中で呼ばれていないかを確かめます。
pg_stat_statements の設定と見方は PostgreSQL で遅いクエリを見つける方法 で、MySQL の sys スキーマは MySQL のスロークエリの記事 で説明しています。
N+1 問題の直し方
直し方は SQL で見ると 2 つに分かれます。JOIN で 1 回にまとめるか、IN で関連をまとめて取って 2 回にするかです。ORM のプリロードの機能はこのどちらかの SQL を発行します。
JOIN で 1 回にまとめる
SELECT o.id, o.total, o.created_at, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.created_at DESC
LIMIT 50;
注文 1 件に顧客 1 人のような関係 (多対一) なら、JOIN で行が増えることもなく、1 回で済みます。
IN でまとめて取る
SELECT id, total, created_at, customer_id
FROM orders
ORDER BY created_at DESC
LIMIT 50;
-- 1 回目の結果に出てきた customer_id をまとめて渡す
SELECT id, name
FROM customers
WHERE id IN (812, 37, 1290, ...);
アプリの側で、2 回目の結果を id で引ける形 (ハッシュやマップ) にしておき、注文ごとに顧客を当てはめます。1 対多の関係 (注文 1 件に明細が複数など) では、JOIN すると親の行が明細の数だけ重なるので、こちらの方法が向いています。
ORM のプリロードを使う
ORM を使っているなら、上の SQL を自分で書かなくても、プリロード (事前読み込み) の機能で同じことができます。
| ORM | IN で別に取る | JOIN で 1 回にまとめる | 自動で選ぶ |
|---|---|---|---|
| Rails (Active Record) | preload(:customer) |
eager_load(:customer) |
includes(:customer) |
| Django | prefetch_related("items") |
select_related("customer") |
なし |
| Laravel (Eloquent) | with('customer') |
なし (join を自分で書く) |
なし |
| SQLAlchemy | selectinload(Order.items) |
joinedload(Order.customer) |
なし |
冒頭の Rails の例なら、includes を足すだけで 51 回が 2 回になります。
@orders = Order.includes(:customer).order(created_at: :desc).limit(50)
Order Load (0.8ms) SELECT "orders".* FROM "orders" ORDER BY "orders"."created_at" DESC LIMIT $1 [["LIMIT", 50]]
Customer Load (0.6ms) SELECT "customers".* FROM "customers" WHERE "customers"."id" IN ($1, $2, $3, ...)
Django では、外部キーや一対一の関連は select_related (JOIN)、逆向きの外部キーや多対多は prefetch_related (別のクエリ) を使います。select_related は「多」の側の関連には使えません。
orders = (
Order.objects
.select_related("customer") # 注文 → 顧客 (多対一)
.prefetch_related("items") # 注文 → 明細 (一対多)
.order_by("-created_at")[:50]
)
SQLAlchemy の公式ドキュメントでは、一対多や多対多のコレクションには selectinload、多対一には joinedload が勧められています。
Laravel ではネストした関連も with('customer.company') のようにまとめて指定できます。Laravel 12 にはアクセスした関連を自動でまとめて読み込む automaticallyEagerLoadRelationships もありますが、2026 年 11 月時点の公式ドキュメントではベータ版の扱いです。
件数や合計だけが要るときは集計する
一覧に「明細の件数」だけを出したいなら、明細そのものを読み込む必要はありません。GROUP BY で集計すれば 1 回で済みます。
SELECT o.id, o.total, count(i.id) AS item_count
FROM orders o
LEFT JOIN order_items i ON i.order_id = o.id
WHERE o.id IN (...)
GROUP BY o.id, o.total;
ORM にも同じための機能があります。Laravel の withCount('items')、Django の annotate(item_count=Count("items")) などです。
直すときの注意点
プリロードを足せば必ず速くなるわけではありません。直すときは次の点に気をつけてください。
JOIN で一対多の関連をまとめると、親の行が子の数だけ重なって返ります。自分で書いた SQL に LIMIT 50 を付けると、「親 50 件」ではなく「重なった行 50 行」になってしまいます。ORM によっては内部で補正しますが、そのぶん SQL が複雑になります。ページ分けをする一覧で一対多をまとめるときは、IN で別に取る方法 (Rails の preload、Django の prefetch_related、SQLAlchemy の selectinload) のほうが扱いやすくなります。
IN に渡す値が数万件になると、SQL が長くなりすぎてドライバや DB の上限にかかることがあります。大量のデータをまとめて処理するバッチでは、1,000 件ずつのように区切って取ります。
使わない関連まで読み込むと、かえって遅くなります。画面で実際に表示する関連だけを指定し、必要なら取り出す列も絞ってください。
N+1 問題を放置してよい場合
N+1 がすべて問題になるわけではありません。一覧の件数が数件で、それ以上増えないことが決まっているなら、2 回のクエリと 6 回のクエリの差はほとんど体感できません。管理画面で 1 日に数回しか開かない画面なら、直すより他の作業を優先してよい場合もあります。
判断の目安は、件数が増える見込みがあるかと、その画面がどれだけ頻繁に開かれるかです。データが増える一覧や、アクセスの多い画面・API は、早めに直しておくと後で困りません。
この確認を続けるには
N+1 は新しい画面や API を作るたびに入り込む可能性があります。続けて防ぐには、開発環境とテストで遅延読み込みを禁止する設定を入れ、クエリの数を確かめるテストを用意しておくのが効果的です。本番では pg_stat_statements や sys.statement_analysis で実行回数の多いクエリを定期的に確かめ、急に回数が増えたものがないかを見ます。cron で上位を集計してチームのチャットに送る方法もあります。
AI に任せる方法もあります。Scandock は AI がデータベースを監視するサービスで、接続文字列を登録すると、AI がテーブルの構成と実データを読んで監視する項目を決めます。注文数や決済の成否のような事業の数字を、固定の閾値ではなく推移や過去の経緯を踏まえて判断し、異常があれば読み取り専用のクエリで関連するデータを調べて、原因と対処案をつけて通知します。PostgreSQL、MySQL、SQL Server などに対応しています。
参考
- Rails Guides: Active Record Query Interface (Eager Loading Associations, Strict Loading)
- Django documentation: QuerySet API reference (select_related, prefetch_related)
- Django documentation: Testing tools (assertNumQueries)
- Laravel 12.x: Eloquent Relationships (Eager Loading, Preventing Lazy Loading)
- SQLAlchemy 2.0 Documentation: Relationship Loading Techniques
- PostgreSQL 18 Documentation: pg_stat_statements
- MySQL 8.4 Reference Manual: The statement_analysis and x$statement_analysis Views