N+1クエリ問題
N+1問題とは
N+1問題とは、データベースからのデータ取得が「無駄にたくさん」発生してしまう現象。
前回まとめたもの。
N+1クエリ問題とは
データの取得時に不必要に多くのクエリ(問い合わせ)が発行される問題。
<% @users.each do |user| %>
<%= user.owned_groups.pluck(:name).join(", ") %>
<% end %>
@users = User.all
上記コードだと、発生するクエリは以下。
SELECT * FROM users;
SELECT * FROM groups WHERE user_id = 1;
SELECT * FROM groups WHERE user_id = 2;
SELECT * FROM groups WHERE user_id = 3;
...
最初のUserを取得するクエリ(1回) + それぞれのユーザーの owned_groups を取得するクエリ(N回) = 「N+1クエリ」が発生する。
ユーザーが100人いる状況だと、全ユーザの取得(1回)+各ユーザの owned_groups を取得(100回)で、計101回クエリが発生することになる。
問題になる点
- クエリの数が増えると、データベースへの負荷が高まる
- パフォーマンスが悪化する(特にデータ量が多い場合)
解決策
- preloadメソッド
- eager_loadメソッド
- includesメソッド
の使用で解決できる。
taskの一覧画面
ユーザー:タスクの関係が、1:多。
ユーザー1人に対して複数のタスクを所持している。
ビュー
<% @tasks.each do |task| %>
<%= task.user.name %>
<% end %>
コントローラ
def index
sort_order = params[:sort] || 'created_at DESC'
@tasks = Task.joins(:user).where(users: { is_active: true }).order(sort_order).page(params[:page])
end
この書き方でログを見ると、下記のようにクエリが複数回出てしまう。
User Load (0.2ms) SELECT "users".* FROM "users" WHERE "users"."id" = ? LIMIT ?
↳ app/views/public/tasks/index.html.erb:21
下記もタスクごとに個別のクエリが発行されてしまっている。
(task.task_comments.count
task.favorites.count
)
SELECT COUNT(*) FROM "task_comments" WHERE "task_comments"."task_id" = ?
SELECT COUNT(*) FROM "favorites" WHERE "favorites"."task_id" = ?
修正方法
n+1を解決するメソッド
- preload
- eager_load
- includes
以下に書き換える
コントローラ
def index
sort_order = 'tasks.created_at DESC'
@tasks = Task
.includes(
:comments,
:favorites,
user: { image_attachment: :blob }
)
.references(:user)
.where(users: { is_active: true })
.order(sort_order)
.page(params[:page])
end
.includes(...)
.includes(:comments, :favorites, user: { image_attachment: :blob })
N+1問題を防ぐために関連モデルを一括で読み込む。
コメント、お気に入り、ユーザーの画像、
.references(:user)
where(users: { is_active: true })
で別テーブルの条件を指定しているので、references(:user)
でJOIN先を指定する。
.where(users: { is_active: true })
のような条件をつける場合に必要
JOIN
複数のテーブルの関連したデータを「つなげて」一度に取り出すSQLの仕組み。
.where(users: { is_active: true })
退会済でない、アクティブなユーザーのみ取得。
.order(sort_order)
順序変更。 sort_order = 'tasks.created_at DESC'
で指定したように、投稿順にする。
.page(params[:page])
ページネーション
ビュー
.count
を.size
にする!
<!-- これを… -->
<%= task.comments.count %>
<%= task.favorites.count %>
<!-- 以下に変更 -->
<%= task.comments.size %>
<%= task.favorites.size %>
.count
毎回SQLを発行して 常にDBに聞きに行く。
.size
メモリにあれば数え、なければSQL発行。
length
SQLを発行しない。
ユーザー一覧画面
変更前
def index
@users = User.where(is_active: true).page(params[:page])
end
変更後
タスクと同じようにinclude
を使用する。
@users = User.where(is_active: true).includes(:tasks).page(params[:page])
参考文献
Discussion