まずは下記のコードを見てください。
review = Review.preload(:user, :book).find_by(id: review_id)
このようなコードを見かけたとき、あなたはどうしますか?
私ならpreload
は付けなくて良いよ。と指摘すると思います。
この記事では、なぜこのpreload
は不要なのか説明したいと思います。
preloadとは
preloadをつけると指定した関連データを同時に取得することができます。
この例の場合、reviewを取得したときに関連するuserとbookも同時に取得します。
下記にirbで実行した結果を載せておきます。
reviewを取得したときにuserとbookもSELECTしており、実際に使うところではSQLが発行されていないことがわかります。
irb(main):011:0> review_id = 15
=> 15
irb(main):012:0> review = Review.preload(:user, :book).find_by(id: review_id)
Review Load (0.8ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`id` = 15 LIMIT 1
User Load (0.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
Book Load (0.8ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1
=> #<Review id: 15, content: "hogehoge", user_id: 1, book_id: 1, status: "draft", created_at: "2020-06-15 14:21:23", updated_at: "2020-06-15 14:21:23">
irb(main):013:0> review.user
=> #<User id: 1, name: "1234567890", created_at: "2019-12-12 05:43:52", updated_at: "2019-12-12 05:43:52">
irb(main):014:0> review.book
=> #<Book id: 1, title: "book1", created_at: "2020-06-15 14:21:15", updated_at: "2020-06-15 14:21:15">
preloadはどういうときに使うのか?
主にN+1の対策で使われます。
N+1についてはここでは詳しくは述べませんが、下記のようにループなどで関連データの取得SQLが1件ずつ発行されるような事象のことです。
irb(main):022:0> Review.all.each do |review|
irb(main):023:1* review.book
irb(main):024:1> end
Review Load (0.6ms) SELECT `reviews`.* FROM `reviews`
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 2 LIMIT 1
Book Load (0.4ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 3 LIMIT 1
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 4 LIMIT 1
Book Load (2.7ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 5 LIMIT 1
上記では、bookを事前に取得していないのでreview.bookのところで1件ずつSQLを発行しています。
prealodをつけて事前にbookを取得しておくと下記のようになります。
irb(main):025:0> Review.all.preload(:book).each do |review|
irb(main):026:1* review.book
irb(main):027:1> end
Review Load (0.8ms) SELECT `reviews`.* FROM `reviews`
Book Load (0.7ms) SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5)
ループに入る前にReview.allで取得できたreviewに関連するbookを1つのSQLで取得していることがわかります。
ループ前にまとめで取得できているのでループ中にはSQLが発行されません。
一般的にSQL発行はコストがかかる処理なので、SQLが1回になることでパフォーマンスが向上します。
上記例でもSQLの合計実行時間をみるとパフォーマンスに差が出ていることがわかります。
なぜ今回は付けなくて良いのか?
では、最初の例の場合はどうでしょうか?
reviewを1件しか取得していないので先ほどのようにループでN+1になることはありえません。
preloadをしているということは少なくとものちに使う可能性があるということだと思います。
次の例を見てみましょう。
# userを取得
# あとでuserとreviewを使うのでpreloadしておく
review = Review.preload(:user, :book).find_by(id: review_id)
# userを使う
review.user
# bookを使う
review.book
preloadをつけているので、reviewを取得したときにuserやbookも取得されます。
実行結果は下記の通り。
irb(main):007:0> review = Review.preload(:user, :book).find_by(id: review_id)
Review Load (0.8ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`id` = 36 LIMIT 1
User Load (0.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1
Book Load (0.4ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1
=> #<Review id: 36, content: "", user_id: 1, book_id: 1, status: "draft", created_at: "2020-06-30 15:20:01", updated_at: "2020-06-30 15:20:01">
irb(main):008:0> review.user
=> #<User id: 1, name: "1234567890", created_at: "2019-12-12 05:43:52", updated_at: "2019-12-12 05:43:52">
irb(main):009:0> review.book
=> #<Book id: 1, title: "book1", created_at: "2020-06-15 14:21:15", updated_at: "2020-06-15 14:21:15">
では、もしpreloadをつけていなかったらどうなるでしょうか?
irb(main):010:0> review = Review.find_by(id: review_id)
Review Load (0.7ms) SELECT `reviews`.* FROM `reviews` WHERE `reviews`.`id` = 36 LIMIT 1
=> #<Review id: 36, content: "", user_id: 1, book_id: 1, status: "draft", created_at: "2020-06-30 15:20:01", updated_at: "2020-06-30 15:20:01">
irb(main):011:0> review.user
User Load (0.5ms) SELECT `users`.* FROM `users` WHERE `users`.`id` = 1 LIMIT 1
=> #<User id: 1, name: "1234567890", created_at: "2019-12-12 05:43:52", updated_at: "2019-12-12 05:43:52">
irb(main):012:0> review.book
Book Load (0.6ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1
=> #<Book id: 1, title: "book1", created_at: "2020-06-15 14:21:15", updated_at: "2020-06-15 14:21:15">
reviewを取得したときにはuserとbookは取得されず、使っているところでSQLが発行されています。
ただ、reviewが一件しかないので発行されているSQLの数は一緒です。
この例の場合だと、preloadをつけてもつけなくても効率は同じですね
では、次の例ではどうでしょうか?
# userを取得
# あとでuserとreviewを使うのでpreloadしておく
review = Review.preload(:user, :book).find_by(id: review_id)
# ある条件の時はuserを使う
if hoge
review.user
end
# ある条件の時はbookを使う
if fuga
review.book
end
preloadをつけているので、reviewを取得したときにuserやbookも取得されます。
hogeやfugaがtrueの場合は、userもreviewも使うのでpreloadをしていてもしていなくてもSQLの数は一緒です。
では、falseの場合はどうでしょうか?
例えばhogeがfalseの場合はuserは使わないので、preloadで取得したuserを使うことはありません。
fugaがfalseの場合も同様にbookを使うことはありません。
今回はもしpreloadをつけていなかったらどうなるでしょうか?
# userを取得
review = Review.find_by(id: review_id)
# ある条件の時はuserを使う
if hoge
review.user
end
# ある条件の時はbookを使う
if fuga
review.book
end
reviewを取得したときはuserやbookは取得されません。
hogeやfugaがtrueの場合は使用する箇所でreviewやbookが取得されます。
もしfalseの場合は取得されません。
こちらの実装の場合は使用するときのみ取得することができます。
ちなみに、このように必要になったときにデータを取得する実装は遅延ロードと呼ばれています。
どちらの方が効率が良いかおわかりいただけたでしょうか?
1件のモデルに対してpreloadをした場合、preloadで取得したモデルを全部使った場合でもpreloadをつけていない場合とSQLの数は同じです。
もし1つでも条件によって使わないパターンがある場合はSQLの数が多くなります。
最初の例のように1件だけ取得する場合はpreloadをしても意味がなく、むしろ非効率になるので注意が必要です。
最後に
Railsを覚えたばかりの方などなんとなくpreloadやeager_loadを知っている場合、とりあえずつけておけばいいんでしょ?
と思っている方も多いと思います。
レビュアーとしてもN+1を指摘する人は多いけど、今回のような無駄なpreloadを指摘する人は少ないと感じています(個人の感想です)
N+1を倒してくれるpreloadやeager_loadはつけておいて悪いことはないと思われがちですが、今回のように非効率になってしまうパターンもあるので意識していなかった方は意識しておきましょう。