
はじめに
私のチームではJavaとSpring Bootを使用してWebアプリケーションのバックエンドAPIを構築しています。DBとのやり取りにはORMであるSpring Data JPAを使用しており、基本的には生のSQLをあまり使用せず、ORMに依存した開発を進めてきました。しかし、自動生成されるSQLでは意図通りとはいかないこともよく発生します。特に気を付けなければいけないのが「N+1問題」です。N+1問題ではデータ量が増えれば増えるほど影響が大きくなるため、早急な対処が必要になります。今回はそんなN+1問題を発生させないためにはどのように進めていけばいいか、その方法を紹介します。
N+1問題とは?
N+1問題とは、取得したい対象テーブルに加えて、関連するテーブルからの情報を取得するために追加のSQLが発行されてしまう状態を指します。
この追加SQLが大量になってしまうと当然DBへのアクセスも増え、レスポンスまでの時間が想定以上に長くなってしまいます。
N+1問題に関連するSQLクエリは、以下の2つに分類されます。
- N個の関連テーブルを持つ取得したいテーブルを呼び出すSQL(1個)
- 取得したいテーブルの関連テーブル(N個)を呼び出すSQL(N個)
つまり、関連テーブルを呼び出すSQLは1テーブルにつき1つのSQLを発行するため、関連テーブルがN個存在すれば発行されるSQLはN個になります。
そのため、全部で発行されるSQLは N + 1
になるというのがこの問題です。
N+1問題への対処
前提
ここからはN+1問題にどのように対処すればよいかを紹介します。
まず初めに、今回の説明に用いる構成を紹介しておきます。使う要素は最小限にし、会社(Company)とそこに勤める従業員(Employee)の2つを使って説明します。
Spring Data JPAではDBの情報と実装の中で使用するこれらの情報を紐づけるためにエンティティをそれぞれ作成します。
説明に必要な要素は、できるだけ最小限にしています。
Company.java
package jp.co.alpha.sample.model;
import java.util.ArrayList;import java.util.List;
import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.OneToMany;import lombok.AllArgsConstructor;import lombok.Data;
@Entity@Data@AllArgsConstructorpublic class Company {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id;
private String name;
@OneToMany(mappedBy = "company") private List<Employee> employee;
public Company() { employee = new ArrayList<>(); }}
要素は以下の3つです。
- id:会社固有のID
- name:会社の名称
- employee:会社に所属する従業員一覧
従業員(Employee)との関連付けでは、1つの会社に対して複数の社員が所属することが考えられるため、会社と従業員は「1対多」の関係になります。
Employee.java
package jp.co.alpha.sample.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.Entity;import jakarta.persistence.GeneratedValue;import jakarta.persistence.GenerationType;import jakarta.persistence.Id;import jakarta.persistence.JoinColumn;import jakarta.persistence.ManyToOne;import lombok.Data;
@Entity@Datapublic class Employee {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private int id;
private String name;
@ManyToOne @JoinColumn(name = "company_id", insertable = false, updatable = false) @JsonIgnore private Company company;
}
要素は以下の3つです。
- id:従業員固有のID
- name:従業員の名前
- company:従業員の所属する会社
- DB上ではCompanyテーブルのidをcompany_idと関連付けることで実現します。
今度は従業員側から会社(Company)との関連付けを行い、複数の従業員が1つの会社に所属することが考えられるため、従業員と会社の関係は「多対1」の関係になります。
今回の解説は上記2つのエンティティをベースにします。
仮データは以下のようなデータを予め登録しておきます。
Company
id | name |
---|---|
1 | testCompanyA |
2 | testCompanyB |
3 | testCompanyC |
Employee
id | name | company_id |
---|---|---|
1 | アルファ一郎 | 1 |
2 | アルファ二郎 | 1 |
3 | アルファ三郎 | 2 |
4 | アルファ四郎 | 2 |
これから解説していくN+1問題への対処は、以下の3つの段階に分けて説明します。
- FetchType
- エンティティの作成時に考慮すべき内容です
- JOIN FETCH
- SQLを発行する際に考慮すべき内容です
- Jacksonのアノテーション
- APIで値をシリアライズして返却するときに考慮すべき内容です
FetchType
まずはエンティティ作成時に考慮すべきFetchType
について解説します。
FetchType
はエンティティのフェッチ(取得)を制御するための指定であり、どのように関連エンティティを取得するかを定義します。FetchType
には2種類(EAGER
とLAZY
)のオプションがあります。
N+1問題の具体例も交えて、まずはCompany
から見たEmployee
のFetchType
を2種類設定して、どのような違いが出るかを確認します。
FetchType=EAGER
Company.java
@OneToMany(mappedBy = "company", fetch = FetchType.EAGER) private List<Employee> employee;
ここでは、Spring Data JPAのクエリ生成メソッドを利用してSQLを生成します。
Company company = companyRepository.findById(1).get();
次のSQLが発行されました。 (読みやすさのため、適宜改行しています)
select c1_0.id,c1_0.name,e1_0.company_id,e1_0.id,e1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?
FetchType=LAZY
Company.java
@OneToMany(mappedBy = "company", fetch = FetchType.LAZY) private List<Employee> employee;
次のSQLが発行されました。
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
2つのSQLを見比べると、FetchType=EAGER
の場合は関連エンティティであるEmployee
も同時に取得されているのに対し、FetchType=LAZY
の場合はCompany
のみが取得されていることが確認できます。
さらに追加でCompany
の社員1名の名前をログに表示するようにしてみます。
FetchType=EAGER
次のようなSQL発行とログ出力になりました。
select c1_0.id,c1_0.name,e1_0.company_id,e1_0.id,e1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?アルファ一郎
FetchType=LAZY
次のようなSQL発行とログ出力になりました。
Hibernate: select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?Hibernate: select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?アルファ一郎
Employee
を確認する前はFetchType=EAGER
によってのみEmployee
の情報が取得されていましたが、Employee
を参照しようとした際にFetchType=LAZY
もSQLを追加で発行し、Employee
の情報を取得していることが確認できます。
N+1問題とは、今回の場合で言うとFetchType=LAZY
によって発行された2つ目のEmployee
を取得するためのSQLが大量に生成されてしまうことを指します。
この比較の結果から、FetchType=EAGER
は対象エンティティ取得時に関連エンティティも同じSQLで取得するのに対し、FetchType=LAZY
は関連エンティティを該当エンティティの情報にアクセスした際に追加で取得するという違いが明確になります。
今回のFetchType
の指定は、それぞれをエンティティに明記することで分かりやすくしていますが、FetchType
には関連エンティティとの関係に基づくデフォルトがあります。これは前述の1対多や多対1などの設定に関連しています。
この設定はエンティティクラス間のリレーションを定義するもので、次の4つを使って表現できます。
- OneToOne(1対1)
- OneToMany(1対多)
- ManyToOne(多対1)
- ManyToMany(多対多)
それぞれでFetchType
のデフォルトは次のようになります。
FetchType | クラス間リレーション |
---|---|
EAGER | OneToOne, ManyToOne |
LAZY | OneToMany, ManyToMany |
エンティティの関係性に応じて設定を行いますが、今回はCompany
とEmployee
の関係は「会社(Company)とそこに勤める従業員(Employee)」なため、Company
側のエンティティではEmployee
に対してOneToMany
となり、Employee
側のエンティティではCompany
に対してManyToOne
と記述します。
先ほどのFetchType
の表を参照すると、Company
でのEmployee
の扱いはLAZY
になります。では、先ほどの比較をもとにCompany
のEmployee
のFetchType
をEAGER
に変更することが適切でしょうか?
その判断には、もう1つの重要な要素であるJPQL(Java Persistence Query Language)
を考慮する必要があります。
JPQLはSQLに似ていますが、DBのテーブルやカラムではなく、エンティティやその属性に対して操作を行います。これにより、DBのリレーショナルモデルとJavaのオブジェクトモデルが直接結び付きます。書き方は普通のSQLとは異なり、エンティティを対象にしたクエリを記述します。
実際の運用では、生成クエリによって容易に作成できるSQL(例えば、findBy、readBy、queryBy、countBy、getBy)の他に、さまざまなメソッドが必要になります。その際にJPQLが使用されます。
JPQLはSQLとは異なると説明しましたが、基本的な書き方はほぼ同じですので、先ほどのfindById()
と同様に、id=1のCompany
を取得するJPQLを作成してみます。
CompanyRepositoryで次のように記述します。
CompanyRepository.java
@Query("select a from Company a where id = :id") Company findByCompanyId(int id);
取得されるのは、先に述べたtestCompanyA
です。Company
エンティティにあるEmployee
のFetchType
をEAGER
にしてこのメソッドを実行してみます。
実行後、次のSQLが発行されました。
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?
FetchType
がLAZY
の時に発行されたSQLと同じです。この原因はJPQLがSQLと異なる実行モデルを持っているためです。JPQLを使用してクエリを記述する場合、Hibernate(または他のJPA実装)は、JPQLのクエリが実行された結果を取得し、その関連するエンティティ(EAGER Fetch)を読み込むために追加のSELECT文を発行します。そのため、FetchType
がEAGER
に設定されている場合、関連するすべてのエンティティに対して追加のSQLが自動的に発行されます。
同じJPQLでFetchType
がLAZY
に設定された場合、Employee
を参照する前に発行されるSQLは、findById()
の時と同様に次の1つだけです。
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
これまでにFetchType
について挙動が明らかになったので、FetchType
に対応する関連エンティティの取得について整理します。
LAZY
- 生成クエリ:関連エンティティはアクセス時
- JPQL:関連エンティティはアクセス時
- N+1問題が起きる:関連エンティティにアクセスした際に発生
EAGER
- 生成クエリ:関連エンティティも1つのSQLで取得
- JPQL:関連エンティティも必ず追加SQLで取得
- N+1問題が起きる:生成クエリは問題なし。JPQLでは必ず発生
実際の運用を行う中でJPQLを多く実装することを考えると、FetchType
にEAGER
を指定することはリスクがあると言えます。LAZY
の場合、関連エンティティにアクセスしない限り不要なSQLは発行されず、N+1問題も発生しません。
しかし、FetchType
にLAZY
を指定しても、関連エンティティを参照すると必ず追加のSQLが発行されてしまいます。この問題を解決するのがJOIN FETCH
です。
JOIN FETCH
JOIN FETCH
は関連するエンティティをJOINして一括で取得することで、発行するクエリの数を減らすための方法です。
JOINの形式には次の4つがあります。
- LEFT JOIN FETCH
- 関連エンティティが存在しない場合でも対象のエンティティは取得される
- LEFT OUTER JOIN FETCH
- LEFT JOIN FETCH と同様
- INNER JOIN FETCH
- 関連エンティティが存在しない場合は、対象のエンティティは取得されない
- JOIN FETCH
- INNER JOIN FETCH と同様
Company
を取得するJPQLの中で、Employee
をJOINで取得するようにしてみます。
CompanyRepository.java
@Query("select c from Company c " + "left join fetch c.employee e " + "where c.id = :id") Company findByCompanyId(int id);
FetchType
がデフォルトのLAZY
の状態でfindByCompanyId()
メソッドを実行すると、発行されたSQLは次のようになります。
select c1_0.id,e1_0.company_id,e1_0.id,e1_0.name,c1_0.name from company c1_0 left join employee e1_0 on c1_0.id=e1_0.company_id where c1_0.id=?
1つのSQLで関連するエンティティも同時に取得できました。
JOINの形式を比較するため、Employee
でcompany_id
が設定されていない「testCompanyC」を指定して、LEFT JOIN FETCH
とINNER JOIN FETCH
を試してみます。
説明の通り、LEFT JOIN FETCH
ではCompany
は取得でき、employee
のサイズが0になりました。一方、INNER JOIN FETCH
で記述した場合、Company
の取得もできないことが確認できました。
これらの結果から、JPQLで任意のクエリを実行してエンティティを取得する際の整理は次のようになります。
FetchType=EAGERの場合
何もしなければ関連するエンティティを取得するSQLが必ず発行されることから、JOIN FETCH
は必須です。
FetchType=LAZYの場合
使用頻度を考慮して、JOIN FETCH
を使用するかどうかを判断する必要があります。参照がない場合は、JOIN FETCH
がなくても問題ありません。
ここまででFetchType
をLAZY
に設定し、必要な関連エンティティについてJOIN FETCH
を活用した結果、N+1問題の原因となる無駄なSQLが発行されないことが確認できました。これにより、効率的なデータ取得が実現できます。
しかし、最後にもう一つ注意点があります。それはJOIN FETCH
の章に書いた「関連付けアノテーションがLAZY
の場合は使用頻度を考慮してJOIN FETCH
するかを判断する必要がある」という部分です。これはFetchType
がLAZY
の場合、関連エンティティの参照がない場合には追加のSQLが発行されないことを意味するため、無理にJOIN FETCH
を使用する必要はないということです。ただし、関連エンティティがどのようなタイミングで参照されるかには注意が必要です。
Jacksonのアノテーション
私たちが使用するAPIでは、今回のCompany
のようなエンティティを返却値として使用しているものが多数あります。
どのような挙動になるかを確認するために、実際にCompany
を返却するAPIを作成してみます。
CompanyController.java
@GetMapping({ "/company", "/company/" }) public Company getCompany() { return companyService.getCompany(); }
このようにして、Company
を返却するAPIをControllerに実装しました。
getCompany
メソッドの中では、JPQLを使ってCompany
を取得するメソッドを呼び出し、その結果を返しています。
Company
の関連エンティティであるEmployee
はOneToMany
の関係にあるため、デフォルトでFetchType
はLAZY
です。今回はEmployee
は不要なため、JPQLの中でもJOIN FETCH
を使用しないことにします。
getCompany
メソッドの実行により、次のSQLが発行されました。
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
Company
だけを返却すればよいため、Employee
に関する余計なSQLは発行されておらず、想定通りの結果です。
しかし、CompanyController.java
の最後のreturnでフロントエンドに返却する際に、次のSQLが発行されていました。
select e1_0.company_id,e1_0.id,e1_0.name from employee e1_0 where e1_0.company_id=?select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
それぞれのSQLが次の処理を行っていることが分かります。
- Employeeの取得
- Companyの取得
Spring BootのRest APIでは、戻り値として設定したクラスのインスタンスを、搭載されているJacksonライブラリを用いてシリアライズ(JSON化)し、レスポンスを返します。
今回の状況では、このシリアライズのタイミングで関連エンティティを参照し、追加のSQLが発行されてしまっていることが分かります。2つ目のCompany
はEmployee
から見た場合、ManyToOne
の関係にあります。そのため、FetchType
がEAGER
に設定されている場合、対象のEmployee
を取得する際に一緒にSQLが発行されてしまうことも分かります。
この状況では、FetchType
をLAZY
に設定しても、最終的に関連エンティティを参照することになり、意図しないSQLが発行されてしまいます。
これはJacksonの@JsonIgnore
を使用して対処できます。
@JsonIgnore
は次のようにエンティティ内のプロパティに追記することで、指定したプロパティをシリアライズ、デシリアライズの対象から外すことができます。
Company.java
@OneToMany(mappedBy = "company") @JsonIgnore private List<Employee> employee;
再度同じAPIを実行してみると、SQLは次のようになります。
select c1_0.id,c1_0.name from company c1_0 where c1_0.id=?
今度はCompany
を取得するSQLのみが発行されていることが確認できました。さらに、このJacksonの設定には次のようなものもあります。
@JsonIgnoreProperties
は、@JsonIgnore
の設定を一括で管理することができます。
@JsonInclude
は、対象外とするプロパティの条件を指定することができます。
- Include.NON_ABSENT
- nullまたはOptional.empty()等の場合に対象外とする
- Include.NON_NULL
- nullの場合に対象外とする
- Include.NON_EMPTY
- 空文字の場合に対象外とする
- Include.NON_DEFAULT
- デフォルト値の場合に対象外とする
最初に述べた@JsonIgnore
では必ず対象から外れますが、これらの設定では中身に応じて対象とするかどうかを判断できます。
次のようにInclude.NON_EMPTY
を利用すれば、配列にEmployee
が存在する場合には返却されます。一方で、Employee
が存在しない「testCompanyC」のような場合には、追加のSQLが発行されるのを防ぐことができます。
以下はCompany
クラスでの実装例です。
Company.java
@OneToMany(mappedBy = "company") @JsonInclude(Include.NON_EMPTY) private List<Employee> employee;
ネイティブクエリ
最後に、Spring Data JPAではJPAのエンティティマネージャを介して直接SQLを使用してDBを操作できるネイティブクエリ(Native Query)
があります。
使用方法としては、@Query
アノテーションを使い、クエリにnativeQuery = true
オプションを指定します。
以下はCompanyRepository
におけるネイティブクエリの実装例です。
CompanyRepository.java
@Query(value = "select * from company where id = :id", nativeQuery = true) Company findCompanyByNativeQuery(int id);
FetchType
の指定ができない場合などは、このネイティブクエリと@JsonIgnore
を組み合わせることで、不要なSQLの発行を防ぐことができます。
ネイティブクエリとJPQLには次のような違いがあります。
特徴 | ネイティブクエリ | JPQL |
---|---|---|
構文 | SQLそのもの | エンティティと属性を用いたクエリ |
DB依存 | DBに依存 (DB固有の構文が使える) | DB非依存 |
実行時パフォーマンス | 最適化されたSQLが 直接実行されることが可能 | JPA仕様に従った実行 |
データマッピング | カラム名とエンティティの フィールド名が一致する必要がある | エンティティとして 自動的にマッピングされる |
使用する際の柔軟性 | DB固有の機能や 関数が使用可能 | JPA API及びエンティティ構造 に基づく柔軟性 |
上記のように、ネイティブクエリを使用することのメリットもあるため、開発の状況に応じて使い方を適切に選ぶことが重要です。
おわりに
Spring Data JPAを使用する際にN+1問題を発生させないための方法を紹介しました。
エンティティに対応した生成クエリやJPQLは内容がシンプルであり、適切に活用することで開発者の負担を軽減し、より可読性の高いコードを作成できます。しかし、これには今回のN+1問題のように気を付けるべき点も存在するため、正しく理解して対応することが重要です。また、何か問題が発生した際には、1つ1つのSQLがなぜ発行されるのかを確認し、起きている事象を解析することが問題解決に非常に重要であると、記事を書いていて改めて感じました。
この記事が皆さんの開発に役立てば幸いです。