- Spring
JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文
JPAのN+1問題とは、一覧を1本のSQLで取ったあと、関連するデータを1件ずつ追加のSQLで取りにいき、合計で「1+N本」のSQLが流れる状態のことです。 解決の方針は「関連をまとめて取る」ことで、JPAでは JOIN FETCH・@EntityGraph・バッチ取得・DTO射影 の4つが主な手段になります。
この記事はJPA(Spring Data JPAとHibernate)に絞り、エンティティとリポジトリのコード全文、関連の種類ごとにどの手法が合うかの表、複数コレクションやページングでつまずく点の直し方をまとめました。SQLの本数と時間は、PostgreSQLとJava(JDBC)で同じ状況を再現して測っています。
確認環境:Spring Data JPA 4.1.1・Spring Boot 4.1.1・Hibernate ORM 7.1 の公式ドキュメントと Jakarta Persistence 3.2 のAPIドキュメント(確認日 2026年10月7日)で確認。SQLの本数と時間は Ubuntu 24.04・OpenJDK 21.0.10・PostgreSQL 16.15・PostgreSQL JDBC Driver 42.7.2 で実行。
N+1の解決手法の早見表
| 手法 | 書く場所 | SQLの本数 | 合う場面 |
|---|---|---|---|
| JOIN FETCH | @Query のJPQL |
1本 | 取りたい関連が決まっている検索 |
@EntityGraph |
リポジトリのメソッド | 1本 | 派生クエリや findAll に関連を足す |
| バッチ取得 | @BatchSize/設定 |
1+⌈N÷サイズ⌉本 | コレクションが複数、ページングあり |
| DTO射影 | @Query+record |
1本 | 表示用に必要な列だけ欲しい |
(N は最初のSQLで取れた親の件数、⌈ ⌉ は切り上げです。)
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文」
N+1が起きる仕組みをSQLで確かめる
「著者(author)を一覧で取り、各著者の本(book)を表示する」処理で考えます。関連を遅延読み込み(必要になったときに取る方式)にしていると、ループの中で本に触れるたびに1本ずつSQLが流れます。
sequenceDiagram
participant A as アプリ
participant D as DB
A->>D: select * from author(1本目)
loop 著者の件数ぶん
A->>D: select * from book where author_id = ?
end
Hibernateの公式ガイドは、N+1 selects を「最初のクエリでN行を取り、関連するエンティティをそのあとN本のクエリで取ること」と説明しています。同じ流れをJDBCで再現したのが次のコードです。著者100件・本500件(著者1人につき5冊)を用意し、3通りの取り方でSQLの本数と時間を測ります。
import java.sql.*;
import java.util.*;
/** N+1 を JDBC で再現: 著者100件・本は著者ごとに5冊 */
public class NPlusOne {
record Book(long id, String title) {}
record Author(long id, String name, List<Book> books) {}
static int sqlCount = 0;
static PreparedStatement prepare(Connection con, String sql) throws SQLException {
sqlCount++;
return con.prepareStatement(sql);
}
public static void main(String[] args) throws Exception {
String url = "jdbc:postgresql://localhost:5432/t438";
try (Connection con = DriverManager.getConnection(url, "t438", "t438pw")) {
setup(con);
for (int warm = 0; warm < 3; warm++) { lazyLoop(con); joinOnce(con); inClause(con); }
measure("1) 1+N本(著者→本を1件ずつ)", con, 1);
measure("2) JOIN 1本(JOIN FETCH 相当)", con, 2);
measure("3) IN句 2本(バッチ取得 相当)", con, 3);
}
}
/** 20回くり返して中央値を出す */
static void measure(String label, Connection con, int mode) throws SQLException {
double[] ms = new double[20];
List<Author> authors = List.of();
for (int i = 0; i < ms.length; i++) {
sqlCount = 0;
long t0 = System.nanoTime();
authors = switch (mode) {
case 1 -> lazyLoop(con);
case 2 -> joinOnce(con);
default -> inClause(con);
};
ms[i] = (System.nanoTime() - t0) / 1_000_000.0;
}
Arrays.sort(ms);
int books = authors.stream().mapToInt(a -> a.books().size()).sum();
System.out.printf("%s%n SQL=%d本 著者=%d件 本=%d件 中央値 %.1f ms%n",
label, sqlCount, authors.size(), books, ms[ms.length / 2]);
}
/** 1+N: 著者一覧 1本 + 著者ごとに本を取る N本 */
static List<Author> lazyLoop(Connection con) throws SQLException {
List<Author> authors = new ArrayList<>();
try (PreparedStatement ps = prepare(con, "select id, name from author order by id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) authors.add(new Author(rs.getLong(1), rs.getString(2), new ArrayList<>()));
}
for (Author a : authors) {
try (PreparedStatement ps = prepare(con,
"select id, title from book where author_id = ? order by id")) {
ps.setLong(1, a.id());
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) a.books().add(new Book(rs.getLong(1), rs.getString(2)));
}
}
}
return authors;
}
/** JOIN 1本: 著者の列が本の数だけ繰り返されるので、Java側でまとめ直す */
static List<Author> joinOnce(Connection con) throws SQLException {
Map<Long, Author> map = new LinkedHashMap<>();
String sql = """
select a.id, a.name, b.id, b.title
from author a left join book b on b.author_id = a.id
order by a.id, b.id""";
try (PreparedStatement ps = prepare(con, sql); ResultSet rs = ps.executeQuery()) {
while (rs.next()) {
long aid = rs.getLong(1);
String name = rs.getString(2);
Author a = map.computeIfAbsent(aid, k -> new Author(k, name, new ArrayList<>()));
long bid = rs.getLong(3);
if (!rs.wasNull()) a.books().add(new Book(bid, rs.getString(4)));
}
}
return new ArrayList<>(map.values());
}
/** IN句 2本: 著者一覧 1本 + 本をまとめて取る 1本 */
static List<Author> inClause(Connection con) throws SQLException {
Map<Long, Author> map = new LinkedHashMap<>();
try (PreparedStatement ps = prepare(con, "select id, name from author order by id");
ResultSet rs = ps.executeQuery()) {
while (rs.next()) map.put(rs.getLong(1), new Author(rs.getLong(1), rs.getString(2), new ArrayList<>()));
}
try (PreparedStatement ps = prepare(con,
"select id, title, author_id from book where author_id = any (?) order by id")) {
ps.setArray(1, con.createArrayOf("bigint", map.keySet().toArray()));
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) map.get(rs.getLong(3)).books().add(new Book(rs.getLong(1), rs.getString(2)));
}
}
return new ArrayList<>(map.values());
}
static void setup(Connection con) throws SQLException {
try (Statement st = con.createStatement()) {
st.execute("drop table if exists review");
st.execute("drop table if exists book");
st.execute("drop table if exists author");
st.execute("create table author (id bigint primary key, name text not null)");
st.execute("""
create table book (id bigint primary key, title text not null,
author_id bigint not null references author(id))""");
st.execute("create index book_author_idx on book(author_id)");
st.execute("insert into author select g, 'author' || g from generate_series(1, 100) g");
st.execute("""
insert into book
select g, 'book' || g, (g - 1) / 5 + 1 from generate_series(1, 500) g""");
}
}
}
実行コマンドと結果です(20回くり返した中央値)。
java -Dstdout.encoding=UTF-8 -cp /usr/share/java/postgresql.jar NPlusOne.java
1) 1+N本(著者→本を1件ずつ)
SQL=101本 著者=100件 本=500件 中央値 10.4 ms
2) JOIN 1本(JOIN FETCH 相当)
SQL=1本 著者=100件 本=500件 中央値 0.9 ms
3) IN句 2本(バッチ取得 相当)
SQL=2本 著者=100件 本=500件 中央値 0.8 ms
| 取り方 | SQLの本数 | 時間(中央値) |
|---|---|---|
| 1+N本 | 101本 | 10.4 ms |
| JOIN 1本 | 1本 | 0.9 ms |
| まとめて2本 | 2本 | 0.8 ms |
同じ600行のデータを取るのに、SQLが101本だと約10ミリ秒、1〜2本だと1ミリ秒未満でした。 この測定はアプリとDBが同じマシンにあるので、往復1回あたりの時間はごく短い条件です。アプリとDBが別のマシンにある構成では、往復の回数がそのまま時間の差として表れやすくなります。
例に使うエンティティとリポジトリ
ここからのSpringのコードは、Spring Boot 4.1.1・Spring Data JPA 4.1.1・Hibernate ORM 7.1 の公式ドキュメントに沿って書いています。著者(Author)と本(Book)の2つのエンティティを使います。
package com.example.library.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.NamedAttributeNode;
import jakarta.persistence.NamedEntityGraph;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
@Entity
@NamedEntityGraph(name = "Author.withBooks",
attributeNodes = @NamedAttributeNode("books"))
public class Author {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// @OneToMany の fetch は、指定しなければ LAZY
@OneToMany(mappedBy = "author")
private List<Book> books = new ArrayList<>();
protected Author() {
}
public Author(String name) {
this.name = name;
}
public Long getId() { return id; }
public String getName() { return name; }
public List<Book> getBooks() { return books; }
}
package com.example.library.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class Book {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
// @ManyToOne の fetch は、指定しなければ EAGER。LAZY を明示する
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private Author author;
protected Book() {
}
public Book(String title, Author author) {
this.title = title;
this.author = author;
}
public Long getId() { return id; }
public String getTitle() { return title; }
public Author getAuthor() { return author; }
}
N+1が起きるのは、たとえば次のようなサービスです。findAll() で1本、ループの中の getBooks().size() で著者の件数ぶんのSQLが流れます。
package com.example.library.service;
import com.example.library.domain.Author;
import com.example.library.repository.AuthorRepository;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthorReportService {
private final AuthorRepository authorRepository;
public AuthorReportService(AuthorRepository authorRepository) {
this.authorRepository = authorRepository;
}
@Transactional(readOnly = true)
public List<String> summarize() {
List<Author> authors = authorRepository.findAll(); // 1本目
return authors.stream()
// getBooks() に触れるたびに、著者1件につき1本
.map(a -> a.getName() + ": " + a.getBooks().size() + "冊")
.toList();
}
}
手法1:JOIN FETCHで1本にまとめる
JPQLの join fetch は、関連を同じSQLのJOINで取り、エンティティに詰めた状態で返します。 取りたい関連がはっきりしている検索に向いています。
package com.example.library.repository;
import com.example.library.domain.Author;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AuthorRepository extends JpaRepository<Author, Long> {
// 1対多:著者と本を1本で取る。本が無い著者も残すので left join
@Query("select distinct a from Author a left join fetch a.books order by a.id")
List<Author> findAllWithBooks();
// 条件つき
@Query("select distinct a from Author a left join fetch a.books where a.name like :keyword")
List<Author> searchWithBooks(@Param("keyword") String keyword);
}
package com.example.library.repository;
import com.example.library.domain.Book;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface BookRepository extends JpaRepository<Book, Long> {
// 多対1:本と著者を1本で取る
@Query("select b from Book b join fetch b.author order by b.id")
List<Book> findAllWithAuthor();
}
| 関連の向き | 結果の行 | 気をつけること |
|---|---|---|
| 多対1(本→著者) | 本の件数と同じ | 行が増えないので扱いやすい |
| 1対多(著者→本) | 本の件数ぶんに増える | 著者の列が本の数だけ繰り返される |
1対多のJOINでは、SQLの結果は「著者×本」の行数になります。先ほどのJDBCのコードで joinOnce がJava側で著者ごとにまとめ直しているのは、この繰り返しを1件に戻すためです。
手法2:@EntityGraphでメソッドに関連を足す
@EntityGraph は、リポジトリのメソッドに「この関連も一緒に取る」と書き足す方法です。JPQLを書かずに、派生クエリ(メソッド名から作られるクエリ)や findAll にそのまま付けられます。
その場で指定する(attributePaths)
package com.example.library.repository;
import com.example.library.domain.Author;
import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorGraphRepository extends JpaRepository<Author, Long> {
// findAll に books を足す
@Override
@EntityGraph(attributePaths = { "books" })
List<Author> findAll();
// 派生クエリに books を足す
@EntityGraph(attributePaths = { "books" })
List<Author> findByNameContaining(String keyword);
}
名前を付けて使い回す(@NamedEntityGraph)
エンティティ側に @NamedEntityGraph で名前を付けておくと、複数のメソッドから同じ取り方を参照できます。先ほどの Author には Author.withBooks を定義してあります。
package com.example.library.repository;
import com.example.library.domain.Author;
import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.EntityGraph.EntityGraphType;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorNamedGraphRepository extends JpaRepository<Author, Long> {
@EntityGraph(value = "Author.withBooks", type = EntityGraphType.LOAD)
List<Author> findByNameStartingWith(String prefix);
}
| 書き方 | 定義する場所 | 合う場面 |
|---|---|---|
attributePaths |
リポジトリのメソッド | そのメソッドだけで使う |
@NamedEntityGraph+value |
エンティティ | 同じ取り方を何か所でも使う |
手法3:バッチ取得で「1+数本」にする
バッチ取得は、遅延読み込みのSQLを1件ずつではなく、複数件ぶんまとめて発行する方法です。JOINをしないので行が増えず、コレクションが複数あるときやページングと組み合わせるときに扱いやすいのが特徴です。
全体に効かせる(default_batch_fetch_size)
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
spring.jpa.properties.* の下の名前は、Hibernateが期待する名前と完全に一致させます。Spring Bootの公式ドキュメントによると、ここは表記のゆれ(batchSize や batch-size など)を吸収しないため、default_batch_fetch_size とアンダースコアで書きます。
関連ごとに効かせる(@BatchSize)
package com.example.library.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import java.util.ArrayList;
import java.util.List;
import org.hibernate.annotations.BatchSize;
@Entity
public class Publisher {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
// このコレクションは、最大50件の親のぶんをまとめて取る
@OneToMany(mappedBy = "publisher")
@BatchSize(size = 50)
private List<Magazine> magazines = new ArrayList<>();
protected Publisher() {
}
public Long getId() { return id; }
public String getName() { return name; }
public List<Magazine> getMagazines() { return magazines; }
}
package com.example.library.domain;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
@Entity
public class Magazine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "publisher_id")
private Publisher publisher;
protected Magazine() {
}
public Long getId() { return id; }
public String getTitle() { return title; }
public Publisher getPublisher() { return publisher; }
}
Hibernateの公式ガイドでは、PostgreSQLでのバッチ取得のSQLとして、where ... = any (?) の形が示されています。この記事のJDBCのコードで inClause が使っている where author_id = any (?) は、これと同じ形です。
| サイズ | 著者100件のときのSQL |
|---|---|
| 指定なし | 1+100=101本 |
| 25 | 1+4=5本 |
| 50 | 1+2=3本 |
| 100 | 1+1=2本 |
(1+⌈親の件数÷サイズ⌉ で計算した本数です。)
サブセレクト取得という選択肢
Hibernateにはもう1つ、@Fetch(FetchMode.SUBSELECT) という取り方があります。コレクションを、親を取ったときの条件をサブクエリにして1本で取る方式です。全体に効かせる設定は hibernate.use_subselect_fetch です。
@OneToMany(mappedBy = "publisher")
@Fetch(FetchMode.SUBSELECT)
private List<Magazine> magazines = new ArrayList<>();
(org.hibernate.annotations.Fetch と org.hibernate.annotations.FetchMode をimportして、上の Publisher の @BatchSize と置き換えて使います。)
手法4:DTO射影で必要な列だけ取る
一覧画面のように「表示に使う列だけあればよい」場合は、エンティティではなく DTO(値を運ぶだけの型)に直接詰めると、関連の読み込み自体が発生しません。Spring Data JPAは、戻り値がDTOのとき、JPQLをコンストラクタ式に書き換えてくれます。
package com.example.library.repository;
public record BookSummary(Long bookId, String title, String authorName) {
}
package com.example.library.repository;
import com.example.library.domain.Book;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface BookSummaryRepository extends JpaRepository<Book, Long> {
// 本と著者名を1本で取る。列の順番は record の引数の順に合わせる
@Query("select b.id, b.title, a.name from Book b join b.author a order by b.id")
List<BookSummary> findSummaries();
// ページングつき。件数を数えるクエリも明示する
@Query(value = "select b.id, b.title, a.name from Book b join b.author a",
countQuery = "select count(b) from Book b")
Page<BookSummary> findSummaryPage(Pageable pageable);
}
著者ごとの冊数のように集計した値が欲しいときも、同じ形で書けます。
package com.example.library.repository;
public record AuthorBookCount(Long authorId, String name, Long bookCount) {
}
package com.example.library.repository;
import com.example.library.domain.Author;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
public interface AuthorCountRepository extends JpaRepository<Author, Long> {
@Query("""
select a.id, a.name, count(b)
from Author a left join a.books b
group by a.id, a.name
order by a.id""")
List<AuthorBookCount> countBooksPerAuthor();
}
| DTO射影で押さえる点 | 内容 |
|---|---|
| コンストラクタ | すべての引数を受け取るコンストラクタが必要(recordなら自動で用意される) |
| 入れ子 | クラスによる射影では入れ子の射影は使えない |
| 書き換え | @Query にすでにコンストラクタ式があれば、Spring Dataは書き換えをしない |
| 更新 | DTOは値の入れ物なので、更新するときはエンティティを取る |
手法と関連の種類の適否表
どの手法を選ぶかは、「関連の種類」と「ページングの有無」でほぼ決まります。 ◎は第一候補、○は使える、△は注意点あり、です。
| 関連の種類 | JOIN FETCH | EntityGraph | バッチ取得 | DTO射影 |
|---|---|---|---|---|
| 多対1 | ◎ | ◎ | ○ | ◎ |
| 1対多 | ◎ | ◎ | ◎ | ○ |
| 多対多 | ○ | ○ | ◎ | ○ |
| 複数コレクション | △ | △ | ◎ | ○ |
| ページング+多対1 | ◎ | ◎ | ○ | ◎ |
| ページング+コレクション | △ | △ | ◎ | ◎ |
△が付いた理由は次の節で説明します。
発行されるSQLの本数
親(著者)がN件、バッチのサイズがSのときの本数です。
| 手法 | 1対多を1つ取る | コレクションを2つ取る |
|---|---|---|
| 何もしない(LAZYのまま) | 1+N本 | 1+2N本 |
| JOIN FETCH | 1本 | 1本(行数は掛け算で増える) |
@EntityGraph |
1本 | 1本(行数は掛け算で増える) |
| バッチ取得 | 1+⌈N÷S⌉本 | 1+2×⌈N÷S⌉本 |
| DTO射影 | 1本 | 集計ごとに1本ずつ |
選び方の目安
| 状況 | 第一候補 |
|---|---|
| 多対1の関連を一緒に表示する | JOIN FETCH か @EntityGraph |
| 1対多を1つだけ、ページングなしで取る | JOIN FETCH か @EntityGraph |
| 1対多をページングつきで取る | バッチ取得 |
| コレクションを2つ以上取る | バッチ取得 |
| 一覧表示で列が少ない | DTO射影 |
| どこで起きるか把握しきれていない | default_batch_fetch_size を全体に設定 |
つまずく3点と直し方
1. 複数コレクションのJOIN FETCHでMultipleBagFetchException
List で宣言した1対多のコレクションを、1つのクエリで2つ同時に join fetch すると、Hibernateは org.hibernate.loader.MultipleBagFetchException を投げます。公式のAPIドキュメントでは「クエリが複数のbagを同時に取得しようとしていることを示す例外」と説明されています(bagは、順序も重複の制約も持たないコレクションのことで、List で宣言した関連がこれにあたります)。
直す前:
// Author に books と reviews の2つの List があるとする
@Query("""
select distinct a from Author a
left join fetch a.books
left join fetch a.reviews""")
List<Author> findAllWithBooksAndReviews();
背景には行数の問題があります。2つのコレクションを同時にJOINすると、結果は掛け算で増えます。著者100件・本500件・レビュー400件のデータで確かめました。
create table review (id bigint primary key, body text not null,
author_id bigint not null references author(id));
insert into review select g, 'review' || g, (g - 1) / 4 + 1 from generate_series(1, 400) g;
-- 2つのコレクションを同時にJOINしたときの行数
select count(*) as join_rows
from author a
left join book b on b.author_id = a.id
left join review r on r.author_id = a.id;
-- 別々に取ったときの行数
select (select count(*) from author) as authors,
(select count(*) from book) as books,
(select count(*) from review) as reviews;
join_rows
-----------
2000
(1 row)
authors | books | reviews
---------+-------+---------
100 | 500 | 400
(1 row)
別々に取れば合計1,000行で済むデータが、同時にJOINすると2,000行になりました(著者1人につき 本5冊×レビュー4件=20行)。件数が増えるほど差は広がります。Hibernateの公式ガイドも、複数の多値の関連を並行して取るJOINは直積になり、結果セットが大きくなると説明しています。
直した後(JOIN FETCHは1つにして、もう1つはバッチ取得に任せる):
// リポジトリ:JOIN FETCH は books だけ
@Query("select distinct a from Author a left join fetch a.books order by a.id")
List<Author> findAllWithBooks();
// Author エンティティ:reviews はバッチ取得
@OneToMany(mappedBy = "author")
@BatchSize(size = 100)
private List<Review> reviews = new ArrayList<>();
これでSQLは「著者+本の1本」と「レビューをまとめて取る1本」の2本になり、行数も掛け算になりません。
2. コレクションのJOIN FETCHとPageableでメモリ上のページングになる
コレクションを join fetch したクエリに Pageable を付けると、SQLの行は「著者×本」に増えているため、DB側の limit では「著者10件」を正しく切り出せません。Hibernateの公式APIドキュメントには、「ページングをコレクションへのフェッチ結合と組み合わせると、limitはデータベースではなくメモリ上で適用される」とあり、性能面から避けるよう書かれています。該当するクエリを例外にして早く気付けるようにする設定も用意されています。
spring:
jpa:
properties:
hibernate:
query:
fail_on_pagination_over_collection_fetch: true
直す前:
@Query(value = "select distinct a from Author a left join fetch a.books",
countQuery = "select count(a) from Author a")
Page<Author> findPageWithBooks(Pageable pageable);
直した後A(ページングは親だけ、コレクションはバッチ取得):
package com.example.library.repository;
import com.example.library.domain.Author;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AuthorPageRepository extends JpaRepository<Author, Long> {
// JOIN FETCH を付けない。books は default_batch_fetch_size でまとめて取られる
Page<Author> findByNameContaining(String keyword, Pageable pageable);
}
spring:
jpa:
properties:
hibernate:
default_batch_fetch_size: 100
直した後B(IDだけをページングし、そのIDで関連つきの本体を取る2段階):
package com.example.library.repository;
import com.example.library.domain.Author;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface AuthorTwoStepRepository extends JpaRepository<Author, Long> {
// 1段階目:IDだけをページング(limit はDB側で効く)
@Query(value = "select a.id from Author a order by a.id",
countQuery = "select count(a) from Author a")
Page<Long> findIdPage(Pageable pageable);
// 2段階目:そのIDの著者を本と一緒に取る
@Query("select distinct a from Author a left join fetch a.books where a.id in :ids order by a.id")
List<Author> findWithBooksByIdIn(@Param("ids") List<Long> ids);
}
package com.example.library.service;
import com.example.library.domain.Author;
import com.example.library.repository.AuthorTwoStepRepository;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class AuthorPageService {
private final AuthorTwoStepRepository repository;
public AuthorPageService(AuthorTwoStepRepository repository) {
this.repository = repository;
}
@Transactional(readOnly = true)
public Page<Author> findPage(Pageable pageable) {
Page<Long> ids = repository.findIdPage(pageable);
List<Author> authors = repository.findWithBooksByIdIn(ids.getContent());
return new PageImpl<>(authors, pageable, ids.getTotalElements());
}
}
| 直し方 | SQLの本数 | 合う場面 |
|---|---|---|
| A:バッチ取得 | 件数+親+コレクションごとに1本 | 設定だけで済ませたい |
| B:2段階 | 件数+ID+本体の3本 | 取る関連を検索ごとに決めたい |
3. EAGERにしても解決しない理由
「遅延読み込みが原因なら FetchType.EAGER にすればよいのでは」と考えたくなりますが、EAGERは「必ず一緒に取る」という指定であり、「1本のSQLで取る」という指定ではありません。 Jakarta PersistenceのAPIドキュメントでも、EAGERは「関連するエンティティを即時に取得しなければならない」という要件として書かれていて、取り方(JOINか追加のSELECTか)までは決めていません。
| 観点 | EAGER | LAZY+必要な所で明示 |
|---|---|---|
| 指定する場所 | エンティティ(全クエリに効く) | クエリやメソッドごと |
| 使わない画面でも取るか | 取る | 取らない |
| 取り方を選べるか | 選べない | JOIN・グラフ・バッチから選べる |
Hibernateの公式ガイドは、「ほとんどの関連は既定で遅延取得にマッピングし、即時取得は必要な時と場所で明示する」という方針を示しています。この記事のエンティティで @ManyToOne(fetch = FetchType.LAZY) と明示しているのはそのためです(@ManyToOne は指定しなければEAGER、@OneToMany は指定しなければLAZYです)。
N+1を検出する設定一覧
N+1は、流れているSQLを見えるようにすると見つけやすくなります。 開発用のプロファイルに入れておく設定をまとめます。
| 設定 | 内容 |
|---|---|
spring.jpa.show-sql |
SQLを出力する |
hibernate.format_sql |
SQLを複数行に整形する |
hibernate.highlight_sql |
SQLを色付きで表示する |
logging.level.org.hibernate.SQL |
debug でSQLをロガー経由で出力する |
logging.level.org.hibernate.orm.jdbc.bind |
trace でバインドした値を出力する |
hibernate.generate_statistics |
統計情報(SQLの本数など)を集める |
hibernate.query.fail_on_pagination_over_collection_fetch |
メモリ上のページングになるクエリを例外にする |
開発用の設定ファイルの全文です。
# application-dev.yml
spring:
jpa:
properties:
hibernate:
format_sql: true
highlight_sql: true
generate_statistics: true
default_batch_fetch_size: 100
query:
fail_on_pagination_over_collection_fetch: true
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
spring.jpa.show-sql: true でもSQLは見られますが、こちらは標準出力に直接出ます。ログの出力先や書式をそろえたいときは、上のようにロガーの org.hibernate.SQL を使うと扱いやすくなります。
テストでクエリ数を数える方法
目で見るだけでなく、テストで「このメソッドはSQLを何本まで」と決めておくと、あとの変更でN+1が入り込んだときにすぐ気付けます。Hibernateの Statistics には、取得したプリペアドステートメントの数を返す getPrepareStatementCount() があります。統計は hibernate.generate_statistics を true にすると集められます。
package com.example.library;
import static org.assertj.core.api.Assertions.assertThat;
import com.example.library.domain.Author;
import com.example.library.repository.AuthorRepository;
import jakarta.persistence.EntityManagerFactory;
import java.util.List;
import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.annotation.Transactional;
@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true")
@Transactional
class AuthorQueryCountTest {
@Autowired
private AuthorRepository authorRepository;
@Autowired
private EntityManagerFactory entityManagerFactory;
private Statistics statistics;
@BeforeEach
void setUp() {
statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
statistics.clear();
}
@Test
void 著者と本の取得はSQL1本で済む() {
List<Author> authors = authorRepository.findAllWithBooks();
// 本に触れても追加のSQLが流れないことを確かめる
authors.forEach(a -> a.getBooks().size());
assertThat(statistics.getPrepareStatementCount()).isEqualTo(1);
}
}
Statistics のメソッド |
返す値 |
|---|---|
getPrepareStatementCount() |
取得したプリペアドステートメントの数 |
getQueryExecutionCount() |
実行したクエリの数 |
getEntityFetchCount() |
エンティティのフェッチの数 |
getCollectionFetchCount() |
コレクションのフェッチの数 |
clear() |
統計をすべてリセットする |
テストデータは、N+1がはっきり表れるよう親を複数件(3件以上)用意するのがコツです。親が1件だけだと、1+1=2本にしかならず、差が見えにくくなります。
直すときの進め方チェックリスト
- [ ]
org.hibernate.SQLのログを有効にして、同じ形のSQLが繰り返されている箇所を探した - [ ] 繰り返しの元になっている関連(どのエンティティのどの項目か)を特定した
- [ ] 関連の種類(多対1/1対多/多対多)とページングの有無を確認した
- [ ] 適否表から手法を選んだ(コレクションが複数、またはページングありならバッチ取得)
- [ ]
@ManyToOneにfetch = FetchType.LAZYを明示した - [ ] テストでSQLの本数を固定した
よくある質問
Q. JOIN FETCHと@EntityGraphはどちらを使えばよいですか?
A. 発行されるSQLはどちらもJOINを使った1本です。JPQLを自分で書く検索ならJOIN FETCH、メソッド名から作られるクエリや findAll に関連を足したいなら @EntityGraph が書きやすいです。
Q. default_batch_fetch_size はいくつにすればよいですか?
A. 1画面で扱う親の件数(ページサイズ)以上にすると、コレクション1種類につき追加のSQLが1本で済みます。ページサイズが20〜100件であれば、100前後から始めて、ログのSQL本数を見ながら調整する進め方が扱いやすいです。
Q. distinct は付けたほうがよいですか?
A. 1対多のJOINでは親の行が子の数だけ繰り返されるため、この記事のJPQLでは「親を重複なく受け取りたい」という意図が読めるよう distinct を付けています。
Q. N+1は必ず直すべきですか? A. 親が数件で、頻繁に呼ばれない処理なら、影響は小さいことがあります。まずSQLの本数と時間を測り、一覧画面やバッチ処理のように件数が増える場所から順に手を入れると効果が出やすいです。
まとめ
- N+1は「一覧の1本+関連を1件ずつ取るN本」。JDBCでの再現では101本で10.4ミリ秒、1〜2本で1ミリ秒未満だった
- 解決の手段は4つ:JOIN FETCH、
@EntityGraph、バッチ取得、DTO射影 - 多対1や、1対多を1つだけ取るならJOIN FETCHか
@EntityGraph(SQLは1本) - コレクションが複数、またはページングありならバッチ取得(
default_batch_fetch_size) - 複数のListを同時にJOIN FETCHすると
MultipleBagFetchException。1つはバッチ取得に回す - コレクションのJOIN FETCH+Pageableはメモリ上のページングになる。親だけをページングする形に直す
- EAGERは取り方を選べない指定。LAZYを基本にして、必要な所で明示する
org.hibernate.SQLのログとStatisticsのテストで、SQLの本数を見える形にする
参考・出典
確認日はすべて 2026年10月7日 です。
- Spring Data JPA リファレンス Query Methods(4.1.1):https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html
- Spring Data JPA リファレンス Projections(4.1.1):https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html
- Spring Boot How-to Data Access(4.1.1):https://docs.spring.io/spring-boot/how-to/data-access.html
- Hibernate ORM 7.1 Introduction(Tuning の章の原文):https://raw.githubusercontent.com/hibernate/hibernate-orm/7.1/documentation/src/main/asciidoc/introduction/Tuning.adoc
- Hibernate ORM 7.1 Introduction:https://docs.hibernate.org/orm/7.1/introduction/html_single/Hibernate_Introduction.html
- Hibernate ORM 7.1 Javadoc MultipleBagFetchException:https://docs.hibernate.org/orm/7.1/javadocs/org/hibernate/loader/MultipleBagFetchException.html
- Hibernate ORM 7.1 Javadoc QuerySettings:https://docs.hibernate.org/orm/7.1/javadocs/org/hibernate/cfg/QuerySettings.html
- Hibernate ORM 7.1 Javadoc FetchSettings:https://docs.hibernate.org/orm/7.1/javadocs/org/hibernate/cfg/FetchSettings.html
- Hibernate ORM 7.1 Javadoc Statistics:https://docs.hibernate.org/orm/7.1/javadocs/org/hibernate/stat/Statistics.html
- Jakarta Persistence 3.2 API ManyToOne:https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/manytoone
- Jakarta Persistence 3.2 API OneToMany:https://jakarta.ee/specifications/persistence/3.2/apidocs/jakarta.persistence/jakarta/persistence/onetomany
- Spring Boot プロジェクトページ(現行 4.1.1):https://spring.io/projects/spring-boot
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「JPAのN+1問題の解決|JOIN FETCH等4手法の比較とコード全文」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。