🆖

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. カテゴリテーブルから全カテゴリを取得(1回目のクエリ)
  2. 各カテゴリごとに紐づく商品を取得(カテゴリ件数分クエリを発行)

結果としてクエリの発行回数は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)

このように記述するだけで、内部的には以下のようにクエリが実行されます。

  1. カテゴリテーブルから全カテゴリを取得
  2. 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