【Rails】N+1問題はincludesで万事OKと思っていた。
こんにちは。Webエンジニアとなって2週間経ったオクトと申します。
本記事では、N+1問題をincludes
さえ付けておけば完璧じゃない?と思っている方を対象に他にもやり方はあるんだよ、もっと良い方法があるよ、ということをシャアしていきます。
かくいう私も上記の対象に先ほどまで入ってました。
これはそんな私向けの記事でもあるんですね。
バリバリにRailsを書いてきた人には、当たり前に感じると思いますが、
ご指摘箇所がありましたらご教授いただけますと幸いです。
前提
Ruby 3.1.1
Rails 6.1.4.6
テーブル構造は以下の通りです。
includesの他に何があるの?
「ねぇねぇ、先に何があるか教えてよ」という声が聞こえてきたので、先に伝えます。
includes
の他に主に2つの方法で解消することができます。
クエリ数 | 関連先での絞り込み | 関連先の情報 | |
---|---|---|---|
includes | 1 or N | ○ or x | ○ or x |
preload | N | x | x |
eager_load | 1 | ○ | ○ |
* Nは関連テーブル毎にクエリを発行する
* includes
はデフォルトでpreload
と同じ挙動となり、絞り込みなどを行なっている場合はeager_load
と同じ挙動になります。
includes
最強じゃん
なんだと思ったそこの あなた 私。
確かに、様々な使い方ができるincludes
ですが、
クエリが定義次第で変わってくる(クエリを制御しにくい)ため、極力使わない方が良いということになります。
他はどんな感じなのよ
なぜincludes
を極力使わない方が良いか見てきたところで、実際に他のメソッドを使ってみようと思います。
N+1が起きているパターン
まずはN+1問題が発生しているパターンを見ていきます。
class PostsController < ApplicationController
def index
@posts = Post.all
end
# ==== 省略 =====
end
以下のようにひとつひとつクエリを発行していることがわかります。
Post Load (1.3ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:2
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
↳ app/views/posts/_post.html.erb:2
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
↳ app/views/posts/_post.html.erb:2
User Load (0.7ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
↳ app/views/posts/_post.html.erb:2
preload
を使用する場合
preload
の使い方は案外簡単で、以下の通りです。
class PostsController < ApplicationController
def index
# ===== モデル.preload(引数..) =====
@posts = Post.preload(:user)
end
# 省略
end
以下のように、IN
句を使って抽出してキャッシュします。
Post Load (2.3ms) SELECT "posts".* FROM "posts"
↳ app/views/posts/index.html.erb:2
User Load (2.9ms) SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3) [["id", 2], ["id", 1], ["id", 3]]
↳ app/views/posts/index.html.erb:2
↓ちなみにORDER
を使って並び替えることはできます。
# @posts = Post.preload(:user).order(created_at: :desc)
Post Load (3.1ms) SELECT "posts".* FROM "posts" ORDER BY "posts"."created_at" DESC
↳ app/views/posts/index.html.erb:2
User Load (1.0ms) SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3) [["id", 1], ["id", 3], ["id", 2]]
↓ちなみにちなみに関連先を絞り込むと以下のようなエラーが発生します。(注: DBはPostgresql
を使用)
# @posts = Post.preload(:user).where(users: { email: 'satou@example.com' })
ActionView::Template::Error (PG::UndefinedTable: ERROR: missing FROM-clause entry for table "users"
LINE 1: SELECT "posts".* FROM "posts" WHERE "users"."email" = $1
eager_load
を使用する場合
eager_load
を使う場合も、ほとんど同じです。
class PostsController < ApplicationController
def index
# ===== モデル.eager_load(属性..) =====
@posts = Post.eager_load(:user)
end
# 省略
end
はい、以下のように 左外部結合( LEFT OUTER JOIN
) によって1つのクエリで取得してキャッシュしています。
SQL (7.6ms) SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0,
"users"."email" AS t1_r1, "users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7
FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id"
↳ app/views/posts/index.html.erb:2
↓ORDER
を使うこともできます。
# @posts = Post.eager_load(:user).order(created_at: :desc)
SQL (2.8ms) SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0,
"users"."email" AS t1_r1, "users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7
FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" ORDER BY "posts"."created_at" DESC
↳ app/views/posts/index.html.erb:2
↓関連先の絞り込みを行なっても正常にクエリを発行していることがわかります!素晴らしい!
# @posts = Post.eager_load(:user).where(users: { email: 'satou@example.com' }).order(created_at: :desc)
SQL (2.2ms) SELECT "posts"."id" AS t0_r0, "posts"."content" AS t0_r1, "posts"."user_id" AS t0_r2, "posts"."created_at" AS t0_r3, "posts"."updated_at" AS t0_r4, "users"."id" AS t1_r0, "users"."email" AS t1_r1,
"users"."encrypted_password" AS t1_r2, "users"."reset_password_token" AS t1_r3, "users"."reset_password_sent_at" AS t1_r4, "users"."remember_created_at" AS t1_r5, "users"."created_at" AS t1_r6, "users"."updated_at" AS t1_r7
FROM "posts" LEFT OUTER JOIN "users" ON "users"."id" = "posts"."user_id" WHERE "users"."email" = $1 ORDER BY "posts"."created_at" DESC [["email", "satou@example.com"]]
↳ app/views/posts/index.html.erb:2
結局どっち使えば良いのよ
結論、使い分けるということになります。この世界には、絶対的な真はないところが面白いところですね。
目安としては、以下のようです。
-
has_many
-belongs to
の関係であれば、preload
を使用してクエリ発行の負担を減らす。 -
has_one
-belongs to
の関係であれば、eager_load
を使用して1クエリでまとめて取得する。 - 関連先で条件を絞り込みたいときは
eager_load
を使用する。
しかし、両者には気にしておくポイントもあります。
-
eager_load
では結合処理を行うため、データ量が大きい場合スロークエリが発生しやすい。 -
preload
では、こちらもレコード数が多い場合にIN
句が大きくなりすぎるためDB側の設定値を機にする必要があります。
まとめ
いかがだったでしょうか。
それぞれに実装する上での利点があったことがわかったと思います。
私も気にしておくポイントで紹介したことについて、もう少し深掘りしていきたいと思いました。
最後までお読みいただきありがとうございます!
Discussion