Ruby on Rails

【Rails】N + 1問題の解決指針(preload, eager_load, includes)

Railsでアプリケーション開発をする際によく発生するN + 1問題について自分なりの解消の指針をかきたいと思います。

N + 1問題とは

ループ処理(each, mapなど)を用いてデータを取得してくる際に、必要以上にSQL文(クエリ)が発行され、レスポンスが遅くなる問題のことです。例えば、1対Nの関係を持ったBookモデルとAuthorモデルがあったとします。

class book < ApplicationRecord
  belongs_to :author
end
class Publisher < ApplicationRecord
  has_many :books
end

このようなモデルがあり、コントローラーに次のように定義して、それぞれの本の著者を個別に取り出したいとします。

class BooksController < ApplicationController
  def index
    @books = Book.all
  end
end
<% @books.each do |book| %>
  <%= book.publisher.name %>
<% end %>

すると、本の数だけSQLが発行されます。

SELECT "books".* FROM "books"
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" = ? LIMIT ?  [["id", 2], ["LIMIT", 1]]

本が増えれば増えるほどデータベースに負荷がかかり、パフォーマンスが悪くなってしまいます。これがN + 1問題です。

N + 1問題の解決法

N + 1問題の解決策としては、3つあります。includes、eager_loadとpreloadの3つです。それぞれ処理が異なりますがそれほど大きくないシステムやポートフォリオレベルであれば、includesを使っておけばパフォーマンスに影響を与えることはないと思います。しかし、ある程度規模の大きいレベルになるとこれらを使い分ける必要があります。結論からいうとincludesは使うべきでなく、eager_loadとpreloadを使い分けていくことになります。

eager_loadとは

eager_loadはLEFT OUTER JOINでテーブルを結合してキャッシュを行います。結合を行うので、小テーブルの絞り込みが可能になります。(後述)

def index
  @books = Book.eager_load(:publisher).all
end
SQL (0.2ms)  SELECT "books"."id" AS t0_r0, "books"."name" AS t0_r1, "books"."published_on" AS t0_r2, "books"."price" AS t0_r3, "books"."created_at" AS t0_r4, "books"."updated_at" AS t0_r5, "books"."publisher_id" AS t0_r6, "books"."sales_status" AS t0_r7, "publishers"."id" AS t1_r0, "publishers"."name" AS t1_r1, "publishers"."address" AS t1_r2, "publishers"."created_at" AS t1_r3, "publishers"."updated_at" AS t1_r4 FROM "books" LEFT OUTER JOIN "publishers" ON "publishers"."id" = "books"."publisher_id"

preloadとは

親と子で2回SQLを発行します。booksテーブルのpublisher_idカラムの値を見て、そこに存在するidを持つpublisherをSELECTします。2回SQLを発行する分、eager_loadより低速でpreload先のテーブルを使っての絞り込みはできません。(後述)

def index
  @books = Book.preload(:publisher).all
end
Book Load (0.2ms)  SELECT "books".* FROM "books"
Publisher Load (0.2ms)  SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" IN (?, ?)  [[nil, 1], [nil, 2]]

includesとは

上述のeager_loadとpreloadを動的に使い分けます。内部でどのように使い分けているかを知るにはリンクを貼った記事を読むのがいいかと思います。(注) 難しいです・・
下の例では、preloadを行っていました。このように開発者が挙動を制御できないというのがincludesを使うべきでない理由です。ただし、規模の小さいアプリだとeager_loadとpreloadの間に大きなパフォーマンスの差はないです。

def index
  @publisher = Publish.includes(:book).all
end
SELECT "books".* FROM "books"
SELECT "publishers".* FROM "publishers" WHERE "publishers"."id" IN (?, ?)  [[nil, 1], [nil, 2]]

テーブルの絞り込みについて

前述の通り、preloadでは小テーブルの絞り込みを行うことができません。具体的にSQLを見ていきましょう。

class BooksController < ApplicationController
  def index
    @books_preload = Book.preload(:publisher).where(publisher: { address: '東京' })
    @books_eager = Book.eager_load(:publisher).where(publisher: { address: '東京' })
  end
end

eager_loadを用いた場合

下のようにきちんと取得できていることが分かります。

<% @books_eager.each do |book| %>
  <%= book.publisher.name %>
<% end %>
SELECT "books"."id" AS t0_r0, "books"."name" AS t0_r1, "books"."published_on" AS t0_r2, "books"."price" AS t0_r3, "books"."created_at" AS t0_r4, "books"."updated_at" AS t0_r5, "books"."publisher_id" AS t0_r6, "books"."sales_status" AS t0_r7, "publisher"."id" AS t1_r0, "publisher"."name" AS t1_r1, "publisher"."address" AS t1_r2, "publisher"."created_at" AS t1_r3, "publisher"."updated_at" AS t1_r4 FROM "books" LEFT OUTER JOIN "publishers" "publisher" ON "publisher"."id" = "books"."publisher_id" WHERE "publisher"."address" = ?  [["address", "東京"]]

preloadを用いた場合

ActionView::Template::Errorを吐くことが分かると思います。このようにpreloadを使うと低速な上に絞り込みにも制限がかかることになります。

<% @books_preload.each do |book| %>
  <%= book.publisher.name %>
<% end %>
SELECT "books".* FROM "books" WHERE "publisher"."address" = ?  [["address", "東京"]]
ActionView::Template::Error (SQLite3::SQLException: no such column: publisher.address)

では、常にeager_loadを使ったほうがいいのではないかと思われると思います。しかし、preloadを使ったほうがいい場合もあります。それは1対多の関係にあり、ページングなどを行う際にDISTINCTを使う場合です。

eager_loadはLEFT OUTER JOINを使うため、データがかぶってしまう場合があるかと思います。そういうときにSQLでは重複を排除するためにDISTINCTを使うのですが、これは内部で並び替えを行っているためパフォーマンスがよくありません。SQLは設計思想上、並び替えを不得意としています。

なので、こういった場合はpreloadを使ったほうがいいのです。しかし、データ数が少ない場合はそれでもeager_loadの方がパフォーマンスがいいこともあります。私もデモで試してみたのですが、eager_loadの方が早かったです。(データ数が10件程度しかなかったからかもしれませんが・・)

いずれにせよ油断しているとN + 1問題は襲ってくるので、注意してログを見ておく必要がありそうです。N + 1問題を検知してくれるGemがあるそうなので、導入したら所感を書いてみます。

まとめ

ポートフォリオを作る段階であるなら、おとなしくincludesを使いましょう。eager_loadなどを考えている時間で機能を増やしたほうがコスパ高いと思います。
実務ではincludesは使わず、eager_loadやpreloadを使いましょう。1対多でページングなどのようにデータの絞り込みを行う場合はpreload、そうでない場合は、eager_loadを使うとパフォーマンスが上がる傾向にあります。

また、新しい知見ができたらここで共有したいと思います。読んでいただきありがとうございました。

ABOUT ME
sakai
東京在住の30歳。元々は車部品メーカーで働いていてましたが、プログラミングに興味を持ちスクールに通ってエンジニアになりました。 そこからベンチャー → メガベンチャー → 個人事業主になりました。 最近は生成 AI 関連の業務を中心にやっています。 ヒカルチャンネル(Youtube)とワンピースが大好きです!