📖

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メソッド
    の使用で解決できる。

https://zenn.dev/eliri/articles/3ca216e3c7c86f#n%2B1クエリ問題とは

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])

参考文献

https://qiita.com/Kazuyaa/items/ec663856925476f237e3
https://zenn.dev/goldsaya/articles/3af48dadc6cc0f
https://qiita.com/wangqijiangjun/items/71797f4a711a16f33acb
https://qiita.com/1129-tame/items/5c8db29e4718b9f38959
https://qiita.com/musenmai/items/e48e5594e6237a57703c
https://zenn.dev/kinzal/articles/c5745e7d9a950c

Discussion