LoginSignup
3
1

More than 1 year has passed since last update.

【Java】Spring Data JPAでN+1問題が起きないコーディングをしよう。

Last updated at Posted at 2022-07-07

N+1問題とは

1対多の関係にある2テーブルの情報を紐づけて取得する際に、必要以上にSQLが実行されてしまうこと。
  
  対象テーブル(School)
  関連テーブル(Student)

具体的には、対象テーブル(School)に紐づく関連テーブル(Student)を取得するSQLが必要以上の回数で実行されてしまう。

SQLでN+1問題を解説

1つのSchoolに対して、複数のStudentが紐づく。

Schoolテーブル
id
name
Studentテーブル
id
name
school_id

JOINを使えば、全Schoolとそれに紐づくStudentを1つのSQLで取得できる👍

SELECT * FROM School sc JOIN Student st ON sc.id = st.school_id;

これで話は終わり...ではなく、N+1問題の場合はどういうSQLになるかというと、次のようなSQLとなる。

-- 最初に、全Schoolを取得
SELECT * FROM School;
-- 次に、取得したSchoolの数だけ下記のSQLを実行。各Studentを取得する。
SELECT * FROM Student where school_id = ?

N+1の「1」= 全Schoolを「1」回で取得。
N+1の「N」= Schoolの数(N)分だけ、Studentを取得するSQLを「N」回実行する。

これがN+1問題。

Spring Data JPA利用時にN+1問題を回避する

SQLを自分で書く分にはN+1問題は容易に回避できるが、SQLが自動生成されるSpring Data JPAでは、気を付けて実装しないとN+1問題が起きてしまう。

N+1問題が起きないよう、Spring Data JPAでは下記の2つを意識して実装する必要がある。

  1. fetchType
  2. JOIN FETCH

fetchType

Entityクラス内で、関連エンティティに付与する@OneToOne, @OneToMany, @ManyToOne, @ManyToMany のオプションの1つ。

SchoolEntity.java
@OneToMany(fetch = FetchType.LAZY, mappedBy = "school")
private Set<StudentEntity> students = new HashSet<>();
fetchType 意味
EAGER 対象テーブル取得時に関連テーブルも取得するSQLも実行する。
LAZY 関連テーブルのフィールドを参照した時に、関連テーブルを取得するSQLを実行する。

EAGER|対象テーブル取得時に関連テーブルも取得するSQLも実行する。とは?

下記の処理が実行されたタイミングで

List<SchoolEntity> schoolEntities = 
	entityManager.createQuery("SELECT school FROM SchoolEntity school").getResultList();

下記のSQLが自動生成&発行される。
(※実際に生成されるSQLはもうちょい長めなので説明用に簡単にしてます。)

-- 対象テーブル(school)取得用のSQL がまず実行される。
SELECT * FROM school;
-- 関連テーブル(student)取得用のSQL がN回(Schoolの数)実行される。
SELECT * FROM student WHERE school_id = 1;
SELECT * FROM student WHERE school_id = 2;
   --
SELECT * FROM student WHERE school_id = N;

LAZY|関連テーブルのフィールドを参照した時に、関連テーブルを取得するSQLを実行する。とは?

下記の処理が実行されたタイミングで

List<SchoolEntity> schoolEntities = 
	entityManager.createQuery("SELECT school FROM SchoolEntity school").getResultList();

対象テーブル(school)に対するSQLのみ実行される。

SELECT * FROM school;

その後、下記のようにList<SchoolEntity> schoolEntities内のStudentEntityのフィールドにアクセスする処理が実行されると

return schoolEntities.stream()
		.map(e -> modelMapper.map(e, SchoolResponse.class))
        .collect(Collectors.toList());

関連テーブル(student)を取得するSQLが流れる。

SELECT * FROM student WHERE school_id = 1;
SELECT * FROM student WHERE school_id = 2;
   --
SELECT * FROM student WHERE school_id = N;

fetchTypeのLAZYとEAGER、いずれも後述するJOIN FETCHを使わないとN+1問題が発生してしまう。

どちらでもN+1問題が発生するならどっちでもいいのでは?

結論:常にLAZYを設定しよう。EAGERは設定しない。
理由:EAGERだと、関連テーブル不要のときも関連テーブル取得用SQLが実行されるから。

fetchTypeのデフォルト値

アノテーション fetchTypeのデフォルト値
@OneToOne EAGER
@OneToMany LAZY
@ManyToOne EAGER
@ManyToMany LAZY

JOIN FETCH

この記事の主役。N+1問題を解決するやつ。

下記のようにJOIN FETCHを用いたJPQLを記述すると、このチャプターの最初にあげたようなSQL、すなわち全Schoolとそれに紐づくStudentを取得するSQLが自動生成&実行される。

LEFT JOINを使ったJPQL
SELECT DISTINCT school FROM SchoolEntity school JOIN FETCH school.students
生成されるSQL(一部説明用に簡潔にしてます)
SELECT * FROM School sc JOIN Student st ON sc.id = st.school_id;

結論

  1. fetchTypeは常に「LAZY」にする。
  2. 対象テーブルと共に関連テーブルを取得したい場合のみ、LEFT JOINで取得する。

裏付けのための検証

「fetchType」と「JPQLでJOIN FETCH実行」の組み合わせで検証してみた。

fetchType JPQLでJOIN FETCH実行 SQL実行結果 関連テーブルを取得するSQL実行タイミング
1 EAGER する 1回のSQLで対象テーブル+関連テーブルを取得する。 対象Entity取得時
2 EAGER しない 関連テーブルを取得するSQLをN回実行する。 対象Entity取得時
3 LAZY する 1回のSQLで対象テーブル+関連テーブルを取得する。 対象Entity取得時
4 LAZY しない 関連テーブルを取得するSQLをN回実行する。 関連エンティティのフィールド参照時

N+1問題が発生するケース

fetchType JPQLでJOIN FETCH実行 SQL実行結果 関連テーブルを取得するSQL実行タイミング
2 EAGER しない 関連テーブルを取得するSQLをN回実行する。 対象Entity取得時
4 LAZY しない 関連テーブルを取得するSQLをN回実行する。 関連エンティティのフィールド参照時

使うべきパターン

fetchType JPQLでJOIN FETCH実行 SQL実行結果 関連テーブルを取得するSQL実行タイミング
3 LAZY する 1回のSQLで対象テーブル+関連テーブルを取得する。 対象Entity取得時
3
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
1