🌊

JPAを使ったら発生するN+1問題について

2022/11/09に公開約3,400字

初めまして、Junior Back-end Developer、SIMOKITAZAWAです。
色々と足りないところがあるかと思いますがよろしくお願い致します。

私がZENNを始めた理由は、

  1. 私が勉強したことをまとめたい
  2. 勉強の内容を共有して一緒に成長したい
  3. 私が間違って理解している部分について先輩たちに教えてもらいたい

このような理由で始めることになりました。
たくさんの教えをお願いします。

N+1問題は何?

ServerがDatabaseにあるデータを照会する時に、1つのQueryで処理できると期待したが1つのQuery+N個のQueryがDatabase送れることです。
1つの照会でそのくらいは大丈夫だと思われるかもしれませんが、100万人のuserが照会すると100万個のQuery+(100万 * N)個Queryになります。そうするとServerは爆発するようになり、性能がよくなくなります。

N+1問題はなぜ発生するか?

JPAはEntityとEntityに様々な関係マッピングしてくれます。
@OneToMany, @ManyToOne, @OneToOne, @ManyToMany があります。

例でN+1問題に確認しましょう。

@OneToManyを使っているEntityがあるとしましょう。

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Board {

    @Id @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String title;

    @Column(nullable = false)
    private String body;

    @OneToMany(mappedBy = "comment", fetch = FetchType.LAZY)
    private List<Comment> comments;
}
@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
public class Comment {

    @Id
    @GeneratedValue
    private Long id;

    @Column(nullable = false)
    private String comment;
}

Boardという名前を持っているEntityとCommentという名前を持っているEntityがあります。
Boardには多数のCommentを書くのができますね、なのでboardId=1のBoardを照会するとboard1に書いてあるCommentも一緒に照会します。

public interface BoardRepository extends JpaRepository<Long, Board> {
}

JpaをextendsしているBoardRepositoryで全てのBoardを照会してみます。
JPAはEntityを重点に開発を支援するため、QueryもEntityを中心にQueryを作成するJPQLという機能を提供しています。

boardRepository.findAll();

上のコードを見てJPQLは

SELECT * FROM BOARD

DatabaseにQueryを送ります。

findAll; 例として100個のBoardがあるEntityだったら全てのBoardを照会するので

boardId = 1 ....  boardId = 100

Board                               ....   Board
Id : 1                              ....   Id : 100
title : testTitle1;                 ....   title : testTitle100;
body : testBody1;                   ....   body : testBody100;
List<Comment> comments              ....   List<Comment> comments

getCommentId;                       ....   getCommentId;
getComment;                         ....   getComment;

1から100までのデータが見えます。
ここで私たちはfetch戦略をFetchType.LAZYにしました。
その結果、照会できたBoardsでそれぞれのCommentsをporxyオブジェクトとして持っています。
なぜかというとCommentsを今は使わず、後で使用する時にデータをインポートするようにするからです。

まだ、ここまでにはQueryが1個です。
しかし、BoardにあるCommentが何個かわかりたくて、Databaseにrequest送ります。

board.getComments.getSize();

そうするとJPAはCommentがporxyオブジェクトなので、キャッシュストレージでデータがあるか確認します。確認した結果データないのでQueryを作ってデータを探します。

SELECT * FROM COMMENTS WHERE BOARD_ID = 1;

...

SELECT * FROM COMMENTS WHERE BOARD_ID = 100;

これを100まで繰り返します。
全てのBoardを照会するのに1個のQueryではなく、どんどんQueryが増えてDatabaseに行きます。

fetch Eager

LAZYでこの+N+1問題が発生したら、EAGERを使えば良いじゃない?と思われますね。

@OneToMany(mappedBy = "comment", fetch = FetchType.EAGER)
    private List<Comment> comments;

JPQLがEntity基準でQueryを作ると述べました。
そのため、JPQLは初めてQueryを作る時に関係のあるEntityは照会気にせず、照会対象となるEntity基準だけでQueryを作ります。

その後、関連するすべてのEntityを照会するため、N個の追加Queryが発生します。

また、Eagerを適用すると、予想できなかったSQLが発生します。
Queryを作成する際に関係のあるEntityをすべてすぐに持ってくるため、Join Queryが出ます。
@OneToManyが5つあり、全部EAGERに設定されていると考えてみましょう。
joinが5つ発生します。実務ではテーブルの方が多いので想像以上のJoin Queryが出ます。

そのため、実務ではLAZYを使用することをお勧めします。

fetch join

N+1問題を解決する方法の一つは、fetch joinを使用する必要があります。

SELECT BOARD.*, COMMENTS.* FROM BOARD join fetch COMMENTS

proxyオブジェクトではなく、本物のオブジェクトを取得します。最初に関連するデータを一度にインポートしてオブジェクト化してくれたので、DBを介さずにデータを取り出して返します。
これにより、1つのqueryで問題が解決されます。

Side Effects

fetch joinでN+1問題は解決されました。
しかし、pagingをする時、また別の問題が起こります。これを解決するためにQuerydslという技術を使えばいいです。

今回はここまでです。
ありがとうございます。

Discussion

ログインするとコメントできます