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

# はじめに

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問題発生時のログ

部屋一覧を画像付きで表示する際、以下のようなログが確認できた。

```bash
↳ 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
```

### 該当箇所のビュー、コントローラ、モデル

```ruby
# index.html.erb
<p><%= image_tag(room.image.variant(resize: 399)) if room.image.attached? %></p>
```

```ruby
# rooms_controller
def index
  @q = Room.ransack(params[:q]) # ransackの検索機能を使用
  @rooms = @q.result
  @count = @rooms.count
end
```

```ruby
# room.rb
class Room < ApplicationRecord
  belongs_to :user
  has_many :reservation
  has_one_attached :image       # Active Storageを使用
end
```

### 部屋一覧の取得

まず、下記のSQLによって`rooms`テーブルから部屋情報一覧を取得していることがわかる。

（今回は部屋が4つ登録されていたため、4レコード分のデータが取得されている。）

```sql
SELECT "rooms".* FROM "rooms"
```

### 部屋のレコードごとに画像を取得

部屋のレコードごとに画像を取得している。

`["record_id", 1]`部分でレコードごとにSQLを発行していることが確認できる。

1つ目のSQLで取得したレコードのidを元にして、2つ目のSQLでデータを取得している。

```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`）。

```ruby
# activestorage/lib/active_storage/attached/model.rb
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
```

### コントローラの修正

`with_attached_attachment_name`を活用して、コントローラを以下のように修正。

```ruby
def index
  @q = Room.with_attached_image.ransack(params[:q])
  @rooms = @q.result
  @count = @rooms.count
end
```

### N＋1問題解消後のログ

複数発行されていたSQLが1つにまとまっていることが確認できる。

```bash
↳ 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問題を解消することができた。

```ruby
# activestorage/lib/active_storage/attached/model.rb
scope :"with_attached_#{name}", -> { includes("#{name}_attachment": :blob) }
```

### コントローラの修正

```ruby
def index
  @q = Room.includes(image_attachment: :blob).ransack(params[:q])
  @rooms = @q.result
  @count = @rooms.count
end
```

### N＋1問題解消後のログ

複数発行されていたSQLが1つにまとまっていることが確認できる。

```bash
↳ 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問題発生時のログ

ログインユーザーが予約している部屋一覧を取得しようとした際、以下のログが確認できた。

```bash
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)
```

### 該当箇所のビュー、コントローラ、モデル

```ruby
# index.html.erb
<%= image_tag(reservation.room.image.variant(resize: '500x100')) %>
```

```ruby
# reservations_controller
def index
  @reservations = current_user.reservations
end
```

```ruby
# 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）。

```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`がエラーになってしまった。
アソシエーション絡んでも使う方法コメントで教えてください（そもそも使えない？）。
> 

```ruby
def index
  @reservations = current_user.reservations.includes(room: { image_attachment: :blob })
end
```

### N＋1問題解消後のログ

複数発行されていたSQLが1つにまとまっていることが確認できる。

```bash
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
```

# 参考

https://shuttodev.hatenablog.com/entry/2019/09/10/012916

https://karlley.hatenablog.jp/entry/2022/11/07/082439

https://www.web-dev-qa-db-ja.com/ja/ruby-on-rails/activestorage添付ファイルのあるレコードをクエリする方法は？/807113834/

https://qiita.com/ozin/items/f4aea5b244a6aa03caee

# 最後に

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

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

https://qiita.com/sekkey_777/items/7907f7d0f46fd6eaad9d

https://qiita.com/sekkey_777/items/729dada2998d3914e138

https://qiita.com/sekkey_777/items/b40135995de01ae95dc2

https://qiita.com/sekkey_777/items/8a8b3ee2af3c46df50f0