はじめに
N+1はパフォーマンスに影響することは知っていると思います。
ただ、パフォーマンスに影響するということはわかっても具体的にどれくらいの差があるのかを数値で見る機会は、なかなかったので、キャッシュしないものとキャッシュするものを比較してみたいと思います。
テーブル関係
親usersテーブル、子postsテーブルを用意します。
class User < ApplicationRecord
has_many :posts, dependent: :destroy
end
class Post < ApplicationRecord
belongs_to :user
end
seedの用意
以下のようなseedデータを用意します。データの件数は1000件です。
1000.times do |n|
user = User.create!(
name: "山田 太郎",
email: "sample#{n + 1}@sample.com"
)
Post.create!(
user_id: user.id,
title: "タイトル#{ n + 1 }",
body: "これは~のであり、~である。"
)
end
view側
usersテーブルに紐づくpostsテーブルの要素を表示しています。
<% @users.each do |user| %>
<% user.posts.each do |post| %>
<%= post.title %>
<% end %>
<% end %>
joinsの速度
以下のコードを実行します。
@users = User.joins(:posts)
内部結合はassociationをキャッシュをしません。なのでActiveRecord: 329.3ms
の時間がかかっています。
User Load (2.4ms) SELECT `users`.* FROM `users` INNER JOIN `posts` ON `posts`.`user_id` = `users`.`id`
Post Load (0.9ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 1
Post Load (0.5ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 2
Post Load (1.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 3
Post Load (0.4ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 4
Post Load (0.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 5
Post Load (0.2ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 6
---
Rendered users/test.html.erb within layouts/application (Duration: 1261.9ms | Allocations: 526914)
Rendered layout layouts/application.html.erb (Duration: 1264.0ms | Allocations: 529231)
Completed 200 OK in 1346ms (Views: 937.9ms | ActiveRecord: 329.3ms | Allocations: 588057)
left_joinsの速度
以下のコードを実行します。
@users = User.left_joins(:posts)
左外部結合もassociationをキャッシュしません。なので、joinsと同じような速度ActiveRecord: 297.3ms
になります。
User Load (1.6ms) SELECT `users`.* FROM `users` LEFT OUTER JOIN `posts` ON `posts`.`user_id` = `users`.`id`
Post Load (0.4ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 1
Post Load (0.5ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 2
Post Load (0.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 3
Post Load (0.4ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 4
Post Load (0.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 5
Post Load (0.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` = 6
---
Rendered users/test.html.erb within layouts/application (Duration: 1127.0ms | Allocations: 531034)
Rendered layout layouts/application.html.erb (Duration: 1141.5ms | Allocations: 556218)
Completed 200 OK in 1210ms (Views: 853.5ms | ActiveRecord: 297.3ms | Allocations: 620498)
includesの速度
以下のコードを実行します。
@users = User.includes(:posts)
includesはassociationをキャッシュします。速度としてはActiveRecord: 4.9ms
となりました。キャッシュしていないものに比べて約1/60の速度となっています。
User Load (0.9ms) SELECT `users`.* FROM `users`
Post Load (2.7ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6,~,1000)
Rendered users/test.html.erb within layouts/application (Duration: 8.3ms | Allocations: 17176)
Rendered layout layouts/application.html.erb (Duration: 19.6ms | Allocations: 41066)
Completed 200 OK in 103ms (Views: 20.9ms | ActiveRecord: 4.9ms | Allocations: 140693)
eager_loadの速度
以下のコードを実行します。
@users = User.eager_load(:posts)
eager_loadはassociationをキャッシュします。速度としてはActiveRecord: 5.2ms
となりました。キャッシュしていないものに比べてこちらも約1/60の速度となっています。
eager_loadはLEFT OUTER JOINを使用するので、SQLクエリが一つになります。また、eager_loadの場合はeager_load(:posts).where(posts: { id: 1 })
のような絞り込みをすることができます。大量のデータを持たないテーブルで絞り込みたい場合はこちらを使うのがよさそうです。
SQL (3.0ms) SELECT `users`.`id` AS t0_r0, `users`.`name` AS t0_r1, `users`.`email` AS t0_r2,
`users`.`created_at` AS t0_r3, `users`.`updated_at` AS t0_r4, `posts`.`id` AS t1_r0,
`posts`.`title` AS t1_r1, `posts`.`body` AS t1_r2, `posts`.`user_id` AS t1_r3,
`posts`.`created_at` AS t1_r4, `posts`.`updated_at` AS t1_r5 FROM `users` LEFT OUTER JOIN
`posts` ON `posts`.`user_id` = `users`.`id`
Rendered users/test.html.erb within layouts/application (Duration: 5.8ms | Allocations: 17194)
Rendered layout layouts/application.html.erb (Duration: 14.5ms | Allocations: 41078)
Completed 200 OK in 81ms (Views: 15.8ms | ActiveRecord: 5.2ms | Allocations: 143458)
preloadの速度
以下のコードを実行します。
@users = User.preload(:posts)
preloadはassociationをキャッシュします。速度としてはActiveRecord: 5.8ms
となります。キャッシュしていないものに比べてこちらも約1/60の速度となっています。
preloadはjoinをしないので、大量のデータを持つテーブルや、複雑なクエリの場合はこちらを使うのが適しているでしょう。ただ、eager_loadのような子テーブルの絞り込みはpreloadではできません。
User Load (0.9ms) SELECT `users`.* FROM `users`
Post Load (1.3ms) SELECT `posts`.* FROM `posts` WHERE `posts`.`user_id` IN (1, 2, 3, 4, 5, 6,~,1000)
Rendered users/test.html.erb within layouts/application (Duration: 6.6ms | Allocations: 17180)
Rendered layout layouts/application.html.erb (Duration: 17.4ms | Allocations: 41355)
Completed 200 OK in 95ms (Views: 18.5ms | ActiveRecord: 5.8ms | Allocations: 141373)
まとめ
メソッド | クエリ数 | JOINの使用 | 絞り込み | ActiveRecord時間 | 大量のデータを扱うのに適しているか | アソシエーション |
---|---|---|---|---|---|---|
joins |
N + 1 | はい | 可能 | 約329.3ms | いいえ | キャッシュしない |
left_joins |
N + 1 | はい | 可能 | 約297.3ms | いいえ | キャッシュしない |
includes |
1または2 | 場合による | 場合による | 約4.9ms | 場合による | キャッシュする |
eager_load |
1 | はい | 可能 | 約5.2ms | いいえ | キャッシュする |
preload |
2 | いいえ | いいえ | 約5.8ms | はい | キャッシュする |
includesは場合によって、eager_loadの挙動かpreloadの挙動を行うようです。
ただ、意図した挙動を行いたい場合は、eager_load、preloadを指定するのが良いかと思います。
参考