はじめに
Railsなどを中心に勉強中のエンジニア初心者が他の記事を参考にしたり、実際に実装してみたりして、アウトプットの一環としてまとめたものです。
間違っていることもあると思われるので、その際は指摘いただけると幸いです。
N+1問題とは
N+1問題とは、データベースからデータを取り出す際に、大量のSQLが発行されて動作が遅くなる問題のこと。
もう少し具体的に言うと、ループ処理の中で都度SQLを発行してしまうことで、1つ目の値の関連データを取得するためにSLQを発行し、2つ目の値の関連データを取得するためにSQLを発行し、3つ目の、、、というイメージ。
関連データもまとめて取得してからループ処理に入ればN+1問題は起きない(はず)。
N+1問題の例
Room
モデルとUser
モデルがそれぞれ関連づけられており、各room
に紐づくuser.name
を取得したいとする。
コントローラとビュー
# コントローラ
@rooms = Room.all
# ビュー
@rooms.each do |room|
room.user.name
end
N+1問題の発生
コントローラでroom
一覧を取得する際のSQL。
Room Load (0.1ms) SELECT "rooms".* FROM "rooms"
ビューでroom.user.name
を表示する際のSQL文。
各room
毎に紐づいているuser
を都度取得しているため、room
の数が増えるほど発行されるSQLも増えてしまう。
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
User Load (0.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
N+1問題対策
N+1問題の対策として、以下のメソッドを使った対策が挙げられる。
以下は、アソシエーションされたモデルのデータを効率よく取得するためのメソッドである。
preloadメソッド
preload
メソッドは2つのクエリを発行する。
まずは、room
一覧を取得するSQLが発行され、その次にそのroom
に紐づくuser
を一括で取得するSQLが発行されている。
# コントローラ
@rooms = Room.all.preload(:user)
Room Load (0.1ms) SELECT "rooms".* FROM "rooms"
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?) [["id", 1], ["id", 2], ["id", 3]]
eager_loadメソッド
eager_load
引数に取ったマスターテーブルとして外部結合する。
今回はusers
テーブルをマスターテーブルとして、rooms
テーブルと外部結合(left outer join
)している。
# コントローラ
@rooms = Room.all.eager_load(:user)
SQL (0.2ms)
SELECT
"rooms"."id" AS t0_r0,
"rooms"."room_name" AS t0_r1,
"rooms"."room_introduction" AS t0_r2,
"rooms"."usage_fee" AS t0_r3,
"rooms"."address" AS t0_r4,
"rooms"."room_picture" AS t0_r5,
"rooms"."created_at" AS t0_r6,
"rooms"."updated_at" AS t0_r7,
"rooms"."user_id" AS t0_r8,
"users"."id" AS t1_r0,
"users"."name" AS t1_r1,
"users"."email" AS t1_r2,
"users"."password_digest" AS t1_r3,
"users"."created_at" AS t1_r4,
"users"."updated_at" AS t1_r5,
"users"."icon_img" AS t1_r6,
"users"."introduction" AS t1_r7
FROM "rooms" LEFT OUTER JOIN "users" ON "users"."id" = "rooms"."user_id"
includesメソッド
デフォルトでは、preload
と同様の挙動を示す。
関連先のテーブルで絞り込みを行っている場合などは、eager_load
と同じ挙動になるとのこと。
いろいろな記事を調べていると、preload
とeager_load
を使って挙動を明確にすることを推奨している記事が多い印象。
# コントローラ
@rooms = Room.all.includes(:user)
Room Load (0.2ms) SELECT "rooms".* FROM "rooms"
User Load (0.1ms) SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?) [["id", 1], ["id", 2], ["id", 3]]
参考
最後に
いかがでしたでしょうか。
ここ違うよ!でしたり、こうした方がいいよ!などがあればコメントいただけると幸いです。
他にも下記のような記事を投稿しております。
興味がありましたら、ぜひご覧ください。