Spring Data JPAのN+1問題を解説する記事のアイキャッチ。Java道場風の空間で若手エンジニアが大量SQLとJOINを調査するイラスト。

実装・エラー解決

Spring Data JPAでSQLが大量に発行される原因|N+1問題の確認方法と対策

AIによる要約

N+1問題は、一覧を取得する1本のSQLに加え、各行の関連Entityを読むたびに追加SQLが発行される状態です。件数が少ない開発環境では気づきにくく、本番データで急に遅くなります。SQLログで「似たSELECTがIDを変えて繰り返されていないか」を確認し、その画面で必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionなどでまとめて取得します。すべてをEAGERへ変える対策は避けます。

新人SE
新人SE
一覧画面は表示できますが、注文が100件になると急に遅くなります。RepositoryのfindAllは1回しか呼んでいません。
ポンコツSE
ポンコツSE
Java上のRepository呼び出しが1回でも、関連Entityを参照した時にSQLが追加発行されることがあります。まずSQLログを数えましょう。

Spring Data JPAでは、Entity同士の関連をJavaオブジェクトとしてたどれます。便利な一方、一覧処理のループ内で関連を参照すると、気づかないうちに大量のSELECTが発行されることがあります。

N+1問題は、エラーにならず結果も正しいため、レビューや単体テストで見落としやすい不具合です。現場では「ローカルでは速いのに、本番だけ遅い」という形で現れます。

この記事のポイント

  • Repositoryの呼び出し回数ではなく発行SQLを確認する
  • 1件の一覧SQLとN件の関連SQLでN+1になる
  • そのユースケースで必要な関連だけをまとめて取得する
  • JOIN FETCHと@EntityGraphは取得単位で使い分ける
  • すべてEAGERへ変更する対策は別画面へ負荷を広げる

実行用サンプル

同じ3注文で顧客が全員異なる場合と全員同じ場合を作り、顧客取得のSELECT回数と返す一覧を比較します。 JDK 17とMaven 3.9系で mvn test を実行できます。ZIPにはソース全文・テスト・pom.xml・READMEを収録しています。

N+1問題が起きるコード例

Order.java の全文

package info.syachiku.audit;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity(name = "PurchaseOrder")
@Table(name = "orders")
public class Order {
    @Id
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id")
    private Customer customer;

    private BigDecimal totalAmount;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    protected Order() {
    }

    Order(Long id, Customer customer, BigDecimal totalAmount) {
        this.id = id;
        this.customer = customer;
        this.totalAmount = totalAmount;
        this.status = OrderStatus.WAITING;
    }

    Long getId() {
        return id;
    }

    Customer getCustomer() {
        return customer;
    }

    BigDecimal getTotalAmount() {
        return totalAmount;
    }

    OrderStatus getStatus() {
        return status;
    }

    void ship() {
        status = OrderStatus.SHIPPED;
    }
}

OrderからCustomerを遅延読み込みする関連を考えます。注文一覧を取得し、画面用DTOへ変換する処理が次です。

OrderQueryService.java の全文

package info.syachiku.audit;

import java.util.List;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OrderQueryService {
    private final OrderRepository repository;

    OrderQueryService(OrderRepository repository) {
        this.repository = repository;
    }

    @Transactional(readOnly = true)
    public List<OrderRow> findRows() {
        return rows(repository.findAll(Sort.by("id")));
    }

    @Transactional(readOnly = true)
    public List<OrderRow> findRowsWithFetchJoin() {
        return rows(repository.findAllWithCustomer());
    }

    @Transactional(readOnly = true)
    public List<OrderRow> findRowsWithEntityGraph() {
        return rows(repository.findByStatusOrderById(OrderStatus.WAITING));
    }

    @Transactional(readOnly = true)
    public List<OrderRow> findRowsWithProjection() {
        return repository.findOrderRows();
    }

    private List<OrderRow> rows(List<Order> orders) {
        return orders.stream()
                .map(order -> new OrderRow(order.getId(), order.getCustomer().getName(), order.getTotalAmount()))
                .toList();
    }
}

findAllで注文一覧を取得するSQLが1本発行されます。その後、order.getCustomer().getName()を評価すると、未取得のCustomerを読むSQLが注文ごとに発行される可能性があります。

-- 最初の1本
select o.id, o.customer_id, o.total_amount
from orders o;

-- 注文ごとに追加されるSQL
select c.id, c.name from customers c where c.id = ?;
select c.id, c.name from customers c where c.id = ?;
select c.id, c.name from customers c where c.id = ?;
-- 以下繰り返し

上のSQLは仕組みを示す概略です。関連取得は未取得の顧客ごとに発生するため、注文が同じ顧客を参照すれば本数が減ります。別顧客と同一顧客の測定結果は、次のSQLログの章で比較します。

なぜ開発環境では気づきにくいのか

  • テストデータが3件程度しかない
  • DBが同じPC上にあり通信が速い
  • 一度取得したEntityがキャッシュに残っている
  • SQLログを無効にしている
  • 結果が正しいため機能テストを通過する

本番では数百件、DBは別サーバー、同時アクセスもあるため、SQLの往復回数が応答時間へ大きく影響します。1本あたり数ミリ秒でも、数百本積み重なると画面の遅さになります。

まずSQLログで繰り返しを確認する

Hibernateのバッチ取得とOSIVを無効にし、初期データを登録したトランザクションを閉じてから計測します。注文のSELECTが1回、未取得の顧客のSELECTが顧客ごとに1回です。3注文が別顧客なら4回、同じ顧客なら2回。JOIN FETCH、EntityGraph、DTO取得のいずれも1回となり、返した一覧の値は修正前と同じです。ここでは各読み取り処理がSELECTだけを発行するため、Hibernateが記録したSQL準備回数を比較しています。

OrderRepository.java の全文

package info.syachiku.audit;

import java.util.List;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("""
            select o
              from PurchaseOrder o
              join fetch o.customer
             order by o.id
            """)
    List<Order> findAllWithCustomer();

    @EntityGraph(attributePaths = "customer")
    List<Order> findByStatusOrderById(OrderStatus status);

    @Query("""
            select new info.syachiku.audit.OrderRow(o.id, c.name, o.totalAmount)
              from PurchaseOrder o
              join o.customer c
             order by o.id
            """)
    List<OrderRow> findOrderRows();
}

OrderQueryServiceTest.java の全文

package info.syachiku.audit;

import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import java.math.BigDecimal;
import java.util.List;
import java.util.function.Supplier;
import org.hibernate.SessionFactory;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.transaction.support.TransactionTemplate;

import static org.junit.jupiter.api.Assertions.assertEquals;

@SpringBootTest
class OrderQueryServiceTest {
    @Autowired
    private EntityManager entityManager;
    @Autowired
    private EntityManagerFactory entityManagerFactory;
    @Autowired
    private TransactionTemplate transaction;
    @Autowired
    private OrderQueryService queries;

    @ParameterizedTest
    @CsvSource({"false, 4", "true, 2"})
    void 顧客の共有数でSELECT回数が変わり修正後は一回になる(
            boolean sameCustomer, long expectedSelects) {
        prepareOrders(sameCustomer);

        List<OrderRow> before = countStatements(queries::findRows, expectedSelects);
        assertEquals(List.of(1L, 2L, 3L), before.stream().map(OrderRow::orderId).toList());
        assertEquals(sameCustomer ? List.of("C1", "C1", "C1") : List.of("C1", "C2", "C3"),
                before.stream().map(OrderRow::customerName).toList());
        assertEquals(before, countStatements(queries::findRowsWithFetchJoin, 1));
        assertEquals(before, countStatements(queries::findRowsWithEntityGraph, 1));
        assertEquals(before, countStatements(queries::findRowsWithProjection, 1));
    }

    private List<OrderRow> countStatements(Supplier<List<OrderRow>> query, long expected) {
        var stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        stats.clear();
        List<OrderRow> result = query.get();
        // These read-only methods perform SELECTs only.
        assertEquals(expected, stats.getPrepareStatementCount());
        return result;
    }

    private void prepareOrders(boolean sameCustomer) {
        transaction.executeWithoutResult(status -> {
            entityManager.createQuery("delete from PurchaseOrder").executeUpdate();
            entityManager.createQuery("delete from Customer").executeUpdate();

            var first = new Customer(1L, "C1");
            entityManager.persist(first);
            for (long id = 1; id <= 3; id++) {
                var customer = first;
                if (!sameCustomer && id != 1) {
                    customer = new Customer(id, "C" + id);
                    entityManager.persist(customer);
                }
                entityManager.persist(new Order(id, customer, BigDecimal.valueOf(100 * id)));
            }
        });
    }
}
# 開発・調査環境でSQLを確認する例
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# logger名は利用するHibernate/Spring Bootのバージョンに合わせる
logging.level.org.hibernate.SQL=DEBUG

設定はプロジェクトのバージョンとログ方針に合わせます。本番で常時SQLやバインド値を詳細出力すると、ログ量と機密情報の問題があるため、調査環境や一時設定を基本にします。

N+1を疑うログの特徴は次の通りです。

  • 同じ形のSELECTが短時間に何度も出る
  • WHERE句のIDだけが変わっている
  • 一覧件数を増やすとSQL本数も比例して増える
  • DTO変換やテンプレート描画中に追加SQLが出る

N+1は「処理が遅い」だけで判断せず、一覧件数とSQL本数の関係を確認して特定します。

対策1:JOIN FETCHで必要な関連を同時取得する

public interface OrderRepository
        extends JpaRepository<Order, Long> {

    @Query("""
           select o
             from PurchaseOrder o
             join fetch o.customer
            order by o.id
           """)
    List<Order> findAllWithCustomer();
}

JOIN FETCHを使うと、OrderとCustomerを同じクエリで取得できます。注文一覧で必ず顧客名を表示するなら、そのユースケースに合った取得方法です。

select
    o.id,
    o.total_amount,
    c.id,
    c.name
from orders o
join customers c
  on c.id = o.customer_id
order by o.id;

Repositoryメソッド名にWithCustomerなどを入れ、通常のfindAllとの違いを示すと、呼び出し側が取得範囲を判断しやすくなります。

対策2:@EntityGraphで取得対象を指定する

public interface OrderRepository
        extends JpaRepository<Order, Long> {

    @EntityGraph(attributePaths = "customer")
    List<Order> findByStatusOrderById(OrderStatus status);
}

@EntityGraphは、Repositoryメソッド単位で取得したい関連を宣言できます。JPQLを手書きせず、派生クエリへ取得グラフを加えたい場合に使いやすい方法です。

方法向いている場面注意点
JOIN FETCHJPQLで結合・条件を明示したいクエリが長くなる
@EntityGraphRepositoryメソッドへ取得範囲を加えたい複雑な条件は別途必要
DTO projection一覧に必要な列だけ取得したい更新用Entityとしては使わない

対策3:一覧用DTOを直接取得する

public record OrderRow(
        Long orderId,
        String customerName,
        BigDecimal totalAmount) {
}

@Query("""
       select new info.syachiku.audit.OrderRow(
           o.id,
           c.name,
           o.totalAmount)
         from PurchaseOrder o
         join o.customer c
        order by o.id
       """)
List<OrderRow> findOrderRows();

一覧画面で使う列が決まっているなら、Entity全体ではなくDTOを直接取得できます。遅延関連を後からたどらず、公開項目も明確です。

一方、取得した注文をそのまま更新する処理には向きません。参照用クエリと更新用Entity取得を役割で分けます。

すべてEAGERにする対策が危険な理由

// N+1が出たからと、すべてEAGERへ変えない
@ManyToOne(fetch = FetchType.EAGER)
private Customer customer;

EAGERへ変えると、顧客情報が不要なバッチや集計でも常に取得されます。関連がさらに別の関連を持つと、想定以上に大きなオブジェクトグラフを読み込むことがあります。

また、EAGERだから必ず1本のJOINになるとは限りません。JPA実装が追加SELECTを使えばN+1になることもあります。取得戦略はマッピングへ固定するより、画面や処理ごとの必要データに合わせてクエリで指定する方が追いやすいです。

コレクションのJOIN FETCHとページングに注意する

明細が3行ある注文を1件表示するとき、注文一覧で数えたいのは1注文です。明細を結合したSQLでは3行になります。注文と明細を一緒に取るクエリにそのままPageableを渡すと、Hibernate ORM 6.6ではメモリ上でページングすることがあります。

Spring Boot 3.5.16、Hibernate ORM 6.6.53.Final、H2 2.3.232の実習では、spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=trueに設定します。直接ページングするfindPageWithItemsは例外になります。設定は、メモリでページングするクエリを開発時に見つけるためのものです。 Hibernate ORM 6.6の設定リファレンス

修正版はfindPageIdsで注文IDだけに件数制限を掛け、findWithItemsでそのIDの明細をまとめて取得します。注文日は降順、同じ日は注文IDの昇順です。LEFT JOIN FETCHを使うので明細なしの注文も残ります。以下は実習のOrderRepository.javaの全文です。

package info.syachiku.fetchpaging;

import java.util.List;
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;

interface OrderRepository extends JpaRepository<Order, Long> {
    @Query("""
            select o from PurchaseOrder o
            left join fetch o.items
            order by o.placedOn desc, o.id asc
            """)
    List<Order> findPageWithItems(Pageable page);

    @Query("""
            select o.id from PurchaseOrder o
            order by o.placedOn desc, o.id asc
            """)
    List<Long> findPageIds(Pageable page);

    @Query("""
            select o from PurchaseOrder o
            left join fetch o.items
            where o.id in :ids
            """)
    List<Order> findWithItems(@Param("ids") List<Long> ids);
}

INで取得した結果の順序には頼らず、OrderPageService.findPageで最初のID順にDTOを組み立てます。空ページなら2回目の取得を省きます。以下は同じZIPのメソッド抜粋です。

@Transactional(readOnly = true)
public List<OrderRow> findPage(int page, int size) {
    List<Long> ids = orders.findPageIds(PageRequest.of(page, size));
    if (ids.isEmpty()) {
        return List.of();
    }

    Map<Long, Order> byId = orders.findWithItems(ids).stream()
            .collect(Collectors.toMap(Order::id, Function.identity()));
    return ids.stream()
            .map(byId::get)
            .filter(Objects::nonNull)
            .map(order -> new OrderRow(order.id(), order.itemSkus()))
            .toList();
}

1ページを2注文とすると、page=0は注文3(明細A・B・C)と注文1(明細なし)、page=1は注文2(明細D)、page=2は空です。この実行では先の2ページでSELECT各2回、空ページで1回です。一覧はListを返すため、全件数を求めるCOUNTは含めていません。

注文と明細のページングを4ケースで試す

上の顧客取得サンプルとは別の実習です。展開先でJDK 17とMaven 3.9系を使い、mvn testを実行します。ZIPにはEntity、Repository、Service、JUnitテスト、設定、pom.xml、手順書を収録しています。

実習の件数は固定データで測定しています。取得の途中で削除された注文は表示から外します。全件数が必要な画面や、更新がある間もページ間の重複・欠落を防ぐ必要がある画面では、その条件を加えて確認します。

Open Session in Viewに隠されることがある

設定によっては、ControllerやThymeleafの描画中も遅延読み込みが可能です。その場合、ServiceではSQLが少なく見えても、テンプレートのループ中にN本追加されます。

<tr th:each="order : ${orders}">
  <td th:text="${order.customer.name}"></td>
  <td th:text="${order.totalAmount}"></td>
</tr>

テンプレートへEntityを直接渡すと、どの参照でSQLが出たか気づきにくくなります。Service内で必要データを取得し、画面用DTOへ変換して渡すとSQLの境界が明確になります。

N+1を見つけた時の確認順

  1. 遅いリクエストと対象画面を固定する
  2. SQLログを有効にし発行本数を数える
  3. IDだけ変わる同型SELECTを探す
  4. どのgetter・DTO変換・テンプレート参照で出るか特定する
  5. その画面で本当に必要な関連を決める
  6. JOIN FETCH、@EntityGraph、DTO取得から最小の方法を選ぶ
  7. 件数を増やしてSQL本数が増えないことを確認する
  8. 別画面やページングへの副作用を確認する

現場レビューでよくある指摘

// レビューコメント例
注文一覧のDTO変換でcustomerを参照しており、
一覧件数分のSELECTが発行されています。SQLログを確認してください。

// レビューコメント例
対策として全関連をEAGERへ変更していますが、
顧客を使わない処理にも取得負荷が広がります。
この一覧用Repositoryメソッドだけで取得範囲を指定してください。

// レビューコメント例
1対多のfetch joinとPageableを組み合わせています。
件数・ページ境界が正しいか、実データ量でSQLと結果を確認してください。

提出前のセルフチェック

  • 一覧件数を増やしてSQL本数を確認したか
  • ループ内で関連Entityを参照していないか
  • テンプレート描画中のSQLも確認したか
  • 必要な関連だけをまとめて取得しているか
  • すべてをEAGERへ変更していないか
  • 1対多JOINとページングの結果を確認したか
  • 参照専用ならDTO projectionを検討したか
  • SQL・バインド値を本番へ常時詳細出力していないか

Spring Data JPAを体系的に学ぶ参考書

Entityの関連、遅延読み込み、Repositoryクエリを一つの仕組みとして学ぶと、SQLログからN+1の原因を説明しやすくなります。

書籍「Spring徹底入門 第2版」の表紙
商品画像:Amazon.co.jp
PR 入門後の参考書|Spring Framework 5向け

Spring徹底入門 第2版

株式会社NTTデータ (著), 株式会社NTTデータグループ (編集)

アプリを作った後に、DIやDB更新の仕組みを調べる。

DI、MVC、データアクセス、テスト、セキュリティまで幅広く確認できます。小さなWebアプリを一通り作り、Springの仕組みを詳しく学びたい人に向く参考書です。

  • Springで小さなWebアプリを作ったことがある
  • DIやトランザクションの仕組みを確認したい

Spring Framework 5向けです。Boot 3の実装に使うコード・設定は、採用している版の公式資料と照合してください。

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

関連記事Spring BootのRepositoryでよくある戻り値の扱い 検索結果の0件・1件・複数件をどう表すか、基本から確認できます。 記事を読む 関連記事Spring BootのDTO変換はどこでやるべきか Entityを画面へ直接渡さず、必要な値へ変換する場所を整理します。 記事を読む 関連記事Spring Data JPAの@MappedSuperclassと@Embeddableの違い|共通項目をどこへまとめる? JPA Entityのマッピングと共通項目設計を学べます。 記事を読む

まとめ

N+1問題は、一覧を取得する1本のSQLに加え、関連Entityを読むSQLが件数分発行される状態です。Repositoryを1回しか呼んでいなくても起こるため、SQLログで実際の本数を確認します。

必要な関連だけをJOIN FETCH、@EntityGraph、DTO projectionでまとめて取得し、すべてEAGERへ変える対策は避けます。データ件数を増やしてSQL本数、ページング、別画面への影響まで確認しましょう。

-実装・エラー解決
-,