#はじめに
既存アプリにおいてActiveStorageで画像を取得する際発生していたN+1問題を改善したため、これを機にN+1問題についてまとめてみました。
##実行環境
・macOS
・Rails 6.1.4
・Ruby 2.6.7
#N+1問題とは
モデルに対してeach文などで繰り返しを行う時、そのモデルに紐付く要素に対してモデルの数分SQLクエリが発行されてしまう問題のこと。レコード数が多くなってしまうとレスポンスが遅くなるなどの影響が出てしまう。
自分のアプリで実際にN+1問題が起きていた箇所を例に見ていきます。
class Shop < ApplicationRecord
belongs_to :user
end
userがshopを投稿するため上の関連付けになっています
class ShopsController < ApplicationController
def index
@shops = Shop.all
end
end
shopレコードは上のコードで取得します。
<% if current_user?(shop.user) %>
<div class="manipulate_shop">
<%= link_to icon("far", "edit"), edit_shop_path(shop), class: "edit_icon" %>
| <%= link_to icon("far", "trash-alt"), shop, method: :delete, data: { confirm: "本当に削除しますか?" }, class: "trash_icon" %>
</div>
<% end %>
ログインユーザーが店舗の投稿主かどうかでレイアウトを変えるコードです。eachがありませんがshopを繰り返し読み込んでいます。
このviewコードを読み込む際N+1問題が以下のように発生します。
SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 3], ["LIMIT", 1]]
shopに紐付くuserの数だけSQLを発行しています。
このままではレコードが多くなるほどSQLも多く発行されるので、将来的にレスポンスが遅くなってしまいます。
そのため、コントローラでshopを取得するときにincludesを用いて一緒にそのshopを投稿したuserも取得します。
class ShopsController < ApplicationController
def index
@shops = Shop.all.includes(:user)
end
end
以下がincludesを使った後のSQLクエリです。一回の問い合わせでレコードを取得してくれるようになりました。
IN (1, 2, 3)は WHERE "users"."id" = 1 OR "users"."id" = 2 OR "users"."id" = 3と同じでusersテーブルからidが1か2か3がtrueになるものでテーブルを作成しています。
SELECT "users".* FROM "users" WHERE "users"."id" IN ($1, $2, $3) [["id", 1], ["id", 2], ["id", 3]]
これでN+1問題が解消されました。
#ActiveStorageでのN+1問題
shopモデルに画像を紐づけるためにActiveStorageを使いimagesとしてアタッチしました。
class Shop < ApplicationRecord
belongs_to :user
has_many_attached :images
end
先ほどのshops_contorollerと同じindexアクションから取得した@shopsを使って以下のコードを書きました。
画像が紐づいているかどうかで画像表示を変えています。このshop.images.first
の部分でN+1問題が発生しました。
<%= link_to shop do %>
<% if shop.images.attached? %>
<%= image_tag shop.images.first, class: "card-img" %>
<% else %>
<%= image_tag "noimage.png", class: "card-img" %>
<% end %>
<% end %>
以下が発行されたSQLクエリです。N+1問題が発生しております。
SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 ORDER BY "active_storage_attachments"."id" ASC LIMIT $4 [["record_id", 3], ["record_type", "Shop"], ["name", "images"], ["LIMIT", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 5], ["LIMIT", 1]]
SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 ORDER BY "active_storage_attachments"."id" ASC LIMIT $4 [["record_id", 2], ["record_type", "Shop"], ["name", "images"], ["LIMIT", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 2], ["LIMIT", 1]]
SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_id" = $1 AND "active_storage_attachments"."record_type" = $2 AND "active_storage_attachments"."name" = $3 ORDER BY "active_storage_attachments"."id" ASC LIMIT $4 [["record_id", 1], ["record_type", "Shop"], ["name", "images"], ["LIMIT", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
ActiveStorageの場合はwith_attached_つけた名前
でN+1問題を解消できます。shopレコードに関連するactive_storage_attachmentsとactive_storage_blobsをまとめて取得してくれます。
class ShopsController < ApplicationController
def index
@shops = Shop.all.includes(:user).with_attached_images
end
end
以下が改善後のSQLクエリです。今回の場合、改善後により多くのデータを取得してしまっていますがSQLクエリの回数が抑えられています。
SELECT "active_storage_attachments".* FROM "active_storage_attachments" WHERE "active_storage_attachments"."record_type" = $1 AND "active_storage_attachments"."name" = $2 AND "active_storage_attachments"."record_id" IN ($3, $4, $5) [["record_type", "Shop"], ["name", "images"], ["record_id", 3], ["record_id", 2], ["record_id", 1]]
SELECT "active_storage_blobs".* FROM "active_storage_blobs" WHERE "active_storage_blobs"."id" IN ($1, $2, $3, $4, $5, $6, $7, $8) [["id", 1], ["id", 2], ["id", 3], ["id", 4], ["id", 5], ["id", 6], ["id", 7], ["id", 8]]
以上です。