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つを意識して実装する必要がある。
- fetchType
- JOIN FETCH
fetchType
Entityクラス内で、関連エンティティに付与する@OneToOne, @OneToMany, @ManyToOne, @ManyToMany のオプションの1つ。
@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が自動生成&実行される。
SELECT DISTINCT school FROM SchoolEntity school JOIN FETCH school.students
SELECT * FROM School sc JOIN Student st ON sc.id = st.school_id;
結論
- fetchTypeは常に「LAZY」にする。
- 対象テーブルと共に関連テーブルを取得したい場合のみ、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取得時 |