Active StorageのN+1問題を解決する

FBCのActive Storageを使ったユーザーアイコンの表示機能を実装しています。 Active Storageを使用した画像取得時にN+1問題が発生していると指摘を受けたので解決法を調べました。

結論

Active StorageのN+1問題はwith_attached_attachment_name スコープを使うことで解決する。

N+1問題とは?

ループ処理の中で都度SQLを発行してしまい、大量のSQLが発行されてパフォーマンスが低下してしまう問題のこと

N+1問題は関連モデルで他のモデルの情報を参照する場合に発生するケースが殆どです。

【Ruby on Rails】N+1問題ってなんだ? - Qiita

ORMとは?

N+1問題を調べていると頻出するORM について調べました。

RDB(Relational Database: 関係データベース)に対するデータの操作をオブジェクト指向型言語のやり方で扱えるようにするための手法

  • Object Relational Mapper の略
  • O/Rマッパー と書かれている場合もある
  • SQLを直接書く必要が無くなる(Railsの場合はActive Recordがこの働きを担っている)
  • RailsもORMの一つ
  • N+1問題が起こりやすいので注意が必要

ORMとは?|SQLAlchemy 概要と基本の使い方

関連モデルを使った場合のN+1の対策

has_manybelongs_to 等を使用したアソシエーションを組まれている場合に、一覧画面で関連先の情報を取得する際にN+1問題が発生することが多いようです。 関連モデルを使った場合のN+1問題については下記のメソッドを使って対策するようです。

  • include
  • preload
  • eager_load

ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由 - Qiita

Rails で includes して N+1 問題対策 - Qiita

Active StorageのN+1の対策

今記事の本題です。 今回の場合は他のモデルとのアソシエーションは組んでおらず、次のように1つのモデルでActive Storageを使用した画像表示機能でN+1問題が発生しています。

# app/models/user.rb

class User < ApplicationRecord
  devise :database_authenticatable, :registerable,
         :recoverable, :rememberable, :validatable
  has_one_attached :icon # ←Active Storageで画像表示用の属性を追加
end
# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.order(:id).page(params[:page])
  end
end
  • .page(params[:page]) はkaminari gemでのページネーション用メソッド
  • order(:id)id 順でレコードを並び替え
# app/views/users/index.html.erb

<% @users.each do |user| %>
<tr>
  <% if user.icon.attached? %>
  <td><%= image_tag user.icon.variant(resize_to_fit: [500, 500]).processed, class: 'user-icon' %> # ←ここでN+1が発生
  </td>
  <% else %>
  <td></td>
  <% end %>
  <td><%= user.email %></td>
  <td><%= user.name %></td>
  <td><%= user.postal_code %></td>
  <td><%= user.address %></td>
  <td><%= link_to t('views.common.show'), user %></td>
</tr>
<% end %>

上記の場合でusers#index にアクセスするとuserの数だけSELECT文が発行されN+1が発生しています。

Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (0.1ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 25], ["OFFSET", 0]]
↳ app/views/users/index.html.erb:16
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", "User"], ["name", "icon"], ["LIMIT", 1]]
  ↳ app/views/users/index.html.erb:18
  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", "User"], ["name", "icon"], ["LIMIT", 1]]
  ↳ app/views/users/index.html.erb:18
  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", "User"], ["name", "icon"], ["LIMIT", 1]]
  ↳ app/views/users/index.html.erb:18

Active Storageは画像表示用のテーブルを用意し、そのテーブルに画像を保存する仕組みです。

Active Storageを使った画像アップロード機能のセットアップ - karlley's tech blog

この画像保存用のテーブルから画像取得する際の重複したSQLを排除することでN+1問題を解決できます。 Railsに用意されているwith_attached_attachment_name スコープを使うと良いようです。

Active StorageのN+1問題を解決する - Qiita

users#index メソッドをActive Storageに用意されているwith_attached_attachement_name スコープを使って次のように修正します。 with_attached_attachment_nameattachement_name はActive Storageをセットアップする際にapp/models/user.rb に追加したhas_one_attached :icon の属性を指します(今回の場合だと:icon)。

# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def index
    @users = User.order(:id).page(params[:page]) # これを↓のように修正
    @users = User.with_attached_icon.order(:id).page(params[:page]) # with_attached_attachment_name を使う
  end
end

修正後のSQLは次のように表示する画像取得をIN句を使ってまとめて取得するようになります。

Rendering layout layouts/application.html.erb
Rendering users/index.html.erb within layouts/application
User Load (0.3ms)  SELECT "users".* FROM "users" ORDER BY "users"."id" ASC LIMIT ? OFFSET ?  [["LIMIT", 25], ["OFFSET", 0]]
↳ app/views/users/index.html.erb:16
ActiveStorage::Attachment Load (0.6ms)  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", "User"], ["name", "icon"], ["record_id", 1], ["record_id", 2], ["record_id", 3], ["record_id", 4], ["record_id", 5], ["record_id", 6], ["record_id", 7], ["record_id", 8], ["record_id", 9], ["record_id", 10], ["record_id", 11], ["record_id", 12], ["record_id", 13], ["record_id", 14], ["record_id", 15], ["record_id", 16], ["record_id", 17], ["record_id", 18], ["record_id", 19], ["record_id", 20], ["record_id", 21], ["record_id", 22], ["record_id", 23], ["record_id", 24], ["record_id", 25]]

with_attached_attachment_name は内部的にinclude を使用しているようです。

Active StorageのN+1問題に対処する - シュッと開発日記

ActiveStorage::Attached::Model | RailsDoc(β)

この辺の仕組みはまだ理解があいまいなので、再度深堀りしてみようと思います。

まとめ

前々から気になっていたN+1問題への理解が深まったので良かった。 スコープ等の理解が全然できていないので、今後もRailsの深い部分を学んでいきたい。

参照

【Ruby on Rails】N+1問題ってなんだ? - Qiita

ORMとは?|SQLAlchemy 概要と基本の使い方

ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由 - Qiita

Rails で includes して N+1 問題対策 - Qiita

Active Storageを使った画像アップロード機能のセットアップ - karlley's tech blog

Active StorageのN+1問題を解決する - Qiita

Active StorageのN+1問題に対処する - シュッと開発日記

ActiveStorage::Attached::Model | RailsDoc(β)