【Rails】N+1問題におけるpreload, eager_load, includesの使い分け

Railsにおけるpreload, eager_load, includesの違いを理解できていますか?

ぼくはN+1問題を解消するときにいつもわからなくなって調べています。

毎回調べるのは効率が悪いので、この3つの違いと使い分けをまとめました。

最後まで読めば、3つの違いと使い分けを理解できます。

この記事を書いているぼくは実務経験1年。独学で未経験から従業員300名以上の自社開発企業へ転職しました。実務ではVue.jsとRailsを毎日書いています。

この記事で解説するバージョンはRuby3.0.3, Rails6.1.6.1です!

目次

問題

実務にて以下のようなコードを発見しました。

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が内部的にpreloadeager_loadを自動で使い分けてくれる。

注意:includes はイマイチ

一見するとincludesが良さそうに思えますが、特に大規模なアプリケーションでは推奨されません。

実行されるクエリがpreloadeager_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が発行されます。

今回の場合、必要なのはTagTagCategoryなので、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エンジニアになりたいと考えている方向けにおすすめの技術書を紹介しています。

こちらの記事もぜひ読んでみてください。

あわせて読みたい
【実務経験1年以上向け】Rubyで設計を学べる技術書3選 Rubyで設計を学べる技術書を知りたくないですか?この記事ではRubyエンジニア向けにサンプルコードがRubyで書かれていて設計を学べる技術書を3つ紹介しています。自分に必要なのはどの1冊なのか、ぜひ考えてみてください。
参考文献
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

未経験でSESから従業員300名以上の自社開発企業に転職しました。業務や個人開発で直面した問題や、転職・学習の経験を発信していきます。

コメント

コメントする

目次