- 技術 💻
N+1問題とは?原因・見つけ方・解決方法を実測比較【ORM別】
N+1問題とは、一覧を1本のSQLで取ったあと、各行の関連データを1件ずつ取りにいくため、SQLが「1+N本」発行される性能問題です。 投稿100件の一覧で著者名を出すと、投稿の取得1本+著者の取得100本で、合計101本になります。直し方は「関連をまとめて取る」こと(JOIN か IN句)で、本数は件数に関係なく1〜3本になります。
この記事では、PostgreSQL 16 に同じデータを用意し、N+1 が起きる書き方と直した書き方で、発行されたSQLの本数と時間を、件数10・100・1000で実測しました。結果の要約は次のとおりです(Python の SQLAlchemy 2.1、投稿に著者とコメントを付けて表示する処理)。
| 件数N | N+1:SQLの本数 | N+1:時間(往復0.02ms / 2.9ms) | 直した後:本数 | 直した後:時間(同左) |
|---|---|---|---|---|
| 10 | 21本 | 12.3ms / 89.3ms | 2本 | 2.8ms / 12.5ms |
| 100 | 201本 | 97.5ms / 725.2ms | 2本 | 8.2ms / 18.2ms |
| 1000 | 2,001本 | 1,039.5ms / 7,521.3ms | 3本 | 71.7ms / 100.8ms |
「往復」はアプリとDBの間を1回やり取りする時間です。同じマシン内(約0.02ms)と、遅延を足して別サーバー程度にした接続(約2.9ms)の両方で測りました。N+1 の時間は件数と往復時間の掛け算で伸び、1000件・往復2.9msでは7.5秒かかりました。 直した書き方は同じ条件で0.1秒でした。
この記事と関連記事の分担
この記事は N+1問題の仕組み・見つけ方・各ORMの直し方をまとめた入口です。フレームワークごとの詳しい手順は次の記事に分けています。
| 知りたいこと | 読む記事 |
|---|---|
| Spring Data JPA での4手法のコード全文 | JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文 |
| Laravel の with・load の使い方 | Laravel Eloquentの使い方|リレーション・N+1対策 |
| SQLAlchemy のリレーション定義 | SQLAlchemy 2.0の使い方|CRUD・リレーションを動くコードで解説 |
| 遅いSQLの探し方(pg_stat_statements) | pg_stat_statementsで遅いSQLを探す|設定と分析クエリ集 |
確認環境:PostgreSQL 16.15(Ubuntu 24.04、apt のパッケージ)、Python 3.11 + SQLAlchemy 2.1.4 + psycopg 3.3.6、Django 5.2.18 + django-debug-toolbar 8.0.0、Node.js 22.22.2 + Drizzle ORM 0.45.4 + node-postgres 8.23.1 + DataLoader 2.2.3、Java 25(OpenJDK 25)+ PostgreSQL JDBC 42.7.14 で、この記事の計測とログをすべて実行しました。時間は2 vCPU の Linux VM で7回測った中央値です。往復2.9msの条件は、DBとの間に遅延を足す自作の TCP プロキシを挟んで作りました。Hibernate(7.4.13)はソースコードと公式ドキュメント、Rails 8.1・Laravel 13.x・MyBatis・Prisma ORM 7 は公式ドキュメントで確かめました(確認日 2026年10月16日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「N+1問題とは?原因・見つけ方・解決方法を実測比較【ORM別】」
N+1問題とは:SQLが「1+N本」になる仕組み
ブログの投稿(posts)と著者(users)、コメント(comments)の3つのテーブルで、投稿一覧に著者名とコメント数を表示する例です。
posts = session.scalars(select(Post).order_by(Post.id).limit(3)).all() # 1本
for p in posts:
print(p.title, p.author.name) # ここで著者を1件ずつ取得している
このコードを SQLAlchemy で実行し、発行されたSQLをログ(echo=True)で出すと次のようになりました。
SELECT posts.id, posts.user_id, posts.title
FROM posts ORDER BY posts.id
LIMIT %(param_1)s::INTEGER
[generated in 0.00405s] {'param_1': 3}
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[generated in 0.00018s] {'pk_1': 1}
post1 user1
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[cached since 0.003273s ago] {'pk_1': 2}
post2 user2
SELECT users.id, users.name
FROM users
WHERE users.id = %(pk_1)s::INTEGER
[cached since 0.00455s ago] {'pk_1': 3}
post3 user3
コードにはループと p.author という属性の参照しか書いていないのに、同じ形のSQLが、IDだけ変えて3回発行されています。ORM(データベースの行をオブジェクトとして扱うライブラリ)が、関連データに初めて触れた時点で自動的にSQLを発行する「遅延読み込み(Lazy Loading)」をしているためです。
| 用語 | 意味 |
|---|---|
| 1 | 一覧を取るSQL(最初の1本) |
| N | 一覧の行ごとに関連を取るSQL(行数と同じ本数) |
| 遅延読み込み(Lazy Loading) | 関連に触れたときに初めてSQLを発行する読み込み方。多くのORMの既定 |
| 先読み(Eager Loading) | 一覧を取るときに、関連もまとめて取る読み込み方。N+1の直し方 |
関連が「著者」と「コメント」の2つあると、本数は 1+N+N(N=100 なら201本)に増えます。冒頭の表の201本・2,001本はこのケースです。
N+1問題で遅くなる理由:件数10・100・1000で実測
計測の条件
| 項目 | 内容 |
|---|---|
| データ | users 1,000行、posts 1,000行(投稿ごとに著者が違う)、comments 5,000行(1投稿5件)、post_tags 3,000行 |
| 処理 | 投稿を id 順に N 件取り、著者名とコメント数を読む(JDBC は著者名だけ) |
| 件数 | N=10・100・1000 |
| 回数 | 1回空打ちしてから7回測り、中央値 |
| 接続 | 直接(1往復 約0.02〜0.08ms)と、遅延を足した接続(1往復 約2.9ms) |
往復時間は、SELECT 1 を500回実行した平均です(直接 0.022〜0.079ms、遅延あり 2.862〜2.895ms)。
直接接続(往復 約0.02〜0.08ms)の結果
| ライブラリ・書き方 | N=10 | N=100 | N=1000 |
|---|---|---|---|
| SQLAlchemy:遅延読み込み(N+1) | 21本・12.3ms | 201本・97.5ms | 2,001本・1,039.5ms |
| SQLAlchemy:joinedload+selectinload | 2本・2.8ms | 2本・8.2ms | 3本・71.7ms |
| Django:遅延読み込み(N+1) | 21本・17.8ms | 201本・108.5ms | 2,001本・1,357.8ms |
| Django:select_related+prefetch_related | 2本・3.3ms | 2本・12.8ms | 2本・113.4ms |
| Drizzle:ループ内で select(N+1) | 21本・14.4ms | 201本・130.5ms | 2,001本・448.8ms |
| Drizzle:IN句で一括取得 | 3本・1.4ms | 3本・4.2ms | 3本・19.6ms |
| Drizzle:リレーショナルクエリ(with) | 1本・1.3ms | 1本・3.2ms | 1本・16.8ms |
| JDBC:ループ内で SELECT(N+1) | 11本・1.0ms | 101本・4.8ms | 1,001本・21.8ms |
| JDBC:= ANY(配列) で一括取得 | 2本・0.2ms | 2本・0.5ms | 2本・2.3ms |
遅延を足した接続(往復 約2.9ms)の結果
| ライブラリ・書き方 | N=10 | N=100 | N=1000 |
|---|---|---|---|
| SQLAlchemy:遅延読み込み(N+1) | 21本・89.3ms | 201本・725.2ms | 2,001本・7,521.3ms |
| SQLAlchemy:joinedload+selectinload | 2本・12.5ms | 2本・18.2ms | 3本・100.8ms |
| Django:遅延読み込み(N+1) | 21本・72.2ms | 201本・689.5ms | 2,001本・7,002.1ms |
| Django:select_related+prefetch_related | 2本・9.2ms | 2本・15.1ms | 2本・135.6ms |
| Drizzle:ループ内で select(N+1) | 21本・74.4ms | 201本・658.6ms | 2,001本・6,418.6ms |
| Drizzle:IN句で一括取得 | 3本・11.5ms | 3本・15.7ms | 3本・48.4ms |
| Drizzle:リレーショナルクエリ(with) | 1本・4.7ms | 1本・10.2ms | 1本・24.8ms |
| JDBC:ループ内で SELECT(N+1) | 11本・33.3ms | 101本・285.1ms | 1,001本・2,924.8ms |
| JDBC:= ANY(配列) で一括取得 | 2本・5.9ms | 2本・6.3ms | 2本・8.4ms |
結果から分かることは次の3つです。
- N+1 の時間は「本数 × 1往復の時間」でほぼ決まります。 JDBC の 1,001本・2,924.8ms は、1,001 × 2.9ms ≒ 2.9秒とほぼ一致します。
- 同じマシン内の開発環境では目立ちません。 JDBC の N+1 は1000件でも21.8msでした。DBが別サーバーになる本番で、急に遅くなる理由がここにあります。
- ORM は1本ごとの処理(オブジェクトへの変換など)も重く、 直接接続でも1000件で1秒前後かかりました。
DBの中では速い:pg_stat_statements で見た内訳
SQLAlchemy の N+1(N=100、201本)を1回実行した後、PostgreSQL の pg_stat_statements(SQLごとの実行回数と時間を集計する拡張)を見ると次のとおりでした。
calls | rows | total_ms | mean_ms | query
-------+------+----------+---------+--------------------------------------------------------------
100 | 100 | 1.01 | 0.010 | SELECT users.id, users.name FROM users WHERE users.id = $1::
100 | 500 | 2.30 | 0.023 | SELECT comments.id, comments.post_id, comments.body FROM com
DBの中での実行時間は、200本を合わせても約3.3msでした。一方、アプリ側で測った時間は約100msです。差のほとんどは往復とORMの処理です。1本1本は速いため、一定時間を超えたSQLだけを記録するスロークエリログには、N+1 はほとんど出ません。 見つけるには「本数(calls)」を見ます。
N+1問題の見つけ方
| 方法 | 見るもの | 向いている場面 |
|---|---|---|
| SQLログ | 同じ形のSQLが、IDだけ変えて並んでいないか | 開発中に1画面ずつ確かめる |
| pg_stat_statements | calls が極端に多いSQL |
本番・検証環境のDB全体から探す |
| Django Debug Toolbar | 「〇 similar queries」の表示 | Django の画面ごとに確かめる |
| Hibernate の統計 | セッションごとの JDBC 文の本数 | Spring Boot・JPA |
| 遅延読み込みを例外にする | 例外が出た箇所 | 開発中・テストで入り込みを止める |
| テストで本数を固定する | 本数が変わるとテストが失敗する | 後から入り込むのを防ぐ |
SQLログを出す設定
| ORM・フレームワーク | 設定 |
|---|---|
| SQLAlchemy | create_engine(url, echo=True)、またはロガー sqlalchemy.engine を INFO |
| Django | ロガー django.db.backends を DEBUG(DEBUG = True のとき)、または connection.queries |
| Spring Boot(Hibernate) | spring.jpa.show-sql=true、またはロガー org.hibernate.SQL を DEBUG |
| Rails | development 環境では log/development.log に出る |
| Laravel | DB::listen() でクエリを受け取る、または Laravel Debugbar |
| Drizzle | drizzle(pool, { logger: true }) |
| PostgreSQL 側 | log_min_duration_statement = 0(全SQLを記録。本数が多いので一時的に) |
pg_stat_statements で本数の多いSQLを探す
pg_stat_statements は、postgresql.conf の shared_preload_libraries に追加して再起動し、DBで CREATE EXTENSION pg_stat_statements; を実行すると使えます。上の出力は次のSQLで出しました。
SELECT calls,
rows,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 3) AS mean_ms,
left(regexp_replace(query, '\s+', ' ', 'g'), 60) AS query
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
ORDER BY calls DESC
LIMIT 5;
mean_ms が小さいのに calls だけが多く、WHERE id = $1 の形をしたSQLが N+1 の候補です。集計を0に戻すには SELECT pg_stat_statements_reset(); を実行します。設定と分析クエリは、関連記事「pg_stat_statementsで遅いSQLを探す|設定と分析クエリ集」で扱っています。
Django Debug Toolbar の表示
投稿10件の一覧ページ(著者名を表示)を django-debug-toolbar 8.0.0 を入れて開くと、SQL パネルに次のように出ました。
SQL 11 queries in 6.28ms
default 6.28 ms (11 queries including 10 similar )
...
SELECT ••• FROM "users" WHERE "users"."id" = 1 LIMIT 21
10 similar queries.
「10 similar queries」が N+1 の印です。同じ形のSQLが10本あることを示しています。
Hibernate の統計(Spring Boot・JPA)
Hibernate 7.4.13 のソースでは、ロガー org.hibernate.session.metrics を DEBUG にすると、セッション(多くの場合は1リクエスト)が終わるたびに次の集計を記録します(%s に数値が入ります)。
Logging session metrics:
%s ns acquiring %s JDBC connections
%s ns releasing %s JDBC connections
%s ns preparing %s JDBC statements
%s ns executing %s JDBC statements
...
# application.properties(Spring Boot)
# セッションごとの集計(JDBC 文の本数など)をログに出す
logging.level.org.hibernate.session.metrics=DEBUG
# SessionFactory 全体の統計(Statistics)を集める
spring.jpa.properties.hibernate.generate_statistics=true
1画面で「executing 101 JDBC statements」のように件数に比例した本数が出たら、N+1 を疑います。generate_statistics で集めた統計は Statistics#getPrepareStatementCount() などで読めるため、テストで本数を確かめるときにも使えます。
遅延読み込みを例外にする
開発環境で「遅延読み込みが起きたら例外にする」設定を入れると、N+1 をレビューより前に見つけられます。
| ORM | 設定 | 起きる例外 |
|---|---|---|
| SQLAlchemy | .options(raiseload("*")) または relationship(lazy="raise") |
InvalidRequestError |
| Rails | Book.strict_loading など |
ActiveRecord::StrictLoadingViolationError |
| Laravel | Model::preventLazyLoading(! $this->app->isProduction()); |
Illuminate\Database\LazyLoadingViolationException |
SQLAlchemy で raiseload("*") を付けて取得し、著者に触れたときの例外は次のとおりでした。
InvalidRequestError : 'Post.author' is not available due to lazy='raise'
Laravel は Model::handleLazyLoadingViolationUsing(...) で、例外の代わりにログへ記録することもできます(Laravel 13.x のドキュメント)。
テストでSQLの本数を固定する(Django)
from django.test import TestCase
class PostListTest(TestCase):
def test_list(self):
with self.assertNumQueries(2):
for p in Post.objects.order_by("id")[:3]:
p.user.name
N+1 のまま実行すると、次のように失敗し、発行されたSQLの一覧も表示されます。
AssertionError: 4 != 2 : 4 queries executed, 2 expected
Captured queries were:
1. SELECT "posts"."id", "posts"."user_id", "posts"."title" FROM "posts" ORDER BY "posts"."id" ASC LIMIT 3
2. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 1 LIMIT 21
3. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 2 LIMIT 21
4. SELECT "users"."id", "users"."name" FROM "users" WHERE "users"."id" = 3 LIMIT 21
一覧画面のテストで本数を固定しておくと、後から誰かが N+1 を持ち込んだときにテストが失敗します。
N+1問題の解決方法:JOIN・IN句・バッチ・DataLoaderの比較
直し方は「関連をまとめて取る」ことで、まとめ方は大きく次の5つです。
| 方法 | SQLの本数 | 向いている関連 | ページングとの相性 | 注意点 |
|---|---|---|---|---|
| JOIN で一緒に取る(JOIN FETCH・joinedload・select_related・eager_load) | 1本 | 多対1・1対1 | 多対1なら問題なし。1対多だと LIMIT がずれる | 1対多を JOIN すると行が増える |
| EntityGraph(JPA) | 1本(JOIN) | 多対1・1対1 | JOIN FETCH と同じ | メソッドに注釈を付けるだけで使える |
| IN句で一括取得(selectinload・prefetch_related・preload・with) | 関連1つにつき1本 | 1対多・多対多 | 親を先にページングできる | IN に入る件数の上限(後述) |
| バッチ取得(@BatchSize・default_batch_fetch_size) | 1+N÷バッチサイズ | 1対多・多対1 | 親を先にページングできる | コードを変えずに効くが、本数は0にならない |
| DataLoader(GraphQL など) | まとめて1本 | 項目ごとにデータを取る作り | 影響なし | リクエストごとに作り直す |
目安は「多対1・1対1は JOIN、1対多・多対多は IN句」です。上の実測でも、著者(多対1)は JOIN、コメント(1対多)は IN句にした組み合わせが、どの件数でも安定していました。
フレームワーク別の解決方法
SQLAlchemy 2.1(Python)
計測に使ったコードを、そのまま動く形で載せます。テーブルは冒頭の posts・users・comments です。
import time
from sqlalchemy import create_engine, event, select, ForeignKey
from sqlalchemy.orm import (DeclarativeBase, Mapped, mapped_column, relationship,
Session, joinedload, selectinload)
class Base(DeclarativeBase):
pass
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
name: Mapped[str]
class Post(Base):
__tablename__ = "posts"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
title: Mapped[str]
author: Mapped[User] = relationship()
comments: Mapped[list["Comment"]] = relationship(order_by="Comment.id")
class Comment(Base):
__tablename__ = "comments"
id: Mapped[int] = mapped_column(primary_key=True)
post_id: Mapped[int] = mapped_column(ForeignKey("posts.id"))
body: Mapped[str]
engine = create_engine("postgresql+psycopg://user:pass@127.0.0.1:5432/app")
count = 0
@event.listens_for(engine, "before_cursor_execute")
def count_sql(conn, cursor, statement, params, context, executemany):
global count
count += 1 # 発行されたSQLの本数を数える
def show(n, options):
with Session(engine) as s:
stmt = select(Post).order_by(Post.id).limit(n).options(*options)
for p in s.scalars(stmt).unique().all():
_ = (p.author.name, len(p.comments))
for label, options in [
("N+1", []),
("fixed", [joinedload(Post.author), selectinload(Post.comments)]),
]:
count = 0
t = time.perf_counter()
show(100, options)
print(label, count, "queries", round((time.perf_counter() - t) * 1000, 1), "ms")
| ローダー | 取り方 | 向いている関連 |
|---|---|---|
joinedload |
LEFT OUTER JOIN(1本) | 多対1・1対1 |
selectinload |
WHERE ... IN (...)(関連1つにつき1本) |
1対多・多対多 |
subqueryload |
元のクエリをサブクエリにして取得 | 旧来の方式(selectinload が推奨) |
raiseload |
取らずに、触れたら例外 | N+1 の検出 |
直した書き方で発行されたSQLは次の2本でした(N=3)。
SELECT posts.id, posts.user_id, posts.title, users_1.id AS id_1, users_1.name
FROM posts LEFT OUTER JOIN users AS users_1 ON users_1.id = posts.user_id ORDER BY posts.id
LIMIT %(param_1)s::INTEGER
SELECT comments.post_id, comments.id, comments.body
FROM comments
WHERE comments.post_id IN (%(primary_keys_1)s::INTEGER, %(primary_keys_2)s::INTEGER, %(primary_keys_3)s::INTEGER) ORDER BY comments.id
N=1000 で本数が3本・5本に増えたのは、selectinload が IN に入れるキーを500件ずつに分けるためです(SQLAlchemy 2.1.4 のソースで _chunksize = 500)。
Django 5.2
# N+1:201本(N=100)
for p in Post.objects.order_by("id")[:100]:
p.user.name, len(p.comments.all())
# 直した書き方:2本
qs = Post.objects.order_by("id").select_related("user").prefetch_related("comments")
for p in qs[:100]:
p.user.name, len(p.comments.all())
直した書き方で発行されたSQLです(N=3)。
SELECT "posts"."id", "posts"."user_id", "posts"."title", "users"."id", "users"."name" FROM "posts" INNER JOIN "users" ON ("posts"."user_id" = "users"."id") ORDER BY "posts"."id" ASC LIMIT 3
SELECT "comments"."id", "comments"."post_id", "comments"."body" FROM "comments" WHERE "comments"."post_id" IN (1, 2, 3)
| メソッド | 取り方 | 使える関連 |
|---|---|---|
select_related |
JOIN | ForeignKey・OneToOneField(1件に対して1件) |
prefetch_related |
IN句 | 逆参照・ManyToManyField(1件に対して複数件) |
Prefetch("comments", queryset=...) |
IN句(絞り込み・並べ替え付き) | 関連側に条件を付けたいとき |
ループの中で p.comments.filter(...) のように新しい条件を付けると、先読みした結果は使われず、またSQLが発行されます。 条件は Prefetch の queryset に書きます。
Drizzle ORM・Prisma(Node.js / TypeScript)
Drizzle では、relations() を定義して db.query.<テーブル>.findMany({ with: ... }) を使うと、関連を1本のSQLで取れます。
import { drizzle } from "drizzle-orm/node-postgres";
import { pgTable, bigint, text } from "drizzle-orm/pg-core";
import { relations, inArray, asc } from "drizzle-orm";
import pg from "pg";
const users = pgTable("users", { id: bigint("id", { mode: "number" }).primaryKey(), name: text("name").notNull() });
const posts = pgTable("posts", { id: bigint("id", { mode: "number" }).primaryKey(), userId: bigint("user_id", { mode: "number" }).notNull(), title: text("title").notNull() });
const comments = pgTable("comments", { id: bigint("id", { mode: "number" }).primaryKey(), postId: bigint("post_id", { mode: "number" }).notNull(), body: text("body").notNull() });
const postsRelations = relations(posts, ({ one, many }) => ({
author: one(users, { fields: [posts.userId], references: [users.id] }),
comments: many(comments),
}));
const commentsRelations = relations(comments, ({ one }) => ({
post: one(posts, { fields: [comments.postId], references: [posts.id] }),
}));
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
const db = drizzle(pool, { schema: { users, posts, comments, postsRelations, commentsRelations }, logger: true });
// 方法1:リレーショナルクエリ(1本)
const list = await db.query.posts.findMany({
with: { author: true, comments: true },
orderBy: (p, { asc }) => [asc(p.id)],
limit: 100,
});
// 方法2:IN句で一括取得して、アプリ側で組み合わせる(3本)
const ps = await db.select().from(posts).orderBy(asc(posts.id)).limit(100);
const us = await db.select().from(users).where(inArray(users.id, [...new Set(ps.map((p) => p.userId))]));
const cs = await db.select().from(comments).where(inArray(comments.postId, ps.map((p) => p.id)));
const userById = new Map(us.map((u) => [u.id, u]));
const commentsByPost = Map.groupBy(cs, (c) => c.postId);
方法1で発行されたSQLは、関連ごとに LEFT JOIN LATERAL と JSON の集計を使う1本でした。
select "posts"."id", "posts"."user_id", "posts"."title", "posts_author"."data" as "author", "posts_comments"."data" as "comments" from "posts" "posts" left join lateral (select json_build_array("posts_author"."id", "posts_author"."name") as "data" from (select * from "users" "posts_author" where "posts_author"."id" = "posts"."user_id" limit $1) "posts_author") "posts_author" on true left join lateral (select coalesce(json_agg(json_build_array("posts_comments"."id", "posts_comments"."post_id", "posts_comments"."body")), '[]'::json) as "data" from "comments" "posts_comments" where "posts_comments"."post_id" = "posts"."id") "posts_comments" on true order by "posts"."id" asc limit $2
コメントを JSON にまとめてから結合するため、1対多でも投稿の行は増えず、limit も投稿の件数どおりに効きます。スキーマの書き方は、関連記事「Drizzle ORMの使い方|スキーマ定義とマイグレーション」で扱っています。
Prisma は、findMany({ include: { posts: true } }) のように include で関連をまとめて取ります。Prisma ORM 7 のドキュメントでは、relationLoadStrategy(Preview。relationJoins を有効にして使う)で、"join"(PostgreSQL では LATERAL JOIN)と "query"(テーブルごとに1本ずつ送り、アプリ側で組み合わせる)を選べると説明されています。
生のSQLでも起きる:JDBC(Java 25)
ORM を使わなくても、ループの中でSQLを実行すれば N+1 になります。PostgreSQL では、IN句の代わりに = ANY(?) に配列を1つ渡すと、件数が変わっても同じSQL文で済みます。
import java.sql.*;
import java.util.*;
public class JdbcNPlusOne {
record Post(long id, long userId, String title) {}
// N+1:1件ずつ著者を取得(N本)
static Map<Long, String> nPlusOne(Connection c, List<Post> posts) throws SQLException {
Map<Long, String> names = new HashMap<>();
try (PreparedStatement ps = c.prepareStatement("SELECT name FROM users WHERE id = ?")) {
for (Post p : posts) {
ps.setLong(1, p.userId());
try (ResultSet rs = ps.executeQuery()) {
rs.next();
names.put(p.id(), rs.getString(1));
}
}
}
return names;
}
// 一括取得:= ANY(配列) で1本
static Map<Long, String> bulk(Connection c, List<Post> posts) throws SQLException {
Long[] ids = posts.stream().map(Post::userId).distinct().toArray(Long[]::new);
Map<Long, String> byUser = new HashMap<>();
try (PreparedStatement ps = c.prepareStatement("SELECT id, name FROM users WHERE id = ANY(?)")) {
ps.setArray(1, c.createArrayOf("bigint", ids));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) byUser.put(rs.getLong(1), rs.getString(2));
}
}
Map<Long, String> names = new HashMap<>();
for (Post p : posts) names.put(p.id(), byUser.get(p.userId()));
return names;
}
public static void main(String[] args) throws Exception {
try (Connection c = DriverManager.getConnection(
"jdbc:postgresql://127.0.0.1:5432/app", "user", "pass")) {
List<Post> posts = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement(
"SELECT id, user_id, title FROM posts ORDER BY id LIMIT 100");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) posts.add(new Post(rs.getLong(1), rs.getLong(2), rs.getString(3)));
}
System.out.println(nPlusOne(c, posts).size() + " / " + bulk(c, posts).size());
}
}
}
実測では、1000件・往復2.9msで N+1 が2,924.8ms、= ANY(?) が8.4msでした。
JPA(Hibernate)・Spring Data JPA
public interface PostRepository extends JpaRepository<Post, Long> {
// 1. JOIN FETCH(多対1をJOINで一緒に取る)
@Query("SELECT p FROM Post p JOIN FETCH p.author")
List<Post> findAllWithAuthor();
// 2. @EntityGraph(注釈で関連を指定。中身は JOIN)
@EntityGraph(attributePaths = {"author"})
List<Post> findAllByOrderByIdAsc();
}
// 3. @BatchSize(このコレクションを、最大100件分ずつまとめて取る)
@OneToMany(mappedBy = "post")
@BatchSize(size = 100)
private List<Comment> comments;
# 4. すべての関連に既定のバッチサイズを設定(Spring Boot)
spring.jpa.properties.hibernate.default_batch_fetch_size=100
| 手法 | 取り方 | 本数(投稿1000件のコメント) | 向いている場面 |
|---|---|---|---|
| JOIN FETCH | JOIN(1本) | 1本(行は5,000行) | 多対1、件数が少ない1対多 |
| @EntityGraph | JOIN(1本) | 1本 | メソッド名のクエリにそのまま付けたい |
| @BatchSize / default_batch_fetch_size | ID をまとめて取得 | 1+1000÷100=11本 | 1対多、ページングと併用 |
| @Fetch(FetchMode.SUBSELECT) | 元のクエリをサブクエリにして取得 | 2本 | 1対多をまとめて1回で取りたい |
Hibernate 7.4.13 のソースでは、PostgreSQL のように配列型を使えるDBで、バッチ取得の ID を配列のパラメーター1つにまとめて送る実装(EntityBatchLoaderArrayParam・CollectionBatchLoaderArrayParam)が選ばれます。
FetchType.EAGER にしても N+1 は直りません。 JPQL で一覧を取った後、EAGER の関連が1件ずつ追加のSQLで取られることがあります。関連は LAZY にしておき、必要な画面で JOIN FETCH・@EntityGraph・バッチ取得を使います。4手法のコード全文と Spring Boot での実行結果は、関連記事「JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文」で扱っています。
MyBatis
MyBatis の公式ドキュメントは、<association select="...">(ネストした select)を一覧に使うと 「N+1 Selects Problem」 になると説明しています。直し方は、JOIN した結果を resultMap で組み立てる「ネストした結果(Nested Results)」です。
<select id="selectPosts" resultMap="postResult">
SELECT p.id AS post_id, p.title,
u.id AS user_id, u.name AS user_name
FROM posts p
JOIN users u ON u.id = p.user_id
ORDER BY p.id
</select>
<resultMap id="postResult" type="Post">
<id property="id" column="post_id"/>
<result property="title" column="title"/>
<association property="author" javaType="User">
<id property="id" column="user_id"/>
<result property="name" column="user_name"/>
</association>
</resultMap>
ドキュメントは、<id> で行を一意に見分ける列を必ず指定するよう案内しています。fetchType="lazy" にしても、一覧を取ってすぐループで関連に触れれば、遅延読み込みがすべて実行されます。
Rails(Active Record)
Book.includes(:author).limit(10) # 状況に応じて IN句 か JOIN を選ぶ
Book.preload(:author).limit(10) # 必ず IN句(関連ごとに1本)
Book.eager_load(:author).limit(10) # 必ず LEFT OUTER JOIN(1本)
| メソッド | 取り方 | 使いどころ |
|---|---|---|
includes |
通常は IN句。関連先の条件を使うと JOIN | 迷ったとき |
preload |
IN句 | 1対多・多対多。関連先の条件は付けられない |
eager_load |
LEFT OUTER JOIN | 関連先の列で絞り込み・並べ替えをしたい |
Rails 8.1 のガイドでは、includes(:author).limit(10) は SELECT books.* FROM books LIMIT 10 と SELECT authors.* FROM authors WHERE authors.id IN (...) の2本になると説明されています。
Laravel(Eloquent)
$books = Book::with('author')->get(); // 2本(books と authors を IN句で)
$books = Book::with(['author', 'publisher'])->get(); // 複数の関連
$books = Book::with('author.contacts')->get(); // 入れ子の関連
$books->load('author'); // 取得後にまとめて読む
Laravel 13.x のドキュメントには、触れた関連を自動でまとめて読み込む Model::automaticallyEagerLoadRelationships() も載っています。使い方は、関連記事「Laravel Eloquentの使い方|リレーション・N+1対策」で扱っています。
GraphQL(DataLoader)
GraphQL はフィールドごとにリゾルバー(値を返す関数)が動くため、Post.author をそのまま書くと投稿の数だけSQLが出ます。DataLoader は、同じタイミングで頼まれたキーを集めて、1回の関数呼び出しにまとめます。
import DataLoader from "dataloader";
// リクエストごとに作る(キャッシュを他のリクエストと共有しない)
function createLoaders(pool) {
return {
user: new DataLoader(async (ids) => {
const { rows } = await pool.query("SELECT id, name FROM users WHERE id = ANY($1)", [ids]);
const byId = new Map(rows.map((r) => [Number(r.id), r]));
return ids.map((id) => byId.get(id) ?? null); // 渡されたキーと同じ順番・同じ件数で返す
}),
};
}
// リゾルバー
const resolvers = {
Post: { author: (post, _args, ctx) => ctx.loaders.user.load(Number(post.user_id)) },
};
投稿100件の著者を解決した結果です(DataLoader 2.2.3)。
リゾルバーそのまま: 100 queries
DataLoader: 1 query -> SELECT id, name FROM users WHERE id = ANY($1) / 100 authors
バッチ関数は、受け取ったキーと同じ順番・同じ件数の配列を返します。 DB の結果は順番が保証されないため、Map で並べ直しています。DataLoader はキャッシュを持つため、リクエストごとに作り直すのが原則です。GraphQL と REST のリクエスト数の比較は、関連記事「GraphQLとRESTの違い|同じ画面を両方で作ってリクエスト数を実測」で扱っています。
直しすぎに注意:デカルト積・ページング・IN句の上限
1対多を2つ JOIN すると、行が掛け算で増える(デカルト積)
1投稿にコメント5件・タグ3件があるとき、両方を JOIN すると、1投稿あたり 5×3=15行になります。これをデカルト積(すべての組み合わせ)と呼びます。行数を数えた結果です。
| 投稿の件数 | コメントだけ JOIN | コメントとタグを JOIN |
|---|---|---|
| 10 | 50行 | 150行 |
| 100 | 500行 | 1,500行 |
| 1000 | 5,000行 | 15,000行 |
SQLAlchemy で、全部 joinedload(1本)にした場合と、著者だけ JOIN してコメント・タグを selectinload にした場合の時間です。
| 件数N | 全部 JOIN(1本)直接 / 遅延あり | JOIN+IN句(3〜5本)直接 / 遅延あり |
|---|---|---|
| 10 | 5.0ms / 11.5ms | 4.8ms / 18.5ms |
| 100 | 24.6ms / 24.8ms | 13.4ms / 24.4ms |
| 1000 | 268.1ms / 189.5ms | 102.0ms / 133.8ms |
件数が少なく往復が遅いときは、1本にまとめた JOIN が速いこともあります。件数が増えると、増えた行の転送と重複の取り除きで JOIN の方が遅くなりました(1000件で約1.4〜2.6倍)。1対多・多対多が2つ以上あるときは、IN句(selectinload・prefetch_related・preload・バッチ取得)に分けるのが無難です。
Hibernate では、List(bag)のコレクションを2つ同時に JOIN FETCH すると、次の例外になります(Hibernate 7.4.13 のソースのメッセージ)。
org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [...]
1対多の JOIN と LIMIT・ページング(HHH000104 / HHH90003004)
1対多を JOIN した状態で LIMIT 20 を付けると、DBは「投稿20件」ではなく「JOIN 後の20行」を返します。ORM ごとの扱いは次のとおりです。
| ORM | 1対多を JOIN してページングしたとき |
|---|---|
| SQLAlchemy | LIMIT 付きの親をサブクエリにしてから JOIN する(下の SQL) |
| Hibernate 5 | 警告 HHH000104 を出し、全件を読み込んでからメモリ上でページング |
| Hibernate 6・7 | 警告 HHH90003004(同じ内容)。設定で例外にもできる |
| 共通の回避策 | 親の ID だけをページングで取り、関連は IN句で取る2段階 |
SQLAlchemy の joinedload(Post.comments) に limit(3) を付けたときのSQLです。
SELECT anon_1.id, anon_1.user_id, anon_1.title, users_1.id AS id_1, users_1.name, comments_1.id AS id_2, comments_1.post_id, comments_1.body
FROM (SELECT posts.id AS id, posts.user_id AS user_id, posts.title AS title
FROM posts ORDER BY posts.id
LIMIT %(param_1)s::INTEGER) AS anon_1 LEFT OUTER JOIN users AS users_1 ON users_1.id = anon_1.user_id LEFT OUTER JOIN comments AS comments_1 ON anon_1.id = comments_1.post_id ORDER BY anon_1.id, comments_1.id
Hibernate 7.4.13 のソースでは、警告と、例外にする設定は次のように定義されています。
HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
# 警告の代わりに例外にする(既定は false)
spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true
有効にすると、setFirstResult() or setMaxResults() specified with collection fetch join (in-memory pagination was about to be applied, but 'hibernate.query.fail_on_pagination_over_collection_fetch' is enabled) という HibernateException になります。メモリ上のページングは、全件をアプリに読み込んでから切り出すため、件数が増えるほど遅くなり、メモリも使います。 2段階にする書き方の例です。
// 1段目:親の ID だけをページングで取る
@Query("SELECT p.id FROM Post p ORDER BY p.id")
Page<Long> findPageIds(Pageable pageable);
// 2段目:その ID の投稿とコメントをまとめて取る
@Query("SELECT DISTINCT p FROM Post p LEFT JOIN FETCH p.comments WHERE p.id IN :ids ORDER BY p.id")
List<Post> findWithCommentsByIdIn(@Param("ids") List<Long> ids);
default_batch_fetch_size を設定し、1段目でエンティティごとページングして、コメントはバッチ取得に任せる方法もあります。Pageable と Slice の違いは、関連記事「Spring Data JPAページング|Pageable・Slice・ソート」で扱っています。
IN句に入れられる件数には上限がある
PostgreSQL の通信方式では、1つのSQLに付けられるパラメーター($1、$2…)は 65,535個までです。node-postgres で試した結果です。
65535 ok 1000
65536 error: bind message supplies 0 parameters, but prepared statement "" requires 65536
ANY($1) 100000 -> 1000
65,536個で失敗し、配列1つを渡す = ANY($1) なら10万件でも1本で通りました。 SQLAlchemy の selectinload が500件ずつ、Hibernate のバッチ取得が指定した件数ずつに分けるのは、このような上限とSQLの長さを抑えるためでもあります。
使わない関連まで読む
N+1 を避けようとして、画面で使わない関連まで常に先読みすると、今度は不要なSQLと転送が増えます。先読みは「その画面・そのAPIで使う関連だけ」に付けます。JPA で関連を EAGER にするのが勧められないのも、このためです。
データベース以外のN+1問題
ループの中で、外部の API や別のサービスを1件ずつ呼ぶ場合も同じ構造です。
// N+1:注文100件に対して、在庫APIを100回呼ぶ
for (const order of orders) {
order.stock = await stockApi.get(order.productId);
}
// 改善:まとめて取得するAPIを1回呼ぶ
const stocks = await stockApi.getMany(orders.map((o) => o.productId));
API の1往復は、DB より長いことがほとんどです。まとめて取れる API(バルクAPI)を用意する、並列で呼ぶ、キャッシュを使う、といった対策を選びます。
N+1問題を許容してよい場合
- N が小さく、増えないことが決まっている(設定項目の一覧など)
- 関連データがキャッシュから返る(同じ著者が何度も出る場合。ORM のセッション内キャッシュで2回目以降のSQLが出ないこともある)
- 詳細画面のように、対象が1件だけ
「今は件数が少ない」は将来変わります。許容する場合も、理由をコメントに残しておくと後から見直せます。
AIが書いたコードでも起きやすい
コーディング支援の AI が生成したコードも、ループの中で関連に触れる素直な書き方になりやすく、N+1 が含まれることがあります。結果は正しいため、動作のテストでは見つかりません。一覧を返す処理のレビューでは「ループの中でSQLや API 呼び出しが発生していないか」を確かめ、前述の「遅延読み込みを例外にする設定」と「本数を固定するテスト」で仕組みとして防ぎます。
よくある質問
Q. n+1問題とは何ですか? A. 一覧を取る1本のSQLのあと、一覧の行ごとに関連データを取るSQLがN本発行され、合計 N+1 本になる性能問題です。ORM の遅延読み込みと、ループの中での関連へのアクセスが原因です。
Q. 「n + 1問題」「1+N問題」と書くこともありますか? A. どれも同じ問題を指します。最初の1本と後のN本という順番から「1+N問題」と呼ぶこともあります。
Q. N+1問題はどのくらいの件数から問題になりますか? A. 往復時間によります。実測では、往復2.9msの接続で N=10 でも 89.3ms(直した後は12.5ms)、N=100 で 725.2ms でした。件数が増える一覧では、件数に関係なく先読みにしておくのが基本です。
Q. インデックスを張れば解決しますか? A. 1本ごとは速くなりますが、本数は変わりません。実測でも、DB内の実行時間は200本で約3.3msと小さく、時間の大半は往復とORMの処理でした。
Q. 生のSQLを書いていれば、N+1問題は起きませんか? A. ループの中でSQLを実行すれば起きます。JDBC の実測では、1000件・往復2.9msで2,924.8msかかりました。
Q. FetchType.EAGER にすれば解決しますか? A. 解決しません。JPQL の一覧取得のあと、EAGER の関連が1件ずつ取られることがあります。LAZY のまま、画面ごとに JOIN FETCH・@EntityGraph・バッチ取得を使います。
Q. HHH000104 の警告が出ました。 A. コレクションを JOIN FETCH したクエリにページングを付けたため、全件を読み込んでメモリ上でページングしている、という警告です(Hibernate 6 以降は HHH90003004)。親の ID を先にページングで取り、関連を IN句やバッチ取得で取る形に変えます。
まとめ
- N+1問題は、一覧1本+関連N本でSQLが増える問題。時間は「本数 × 往復時間」で伸びる
- 実測では1000件・往復2.9msで、N+1 が約7.5秒、直した書き方が約0.1秒
- DB内の実行時間は小さいため、スロークエリログではなく、SQLの本数(ログ・pg_stat_statements の calls・Debug Toolbar の similar)で見つける
- 直し方は、多対1は JOIN、1対多・多対多は IN句。JPA は JOIN FETCH・@EntityGraph・バッチ取得、Django は select_related・prefetch_related、SQLAlchemy は joinedload・selectinload、GraphQL は DataLoader
- 直しすぎに注意:1対多を2つ JOIN すると行が掛け算になる。1対多の JOIN とページングは HHH000104 / HHH90003004。IN句のパラメーターは 65,535個まで
- 遅延読み込みを例外にする設定と、本数を固定するテストで、後から入り込むのを防ぐ
参考・出典
確認日はいずれも 2026年10月16日です。
- SQLAlchemy 2.1 Documentation「Relationship Loading Techniques」:https://docs.sqlalchemy.org/en/21/orm/queryguide/relationships.html
- Django 5.2 documentation「QuerySet API reference(select_related・prefetch_related)」:https://docs.djangoproject.com/en/5.2/ref/models/querysets/
- Django 5.2 documentation「Testing tools(assertNumQueries)」:https://docs.djangoproject.com/en/5.2/topics/testing/tools/
- Django Debug Toolbar documentation:https://django-debug-toolbar.readthedocs.io/
- PostgreSQL 16 文書「pg_stat_statements」:https://www.postgresql.org/docs/16/pgstatstatements.html
- Hibernate ORM 7.4 ソースコード(QueryLogging・QuerySettings・FetchSettings・MultipleBagFetchException):https://github.com/hibernate/hibernate-orm
- Hibernate ORM User Guide(Fetching):https://docs.hibernate.org/orm/7.4/userguide/html_single/
- Spring Data JPA Reference「JPA Query Methods(@EntityGraph)」:https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- MyBatis 3「Mapper XML Files(association・N+1 Selects Problem)」:https://mybatis.org/mybatis-3/sqlmap-xml.html
- Rails Guides「Active Record Query Interface(Eager Loading Associations)」:https://guides.rubyonrails.org/active_record_querying.html
- Laravel 13.x「Eloquent: Relationships(Eager Loading)」:https://laravel.com/docs/13.x/eloquent-relationships
- Drizzle ORM「Relational queries」:https://orm.drizzle.team/docs/rqb
- Prisma ORM 7「Relation queries」:https://www.prisma.io/docs/orm/v7/prisma-client/queries/relation-queries
- DataLoader(GitHub graphql/dataloader):https://github.com/graphql/dataloader
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「N+1問題とは?原因・見つけ方・解決方法を実測比較【ORM別】」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。