はじめに
ActiveRecordでN+1問題やスロークエリを解消するためにeager loadingを行う場合、普段Railsを使って開発されている方であれば、パッと思いつくのはincludes
ではないでしょうか?もしくは、preload
やeager_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
などです。
補足
出てくる用語の理解が曖昧な場合、参照記事をぜひ読んください。
- ORM : オブジェクト関係マッピング - Qiita
- SQLのJOIN : SQL素人でも分かるテーブル結合(inner joinとouter join) - Qiita
- N+1問題 : Rails「N+1問題」超分かりやすい解説・解決方法【includesを使おう】 | にょけんのボックス
主な違い
メソッド | SQL(クエリ) | キャッシュ | アソシエーション先のデータ参照 | デメリット |
---|---|---|---|---|
joins | INNER JOIN | しない | できる | N + 1問題 |
preload | JOIN せずそれぞれSELECT | する | できない | IN句大きくなりがち |
eager_load | LEFT JOIN | する | できる | LEFT JOINなので 相手が存在しなくても全部ロードしてしまう |
includes | 場合による | する | できる | ただしく理解してないと挙動がコントロールできない |
結論「どういう場合に使ったら良いか」
主な結論を一覧にしました。
-
includes
はなるべく利用しない方が良い(理由は後述します)- 理由:意図しない挙動を防ぐため
- 代わりに、
preload
かeager_load
を使う
-
preload
- どんな場合に使うといいか : 多対多のアソシエーションの場合
- できないこと : アソシエーション先のデータ参照(Whereによる絞り込みなど)
- 注意 : データ量が大きいと、IN句が大きくなりがちで、メモリを圧迫する可能性がある
-
eager_load
はどんな場合に使うといいか- 1対1あるいはN対1のアソシエーションをJOINする場合(belongs_to, has_one アソシエーション)
- JOINした先のテーブルの情報を参照したい場合(Whereによる絞り込みなど)
-
joins
はどんな場合に使うか- メモリの使用量を必要最低限に抑えたい場合
- JOINした先のデータを参照せず、絞り込み結果だけが必要な場合
- 逆に言うと、引用先のデータを参照しない場合、使用しないほうがいいです
補足「preloadとeager_loadの使い分け」
データ量が膨大な場合だと、preload
、eager_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 : レビュー者
著者は自身が執筆した本や記事の情報を持てます。その本に対して、ユーザーはレビューを行えるサイトです。
モデル
class Author < ApplicationRecord
has_many :articles
end
class Article < ApplicationRecord
belongs_to :author
has_many :reviews
has_many :users, through: :reviews
end
class Review < ApplicationRecord
belongs_to :article
belongs_to :user
end
class User < ApplicationRecord
has_many :reviews
has_many :articles, through: :reviews
end
DB
ER図の作り方は、下記の記事が参考になりました。
MySQLWorkbenchでDBからER図(モデル)を作成 – リバースエンジニアリング - 親バカエンジニアのナレッジ帳
環境
OS | Ruby | Rails(ActiveRecord) |
---|---|---|
macOS High Sierra 10.13.6 | 2.6.5 | 5.2.4 |
joins
どんな場合に使うと良いのかというと、joins
の場合は、JOINした先のデータを参照せず、絞り込み結果だけが必要な場面かなと思います。
例えば、サイトで紹介している記事数を表示させたい場合、joins
が良いでしょう。
具体例を作っていきます。
class ArticlesController < ApplicationController
def index
@article_size = Author.joins(:articles).count
end
end
<h1>Articles#index</h1>
<p>このサイトで紹介している記事数<%= @article_size %>点</p>
SELECT COUNT(*) FROM `authors` INNER JOIN `articles` ON `articles`.`author_id` = `authors`.`id`
preload
どんな場合に使うと良いのかというと、preload
の場合は、N対Nのアソシエーションを取得する場面かなと思います。
例えば、レビューがある本の、最初のレビューユーザー名とそのレビュー内容の一覧を表示したい場合、preload
が良いでしょう。
具体例を作っていきます。
class ArticlesController < ApplicationController
def index
@articles = Article.preload(:reviews, :users)
end
end
<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>
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サイト
eager_load
どんな場合に使うと良いのかというと、eager_load
の場合は、JOINした先のデータを参照したい(絞り込み結果など)場面で使います(アソシエーションも考慮したほいうがいいですが)。
例えば、本の中でもGOOD評価をしているレビューが何件あるかの一覧を表示したい場合、eager_load
が良いでしょう。
具体例を作っていきます。
class ArticlesController < ApplicationController
def index
@articles = Article
.eager_load(:reviews, :users)
.where("reviews.body LIKE ?", "%GOOD%")
end
end
<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 (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サイト
検証「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 |
検証結果
検証による結論
-
eager_load
は、1回のSQLでJOINした全データを取得するので、データ量の増加に合わせてレスポンスタイムは長くなる -
preload
も、データ量の増加に合わせてレスポンスタイムは長くなるが、eager_load
ほどではないとグラフを見てわかる- (
preload
はJOINせず、SQLを分割して取得するので)
- (
- なので、
preload
はeager_load
よりも高速なレスポンスが期待できる- (ただし、本記事の結論で述べた、
preload
を使用した方がいい場面、できないことなどの条件に合致していれば)
- (ただし、本記事の結論で述べた、
補足
どの環境でも同等の結果になることは保証しない
includes
本記事の結論で述べた通り、includes
は利用しない方が良いでしょう。なぜなら、includes
は、preload
とeager_load
をよしなに振り分けるので。preload
と、eager_load
の特徴を理解していれば、includes
が登場する場面は、ほとんどないと思います。データが少ないうちはincludes
していても問題にならないかもしれませんが、データが増えてきたときにジワジワと問題が顕在化してくるので、includes
の挙動も正しく知っておきましょう。リファクタの際に役立つと思います。
内部的にはどう使い分けているのか?
アソシエーション先のデータ参照(WHEREなどを)しているかどうかで使い分けています。
- 参照している場合 :
eager_load
- 参照していない場合 :
preload
具体例、下記の場合は、eager_load
の挙動をします。
class ArticlesController < ApplicationController
def index
@articles = Article.includes(:reviews, :users).where(reviews: { article_id: 1})
end
end
具体例、下記の場合は、preload
の挙動をします。
class ArticlesController < ApplicationController
def index
@articles = Article.includes(:reviews, :users)
end
end
どう振り分けているかの詳細は、下記の記事が参考になりました。
ActiveRecordのincludes, preload, eager_load の個人的な使い分け | Money Forward Engineers' Blog
以上です。参考になれば幸いです。ありがとうございました。
参考
- なぜ、SQLは重たくなるのか?──『SQLパフォーマンス詳解』の翻訳者が教える原因と対策 - エンジニアHub|若手Webエンジニアのキャリアを考える!
- To join or not to join? An act of #includes - Goiabada
- Preload, Eagerload, Includes and Joins | BigBinary Blog
その他
使用したseedファイル。
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