🆖
N+1問題とは何か、GORMでの具体例と解決法
ORMを使うとSQLを意識せずデータ操作が可能になりますが、安易な実装で陥りやすいのが「N+1問題」です。
この記事では以下を解説します。
- N+1問題とは何か
- GORMでの具体的な発生例
- GORMでの解決方法
N+1問題とは
N+1問題とは、親レコードN件を取得した後に、それぞれの親レコードに紐づく子レコードを取得するために追加でN回クエリを発行し、合計でN+1回のクエリが発行される問題を指します。
例えば以下のテーブルを考えます。
- 親テーブル: Category
- 子テーブル: Product
Category
id | name |
---|---|
1 | 果物 |
2 | 野菜 |
3 | 肉 |
Product
id | name | category_id |
---|---|---|
1 | レモン | 1 |
2 | オレンジ | 1 |
3 | リンゴ | 1 |
4 | トマト | 2 |
5 | キャベツ | 2 |
6 | レタス | 2 |
7 | ベーコン | 3 |
8 | ウィンナー | 3 |
「カテゴリごとに商品を一覧で取り出したい」という場合、何も考えずに実装すると以下の流れになります。
- カテゴリテーブルから全カテゴリを取得(1回目のクエリ)
- 各カテゴリごとに紐づく商品を取得(カテゴリ件数分クエリを発行)
結果としてクエリの発行回数はN+1回となり、カテゴリが1000件あれば1001回クエリが発行されることになります。
このようにクエリ数が増えると通信回数が増加し、DBの負荷が高まりパフォーマンスが低下します。
GORMでのN+1問題発生例
Go + GORMで例を示します。
モデル定義
type Category struct {
ID uint
Name string
Products []Product
}
type Product struct {
ID uint
Name string
CategoryID uint
}
N+1問題が発生するコード
var categories []Category
db.Find(&categories) // 1回目のクエリ
for i := range categories {
var products []Product
db.Where("category_id = ?", categories[i].ID).Find(&products) // カテゴリ件数分クエリ
categories[i].Products = products
}
この場合、カテゴリ件数Nが3件であれば合計4回クエリが発行されます。件数が増えるほどクエリ発行回数は増加し、N+1問題となります。
GORMでの解決方法(Eager Load)
GORMでは Preload を使うことで関連データを事前にまとめて取得(Eager Load)し、クエリ数を削減できます。
修正例
var categories []Category
db.Preload("Products").Find(&categories)
このように記述するだけで、内部的には以下のようにクエリが実行されます。
- カテゴリテーブルから全カテゴリを取得
- Productテーブルから category_id IN (...) で必要な商品をまとめて取得
実際のクエリ発行回数は1回または2回で済み、ループ内で子テーブルを取得する場合と比べてクエリ数を大幅に削減できます。
Eager LoadとLazy Loadの使い分け
- Eager Load(Preload)
親レコードと関連する子レコードをまとめて取得するため、一覧表示や関連データを頻繁に使う場合に有効。 - Lazy Load
必要なタイミングで子レコードを取得するため、使用頻度が低いデータや重いデータを扱う場合に有効。
パフォーマンス改善が目的であれば、N+1問題を避けるためにEager Loadを基本としつつ、必要に応じてLazy Loadを併用する設計が望ましいです。
まとめ
- N+1問題は親N件に対して子N件の取得でN+1回クエリが発行される問題
- GORMでループ内で関連データを取得すると簡単にN+1問題が発生する
- GORMの Preload を使うことでEager Loadを実現しクエリ数を削減できる
- N+1問題の解消はアプリケーションパフォーマンスの改善につながる
Discussion