動機
日頃Goしか書いてないGopherがRails書いたときにつまったActiveRecord、特にjoins周りの挙動が直感的によくわからなかったので実際にデータ用意して調べてみた
(メソッドの具体的な説明は他にたくさん記事があるのでそちらを参照してください。)
実行環境
Rails公式Docのサンプルブログアプリ
の article.rb
と comment.rb
のモデルを使用。
今回は、それぞれ約25万行を用意した。
class Article < ApplicationRecord
has_many :comments, dependent: :destroy
validates :title, presence: true,
length: { minimum: 5 }
end
class Comment < ApplicationRecord
belongs_to :article
end
Controllerから preload/eager_load/joins/includes
を用い、それぞれの吐くSQLやパフォーマンスを比較してみる
0. 素
Article.limit(100)
SELECT `articles`.* FROM `articles` LIMIT 100
-- Completed 200 OK in 154ms (Views: 127.1ms | ActiveRecord: 15.7ms | Allocations: 20418)
まずは検証用にそのままで...
1. eager_load
Article.eager_load(:comments).limit(100)
SQL (1.9ms) SELECT DISTINCT `articles`.`id` FROM `articles` LEFT OUTER JOIN `comments` ON `comments`.`article_id` = `articles`.`id` LIMIT 100
SQL (575.4ms) SELECT `articles`.`id` AS t0_r0, `articles`.`title` AS t0_r1, `articles`.`text` AS t0_r2, `articles`.`created_at` AS t0_r3, `articles`.`updated_at` AS t0_r4, `comments`.`id` AS t1_r0, `comments`.`commenter` AS t1_r1, `comments`.`body` AS t1_r2, `comments`.`article_id` AS t1_r3, `comments`.`created_at` AS t1_r4, `comments`.`updated_at` AS t1_r5 FROM `articles` LEFT OUTER JOIN `comments` ON `comments`.`article_id` = `articles`.`id` WHERE `articles`.`id`
IN (1, 2, 3, 4, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157)
-- Completed 200 OK in 4869ms (Views: 4280.1ms | ActiveRecord: 585.9ms | Allocations: 2103879)
万能感ある。駆動表のキャッシュをしながら、絞り込みもできる。IN長すぎワロタ。
2. preload
Article.preload(:comments).limit(100)
Article Load (1.9ms) SELECT `articles`.* FROM `articles` LIMIT 100
Comment Load (405.8ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id`
IN (1, 2, 3, 4, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157)
-- Completed 200 OK in 4304ms (Views: 3883.6ms | ActiveRecord: 416.9ms | Allocations: 1658640)
eager_loadより省エネな感じ。ただ、最後のSELECT文がcommentsテーブルしか参照してないので絞り込みができない
3. includes
Article.includes(:comments).limit(100)
Article Load (1.5ms) SELECT `articles`.* FROM `articles` LIMIT 100
Comment Load (384.1ms) SELECT `comments`.* FROM `comments` WHERE `comments`.`article_id`
IN (1, 2, 3, 4, 6, 7, 8, 9, 13, 14, 15, 16, 17, 18, 19, 20, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 122, 123, 124, 125, 126, 127, 128, 129, 130, 131, 132, 133, 134, 135, 136, 137, 138, 139, 140, 141, 142, 143, 144, 145, 146, 147, 148, 149, 150, 151, 152, 153, 154, 155, 156, 157)
-- Completed 200 OK in 3737ms (Views: 3339.1ms | ActiveRecord: 395.3ms | Allocations: 1652329)
今回はpreloadと同じ挙動
(includes は場合によって挙動が変わる. 最後のSELECT文にないテーブルのwhereとかはできないので、そういう時はeager_loadになる)
def eager_loading?
@should_eager_load ||=
eager_load_values.any? ||
includes_values.any? && (joined_includes_values.any? || references_eager_loaded_tables?)
end
4. joins
Article.joins(:comments).limit(100)
Article Load (2.1ms) SELECT `articles`.* FROM `articles` INNER JOIN `comments` ON `comments`.`article_id` = `articles`.`id` LIMIT 100
-- Completed 200 OK in 67ms (Views: 56.1ms | ActiveRecord: 7.0ms | Allocations: 21703)
いつものアレ。安心感ある。
まとめ
そもそもActiveRecordは LazyLoad、すなわち 必要になったときにSQLが実行される 。
for文の中でSQLが複数回発行(N+1)されないために、こういう機構が必要になってくる。
(生SQLerの私にはこの感覚があんまりない)
後は、where使いたいなら eager_load
何もしないなら preload
って感じ。
頭使いたくない人は 「とりあえず includes
」 しとけばなんとかなる。
ただ、複数紐づくテーブル扱い出すと最終的に吐き出されるSQLが予測困難になるので、ちゃんと使い分けたいところ。
ちゃんと使い分けたい人はこの記事とか読みましょう。実装追ってて勉強になります。