1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

【Ruby on Rails】Active StorageのN+1問題

Last updated at Posted at 2023-03-26

はじめに

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_nameincludesメソッドを使うメソッドとして定義されているため、これを活用することでN+1問題を解消することができる。

また、with_attached_attachment_nameattachment_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

参考

最後に

いかがでしたでしょうか。
ここ違うよ!でしたり、こうした方がいいよ!などがあればコメントいただけると幸いです。

他にも下記のような記事を投稿しております。
興味がありましたら、ぜひご覧ください。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?