はじめに
Railsなどを中心に勉強中のエンジニア初心者が他の記事を参考にしたり、実際に実装してみたりして、アウトプットの一環としてまとめたものです。
間違っていることもあると思われるので、その際は指摘いただけると幸いです。
N+1問題とは
N+1問題とは、データベースからデータを取り出す際に、大量のSQLが発行されて動作が遅くなる問題のこと。
もう少し具体的に言うと、ループ処理の中で都度SQLを発行してしまうことで、1つ目の値の関連データを取得するためにSLQを発行し、2つ目の値の関連データを取得するためにSQLを発行し、3つ目の、、、というイメージ。
関連データもまとめて取得してからループ処理に入ればN+1問題は起きない(はず)。
この記事の前提
今回は、RailsのActive Storage
を使用したところN+1問題に遭遇したため、その対処法について記載する。
ここでは、部屋貸出サービスというアプリケーションを題材にして記載する(練習で作ったもの)。
貸し出しユーザーが貸し出したい部屋を登録し、利用ユーザーが好きな部屋を予約できるというものである。
貸し出し部屋として登録された部屋は一覧画面にて確認できるというイメージである。
貸し出したい部屋を登録する際には画像を登録するような仕様で、画像の保存取得はActive Storage
を使用している。
発生したN+1問題
N+1問題発生時のログ
部屋一覧を画像付きで表示する際、以下のようなログが確認できた。
↳ app/controllers/rooms_controller.rb:11:in `index'
Rendering layout layouts/application.html.erb
Rendering rooms/index.html.erb within layouts/application
Room Load (0.0ms) SELECT "rooms".* FROM "rooms"
↳ app/views/rooms/index.html.erb:19
ActiveStorage::Attachment Load (0.0ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 1], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Attachment Load (0.0ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 2], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 3], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Blob Load (0.1ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 7], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 4], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 9], ["LIMIT", 1]]
↳ app/views/rooms/index.html.erb:22
該当箇所のビュー、コントローラ、モデル
# index.html.erb
<p><%= image_tag(room.image.variant(resize: 399)) if room.image.attached? %></p>
# rooms_controller
def index
@q = Room.ransack(params[:q]) # ransackの検索機能を使用
@rooms = @q.result
@count = @rooms.count
end
# room.rb
class Room < ApplicationRecord
belongs_to :user
has_many :reservation
has_one_attached :image # Active Storageを使用
end
部屋一覧の取得
まず、下記のSQLによってrooms
テーブルから部屋情報一覧を取得していることがわかる。
(今回は部屋が4つ登録されていたため、4レコード分のデータが取得されている。)
SELECT "rooms".* FROM "rooms"
部屋のレコードごとに画像を取得
部屋のレコードごとに画像を取得している。
["record_id", 1]
部分でレコードごとにSQLを発行していることが確認できる。
1つ目のSQLで取得したレコードのidを元にして、2つ目のSQLでデータを取得している。
SELECT "active_storage_attachments".* FROM "active_storage_attachments"
WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 1], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs"
WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
N+1問題
今回は**「部屋のレコードごとに画像取得のSQLが実行されている」**ことが問題である。
部屋データが数レコード程度であれば問題ないと思われるが、膨大な部屋データが登録されている場合は、膨大なSQLが発行されるため、データ取得のパフォーマンスに大きく影響する。
実際にどの程度影響するのかは調べきれていないので、コメントで教えていただけるとありがたいです。
N+1問題の対策
基本的には、関連データもまとめて取得してからループ処理に入ればN+1問題は起きないと思われる。
そのため、部屋一覧を取得すると同時に部屋の画像も取得できるようにコントローラを修正すれば良いと考えた。
Active StorageのN+1問題対策のメソッド
Active Storage
にはwith_attached_attachment_name
というスコープが存在しており、メソッドとして定義されている。
with_attached_attachment_name
はincludes
メソッドを使うメソッドとして定義されているため、これを活用することでN+1問題を解消することができる。
また、with_attached_attachment_name
のattachment_name
は、モデルに記載したhas_one_attached :image
の値を指す(今回はimage
)。
# activestorage/lib/active_storage/attached/model.rb
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
コントローラの修正
with_attached_attachment_name
を活用して、コントローラを以下のように修正。
def index
@q = Room.with_attached_image.ransack(params[:q])
@rooms = @q.result
@count = @rooms.count
end
N+1問題解消後のログ
複数発行されていたSQLが1つにまとまっていることが確認できる。
↳ app/controllers/rooms_controller.rb:11:in `index'
Rendering layout layouts/application.html.erb
Rendering rooms/index.html.erb within layouts/application
Room Load (0.0ms) SELECT "rooms".* FROM "rooms"
↳ app/views/rooms/index.html.erb:19
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? AND "active_storage_attachments"."record_id" IN (?, ?, ?, ?) [["record_type", "Room"], ["name", "image"], ["record_id", 1], ["record_id", 2], ["record_id", 3], ["record_id", 4]]
↳ app/views/rooms/index.html.erb:19
ActiveStorage::Blob Load (0.1ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (?, ?, ?, ?) [["id", 1], ["id", 5], ["id", 7], ["id", 9]]
↳ app/views/rooms/index.html.erb:19
includesメソッドを使用して記載した場合
with_attached_attachment_name
ではなく、下記を参考にincludesメソッドを利用した場合もN+1問題を解消することができた。
# activestorage/lib/active_storage/attached/model.rb
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
コントローラの修正
def index
@q = Room.includes(image_attachment: :blob).ransack(params[:q])
@rooms = @q.result
@count = @rooms.count
end
N+1問題解消後のログ
複数発行されていたSQLが1つにまとまっていることが確認できる。
↳ app/controllers/rooms_controller.rb:11:in `index'
Rendering layout layouts/application.html.erb
Rendering rooms/index.html.erb within layouts/application
Room Load (0.0ms) SELECT "rooms".* FROM "rooms"
↳ app/views/rooms/index.html.erb:19
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? AND "active_storage_attachments"."record_id" IN (?, ?, ?, ?) [["record_type", "Room"], ["name", "image"], ["record_id", 1], ["record_id", 2], ["record_id", 3], ["record_id", 4]]
↳ app/views/rooms/index.html.erb:19
ActiveStorage::Blob Load (0.1ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (?, ?, ?, ?) [["id", 1], ["id", 5], ["id", 7], ["id", 9]]
↳ app/views/rooms/index.html.erb:19
アソシエーション先のモデルがActive Storageを使用していた場合
N+1問題が発生している箇所をもう一箇所見つけてしまった。
N+1問題発生時のログ
ログインユーザーが予約している部屋一覧を取得しようとした際、以下のログが確認できた。
Rendering layout layouts/application.html.erb
Rendering reservations/index.html.erb within layouts/application
Reservation Load (0.1ms) SELECT "reservations".* FROM "reservations" WHERE "reservations"."user_id" = ? [["user_id", 3]]
↳ app/views/reservations/index.html.erb:18
Room Load (0.0ms) SELECT "rooms".* FROM "rooms" WHERE "rooms"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 1], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
Room Load (0.1ms) SELECT "rooms".* FROM "rooms" WHERE "rooms"."id" = ? LIMIT ? [["id", 2], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Attachment Load (0.0ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 2], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 5], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
Room Load (0.0ms) SELECT "rooms".* FROM "rooms" WHERE "rooms"."id" = ? LIMIT ? [["id", 3], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Attachment Load (0.0ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 3], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
ActiveStorage::Blob Load (0.0ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 7], ["LIMIT", 1]]
↳ app/views/reservations/index.html.erb:20
Rendered reservations/index.html.erb within layouts/application (Duration: 33.6ms | Allocations: 36513)
該当箇所のビュー、コントローラ、モデル
# index.html.erb
<%= image_tag(reservation.room.image.variant(resize: '500x100')) %>
# reservations_controller
def index
@reservations = current_user.reservations
end
# reservation.rb
class Reservation < ApplicationRecord
belongs_to :user
belongs_to :room
end
N+1問題
該当箇所はReservation
モデルとRoom
モデル間でアソシエーションが組まれている。
そのため、取得したreservation
のデータ一つ一つに対して、room
データを取得するためにSQLが発行され(1つ目のSQL)、さらにroom
に紐づく画像を取得するためにSQLが発行されてしまっている(2つ目、3つ目のSQL)。
SELECT "rooms".* FROM "rooms"
WHERE "rooms"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
SELECT "active_storage_attachments".* FROM "active_storage_attachments"
WHERE "active_storage_attachments"."record_id" = ? AND "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? LIMIT ? [["record_id", 1], ["record_type", "Room"], ["name", "image"], ["LIMIT", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs"
WHERE "active_storage_blobs"."id" = ? LIMIT ? [["id", 1], ["LIMIT", 1]]
コントローラの修正
includes
メソッドを活用して、コントローラを以下のように修正。
アソシエーションが絡むと
with_attached_attachment_name
がエラーになってしまった。
アソシエーション絡んでも使う方法コメントで教えてください(そもそも使えない?)。
def index
@reservations = current_user.reservations.includes(room: { image_attachment: :blob })
end
N+1問題解消後のログ
複数発行されていたSQLが1つにまとまっていることが確認できる。
Rendering layout layouts/application.html.erb
Rendering reservations/index.html.erb within layouts/application
Reservation Load (0.1ms) SELECT "reservations".* FROM "reservations" WHERE "reservations"."user_id" = ? [["user_id", 3]]
↳ app/views/reservations/index.html.erb:18
Room Load (0.1ms) SELECT "rooms".* FROM "rooms" WHERE "rooms"."id" IN (?, ?, ?) [["id", 1], ["id", 2], ["id", 3]]
↳ app/views/reservations/index.html.erb:18
ActiveStorage::Attachment Load (0.1ms) SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = ? AND "active_storage_attachments"."name" = ? AND "active_storage_attachments"."record_id" IN (?, ?, ?) [["record_type", "Room"], ["name", "image"], ["record_id", 1], ["record_id", 2], ["record_id", 3]]
↳ app/views/reservations/index.html.erb:18
ActiveStorage::Blob Load (0.1ms) SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN (?, ?, ?) [["id", 1], ["id", 5], ["id", 7]]
↳ app/views/reservations/index.html.erb:18
参考
最後に
いかがでしたでしょうか。
ここ違うよ!でしたり、こうした方がいいよ!などがあればコメントいただけると幸いです。
他にも下記のような記事を投稿しております。
興味がありましたら、ぜひご覧ください。