Railsにおけるpreload
, eager_load
, includes
の違いを理解できていますか?
ぼくはN+1問題を解消するときにいつもわからなくなって調べています。
毎回調べるのは効率が悪いので、この3つの違いと使い分けをまとめました。
最後まで読めば、3つの違いと使い分けを理解できます。
この記事を書いているぼくは実務経験1年。独学で未経験から従業員300名以上の自社開発企業へ転職しました。実務ではVue.jsとRailsを毎日書いています。
問題
実務にて以下のようなコードを発見しました。
class SampleContext::Infra::TagIndexQuery
def initialize(page: 1)
@tags = Tag.order(:id).paginate(page: page, per_page: 100)
end
def perform
{
list: list,
pagination: {
current_page: @tags.current_page.to_i,
total_pages: @tags.total_pages,
}
}
end
private
def list
@tags.map do |tag|
{
tag_id: tag.id,
tag_name: tag.name,
category_id: tag.tag_category.id,
category_name: tag.tag_category.name,
status: tag.status,
}
end
end
end
このコードを含むAPIを実装したところ、以下のログが出力されました。
sample-rails-1 | user: rails
sample-rails-1 | GET /api/v1/tags/index?page=1
sample-rails-1 | USE eager loading detected
sample-rails-1 | Tag => [:tag_category]
sample-rails-1 | Add to your query: .includes([:tag_category])
sample-rails-1 | Call stack
sample-rails-1 | /usr/src/app/app/contexts/admin_context/tags/infra/index_query.rb:27:in `block in list'
sample-rails-1 | /usr/src/app/app/contexts/admin_context/tags/infra/index_query.rb:23:in `map'
sample-rails-1 | /usr/src/app/app/contexts/admin_context/tags/infra/index_query.rb:23:in `list'
sample-rails-1 | /usr/src/app/app/contexts/admin_context/tags/infra/index_query.rb:12:in `perform'
sample-rails-1 | /usr/src/app/app/contexts/admin_context/tags/domain/index.rb:6:in `perform'
sample-rails-1 | /usr/src/app/app/controllers/api/v1/admin/tags_controller.rb:12:in `perform'
sample-rails-1 | /usr/src/app/config/initializers/query_string_sanitizer.rb:26:in `call'
sample-rails-1 | /usr/src/app/config/initializers/invalid_authenticity_token_rescuer.rb:10:in `call'
sample-rails-1 | /usr/src/app/lib/rack/oidc_api/middleware.rb:24:in `call'
sample-rails-1 | /usr/src/app/config/application.rb:113:in `call'
これはbullet
というN+1問題を検出してくれるgemにより出力されるログです。
Add to your query: .includes([:tag_category])
とあるので素直にincludes
を使おうかと思いましたが、それが適切とは限らないようです、、。
調べたところ、N+1の解決策としてpreload
, eager_load
, includes
の3つがあるようですが、どのように使い分けるかを知らなかったため、迷いました。
解決方法
結論として、次のように使い分けます。
- preload
関連づけるデータ(今回の例だとTagCategoryテーブル)に条件を指定しない場合に用いる - eager_load
関連づけるデータ(今回の例だとTagCategoryテーブル)に条件を指定したい場合に用いる - includes
Railsが内部的にpreload
とeager_load
を自動で使い分けてくれる。
注意:includes はイマイチ
一見するとincludes
が良さそうに思えますが、特に大規模なアプリケーションでは推奨されません。
実行されるクエリがpreload
とeager_load
のどちらなのかを外からは判断しづらく、デバッグやパフォーマンスチューニングがしにくくなってしまうからです。
今回は特にtag_category
に対して条件を指定しないため、preload
を選択しました。
class SampleContext::Infra::TagIndexQuery
def initialize(page: 1)
@tags = Tag.preload(:tag_category).order(:id).paginate(page: page, per_page: 100)
end
def perform
{
list: list,
pagination: {
current_page: @tags.current_page.to_i,
total_pages: @tags.total_pages,
}
}
end
private
def list
@tags.map do |tag|
{
tag_id: tag.id,
tag_name: tag.name,
category_id: tag.tag_category.id,
category_name: tag.tag_category.name,
status: tag.status,
}
end
end
end
捕捉: preloadとeager_loadで発行されるSQLの違い
使い分けとしては↑のとおりでOKですが、それぞれを使うと発行されるSQLにどのような違いがあるか見てみましょう。
preloadの場合
preload
を使う場合、必要なテーブル毎に1回ずつSQLが発行されます。
今回の場合、必要なのはTag
とTagCategory
なので、2つのSQLですね。
sample-rails-1 | Tag Load (0.4ms) SELECT `tags`.* FROM `tags` ORDER BY id LIMIT 100 OFFSET 0
sample-rails-1 | Tag Load (ActiveRecord::Cause) caused by /usr/src/app/app/contexts/admin_context/tags/infra/index_query.rb:23:in `map'
sample-rails-1 | TagCategory Load (0.2ms) SELECT `tag_categories`.* FROM `tag_categories` WHERE `tag_categories`.`id` IN (1, 2, 3, 4, 5, 6, 7)
eager_loadの場合
eager_load
を使う場合、あらかじめ必要なテーブルがLEFT OUTER JOIN
で結合され、1つのSQLでデータを取得します。
sample-rails-1 | SQL (0.5ms) SELECT `tags`.`id` AS t0_r0, `tags`.`tag_category_id` AS t0_r1, `tags`.`name` AS t0_r2, `tags`.`status` AS t0_r3, `tags`.`created_at` AS t0_r4, `tags`.`updated_at` AS t0_r5, `tag_categories`.`id` AS t1_r0, `tag_categories`.`name` AS t1_r1, `tag_categories`.`created_at` AS t1_r2, `tag_categories`.`updated_at` AS t1_r3 FROM `tags` LEFT OUTER JOIN `tag_categories` ON `tag_categories`.`id` = `tags`.`tag_category_id` ORDER BY tag.tag_category_id, tags.id LIMIT 100 OFFSET 0
1回のSQLで済むのでpreload
より優れていそうですが、取得するデータが多ければ多いほど発行されるSQLも長くなってしまうので、逆にパフォーマンスの低下を招く可能性があります。
とりあえずeager_load
を使えばよいわけではないですね。
おわりに
この記事ではpreload
, eager_load
, includes
の違いと最適な使い分けについて解説しました。
パフォーマンスを考慮した実装をする上でこの理解はとても重要です。
しっかりマスターしておきましょう。
また、以下の記事ではワンランク上のRailsエンジニアになりたいと考えている方向けにおすすめの技術書を紹介しています。
こちらの記事もぜひ読んでみてください。
コメント