Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
98
Help us understand the problem. What is going on with this article?
@ryosuketter

ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由

はじめに

ActiveRecordでN+1問題やスロークエリを解消するためにeager loadingを行う場合、普段Railsを使って開発されている方であれば、パッと思いつくのはincludesではないでしょうか?もしくは、preloadeager_loadを使用しますよね。

この記事では、Webサイト表示パフォーマンスを保つため、ActiveRecordのメソッドの違いや、どいういう場合に使ったら良いのか、どいういう場合には使わない方が良いのかについて書きました。

実際に Railsアプリケーションを作成して解説していきます。

用語の説明と分類「ORM の Eager loading と Lazy loading」

Webサイト表示パフォーマンスを保つため、ORM(RailsではActiveRecord)では、Eager loading と Lazy laodingというものをサポートしています。

Eager loadingとは

予めメモリ上にActive Recordで情報を保持する方法です。これによって、素早いレンダリングが可能になります。しかし、アソシエーションしているテーブルにある情報が膨大な場合、大量のメモリを消費することになります。

ActiveRecordのメソッドで言えば、preload, eager_load, includesなどです。

Lazy loadingとは

Railsに限らず、Lazy loadingは遅延読み込みなどと言われたりしています。これは、JOINしたテーブルの情報が必要になった時に SQLを発行します。なので、メモリを確保する量は少なくてすみますが、JOINするテーブルを参照するたびSQLを発行するためWebサイト表示パフォーマンスを悪くする場合があります(N+1問題)。

ActiveRecordのメソッドで言えば、joinsなどです。

補足

出てくる用語の理解が曖昧な場合、参照記事をぜひ読んください。

主な違い

メソッド SQL(クエリ) キャッシュ アソシエーション先のデータ参照 デメリット
joins INNER JOIN しない できる N + 1問題
preload JOIN せずそれぞれSELECT する できない IN句大きくなりがち
eager_load LEFT JOIN する できる LEFT JOINなので 相手が存在しなくても全部ロードしてしまう
includes 場合による する できる ただしく理解してないと挙動がコントロールできない

結論「どういう場合に使ったら良いか」

主な結論を一覧にしました。

  • includesはなるべく利用しない方が良い(理由は後述します)

    • 理由:意図しない挙動を防ぐため
    • 代わりに、preloadeager_loadを使う
  • preload

    • どんな場合に使うといいか : 多対多のアソシエーションの場合
    • できないこと : アソシエーション先のデータ参照(Whereによる絞り込みなど)
    • 注意 : データ量が大きいと、IN句が大きくなりがちで、メモリを圧迫する可能性がある
  • eager_loadはどんな場合に使うといいか

    • 1対1あるいはN対1のアソシエーションをJOINする場合(belongs_to, has_one アソシエーション)
    • JOINした先のテーブルの情報を参照したい場合(Whereによる絞り込みなど)
  • joinsはどんな場合に使うか

    • メモリの使用量を必要最低限に抑えたい場合
    • JOINした先のデータを参照せず、絞り込み結果だけが必要な場合
      • 逆に言うと、引用先のデータを参照しない場合、使用しないほうがいいです

補足「preloadとeager_loadの使い分け」

データ量が膨大な場合だと、preloadeager_loadは、いずれにせよ多くのメモリを必要とします。なので、自分はアソシエーションの種類によって使い分けるべきと考えます。

N対Nのアソシエーションの場合はpreload

データ量が増えるほど、eager_loadよりも、preloadの方がSQLを分割して取得するため、レスポンスタイムは早くなるので、preloadをオススメします。

1対1あるいはN対1のアソシエーションの場合はeager_load

データ量が増えても、1回のSQLでまとめて取得した方が効率的な場合が多いと思うので、eager_loadをオススメします。

作成するRailsアプリケーションの概要

具体例を示すために、実際にRailsで簡単なアプリケーションを作成しました。

作成するRailsアプリケーションは本や記事のレビューサイトを仮定しています。

  • author : 著者
  • article : 著書
  • review : レビュー
  • user : レビュー者

著者は自身が執筆した本や記事の情報を持てます。その本に対して、ユーザーはレビューを行えるサイトです。

モデル

app/models/author.rb
class Author < ApplicationRecord
  has_many :articles
end
app/models/article.rb
class Article < ApplicationRecord
  belongs_to :author
  has_many :reviews
  has_many :users, through: :reviews
end
app/models/review.rb
class Review < ApplicationRecord
  belongs_to :article
  belongs_to :user
end
app/models/user.rb
class User < ApplicationRecord
  has_many :reviews
  has_many :articles, through: :reviews
end

DB

スクリーンショット 2019-12-29 10.52.56.png

ER図の作り方は、下記の記事が参考になりました。

MySQLWorkbenchでDBからER図(モデル)を作成 – リバースエンジニアリング - 親バカエンジニアのナレッジ帳

環境

OS Ruby Rails(ActiveRecord)
macOS High Sierra 10.13.6 2.6.5 5.2.4

joins

どんな場合に使うと良いのかというと、joinsの場合は、JOINした先のデータを参照せず、絞り込み結果だけが必要な場面かなと思います。
例えば、サイトで紹介している記事数を表示させたい場合、joinsが良いでしょう。
具体例を作っていきます。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @article_size = Author.joins(:articles).count
  end
end
app/views/articles/index.html.erb
<h1>Articles#index</h1>
<p>このサイトで紹介している記事数<%= @article_size %></p>
発行されたSQL
SELECT COUNT(*) FROM `authors` INNER JOIN `articles` ON `articles`.`author_id` = `authors`.`id`

preload

どんな場合に使うと良いのかというと、preloadの場合は、N対Nのアソシエーションを取得する場面かなと思います。
例えば、レビューがある本の、最初のレビューユーザー名とそのレビュー内容の一覧を表示したい場合、preloadが良いでしょう。
具体例を作っていきます。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.preload(:reviews, :users)
  end
end
app/views/articles/index.html.erb
<h1>Articles#index</h1>
<p>Title / Review サンプル(1) / サンプル Review ユーザ名(1)</p>
<ul>
  <% @articles.each do |article| %>
      <% if article.reviews.present? || article.users.present? %>
        <li>
          <%= article.title %> /
          <%= article.reviews.first.body %> /
          <%= article.users.first.username %>
        </li>
      <% end %>
  <% end %>
</ul>
発行されたSQL
Article Load (3.0ms)
SELECT `articles`.* FROM `articles`

Review Load (2.8ms)
SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`article_id` IN (1, ..省略.., 400)

User Load (0.5ms)
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (4, ..省略.., 85)

実際のWebサイト

スクリーンショット 2019-12-31 12.23.03.png

eager_load

どんな場合に使うと良いのかというと、eager_loadの場合は、JOINした先のデータを参照したい(絞り込み結果など)場面で使います(アソシエーションも考慮したほいうがいいですが)。
例えば、本の中でもGOOD評価をしているレビューが何件あるかの一覧を表示したい場合、eager_loadが良いでしょう。
具体例を作っていきます。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article
      .eager_load(:reviews, :users)
      .where("reviews.body LIKE ?", "%GOOD%")
  end
end
app/views/articles/index.html.erb
<h1>Articles#index</h1>
<p>Title / User Name サンプル (1) / GOOD件数</p>
<ul>
  <% @articles.each do |article| %>
      <% if article.reviews.present? || article.users.present? %>
        <li>
          <%= article.title %> /
          <%= article.reviews.first.body %> /
          <%= article.users.size %>
        </li>
      <% end %>
  <% end %>
</ul>
発行されたSQL
SQL (6.0ms)  SELECT 
  `articles`.`id` AS t0_r0, 
  `articles`.`author_id` AS t0_r1, 
  `articles`.`title` AS t0_r2, 
  `articles`.`body` AS t0_r3, 
  `articles`.`created_at` AS t0_r4, 
  `articles`.`updated_at` AS t0_r5, 
  `reviews`.`id` AS t1_r0, 
  `reviews`.`article_id` AS t1_r1, 
  `reviews`.`user_id` AS t1_r2, 
  `reviews`.`body` AS t1_r3, 
  `reviews`.`created_at` AS t1_r4, 
  `reviews`.`updated_at` AS t1_r5, 
  `users`.`id` AS t2_r0, 
  `users`.`username` AS t2_r1, 
  `users`.`password_digest` AS t2_r2, 
  `users`.`email` AS t2_r3, 
  `users`.`dm` AS t2_r4, 
  `users`.`roles` AS t2_r5, 
  `users`.`reviews_count` AS t2_r6, 
  `users`.`created_at` AS t2_r7, 
  `users`.`updated_at` AS t2_r8 
FROM `articles` 
LEFT OUTER JOIN `reviews` ON `reviews`.`article_id` = `articles`.`id` 
LEFT OUTER JOIN `reviews` `reviews_articles_join` ON `reviews_articles_join`.`article_id` = `articles`.`id` 
LEFT OUTER JOIN `users` ON `users`.`id` = `reviews_articles_join`.`user_id` 
WHERE (reviews.body LIKE '%GOOD%')

実際のWebサイト

スクリーンショット 2019-12-31 03.17.03.png

検証「preloadとeager_loadのレスポンスタイムの違い」

本記事の結論で述べた内容

「データ量が増えるほど、eager_loadよりも、preloadの方がレスポンスタイムは早くなる」

これを簡単に検証してみました。

検証方法

  • 4回連続webブラウザから2回目から4回目までのリロードから得られたレスポンススピード平均値を出力
  • レスポンスタイムは rails のログから得られる SQL のロードタイムから参照

検証データ

Table data size data size
data size type 1 2
articles 400 1000
reviews 1000 1000
users 30 190

検証結果

スクリーンショット 2019-12-31 09.44.38.png

検証による結論

  • eager_loadは、1回のSQLでJOINした全データを取得するので、データ量の増加に合わせてレスポンスタイムは長くなる
  • preloadも、データ量の増加に合わせてレスポンスタイムは長くなるが、eager_loadほどではないとグラフを見てわかる
    • preloadはJOINせず、SQLを分割して取得するので)
  • なので、preloadeager_loadよりも高速なレスポンスが期待できる
    • (ただし、本記事の結論で述べた、preloadを使用した方がいい場面、できないことなどの条件に合致していれば)

補足

どの環境でも同等の結果になることは保証しない

includes

本記事の結論で述べた通り、includesは利用しない方が良いでしょう。なぜなら、includesは、preloadeager_loadをよしなに振り分けるので。preloadと、eager_loadの特徴を理解していれば、includesが登場する場面は、ほとんどないと思います。データが少ないうちはincludesしていても問題にならないかもしれませんが、データが増えてきたときにジワジワと問題が顕在化してくるので、includesの挙動も正しく知っておきましょう。リファクタの際に役立つと思います。

内部的にはどう使い分けているのか?

アソシエーション先のデータ参照(WHEREなどを)しているかどうかで使い分けています。

  • 参照している場合 : eager_load
  • 参照していない場合 : preload

具体例、下記の場合は、eager_loadの挙動をします。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.includes(:reviews, :users).where(reviews: { article_id: 1})
  end
end

具体例、下記の場合は、preloadの挙動をします。

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.includes(:reviews, :users)
  end
end

どう振り分けているかの詳細は、下記の記事が参考になりました。

ActiveRecordのincludes, preload, eager_load の個人的な使い分け | Money Forward Engineers' Blog

以上です。参考になれば幸いです。ありがとうございました。

参考


その他

使用したseedファイル。

db/seeds.rb
1000.times do |i|
  Article.create!(
    author_id: rand(1..5),
    title: "Title #{i+1}",
    body: "Body #{i+1}",
  )
end

100.times do
  user_last_names = %W(佐藤 鈴木 高橋 田中 伊藤 渡辺 山本 中村 小林 加藤)
  user_first_names = %W(ハルト メイ ソウタ ヒマリ ミナト ハナ ユウト リン リク サクラ)
  names = []
  names << user_last_names.sample
  names << user_first_names.sample
  User.create!(username: names.join)
end

5000.times do
  first_review = %W(これは この本は この書籍は こいつは 本当にこれは)
  last_review = %W(やばい一冊だ とってもいい本だ つまらん BAD GOOD おもしろい! つまらない 普通だ ビックリするくらい普通だ)
  reviews = []
  reviews << first_review.sample
  reviews << last_review.sample

  Review.create!(
    article_id: rand(1..1000),
    user_id: rand(1..100),
    body: reviews.join
  )
end
98
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
life-a-tm
人生のイベントや日常生活に密着した比較サイト、情報サイト等様々なウェブサービスを企画・開発・運営

Comments

No comments
Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account Login
98
Help us understand the problem. What is going on with this article?