🦧

Rails N+1問題

2024/07/28に公開

N+1問題とは?

N+1問題とは、一つのクエリでN個のデータを取得して、その後各データでN回のクエリが発生する場合のことを指します。これによって、何度もDBへのアクセスが発生してアプリケーションのパフォーマンスが著しく低下します。

N+1問題の具体例

Railsアプリケーションで、UserとそのUserが持つ記事を表すPostが一対多の関係であるとしましょう。
この場合、以下のようにコードを書くとN+1問題が発生します。

# N+1問題が発生する例
users = User.all
users.each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

上記コードは下記のようなクエリを発行します。

  1. 最初にユーザーを取得するクエリ(1回)
SELECT * FROM users;
  1. 各ユーザに対する記事を呼び出すクエリ(N回)
SELECT * FROM posts WHERE user_id = 1;
SELECT * FROM posts WHERE user_id = 2;
...
SELECT * FROM posts WHERE user_id = N;

これで、全体としN+1回のクエリを呼び出しています。

対応策

ここでは、上記のようなN+1問題をどのように解決すればいいかを説明します。
今回紹介するのは、Railsに元から含まれている三つのメソッドです。どれもN+1問題を解決する仕組みは同じで、事前に必要なデータ(今回ならPost)をロードしておくというものです。では、それぞれ見ていきましょう。

includes

このincludesメソッドの基本的な使い方は、関連付けされている(アソシエーション)モデルを引数にとることで、必要最低限のクエリの回数で関連するモデルのデータも取得できるというものです。
具体的な使い方は下記のようになっています。

users = User.includes(:post) #User.allだったところ
users.each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

これによって発行されるクエリが下記です。

SELECT * FROM users;
SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);

User.allの時は、N+1回のクエリが発行されていたのに対して、この場合は2回のクエリに削減することができています。一つ目のクエリは、見ての通りUsersテーブルの全てのデータを取得しています。ここでidを覚えておき、2行目のIN (1, 2, 3, ...)で使用しています。このようにすることで、userに紐づいているpostを一回のクエリで取得することができています。
また、実はincludesメソッドはRails側で、下記で説明するeager_loadと同じデータの取得の仕方をするときもあります。下記で、earger_loadのデータ取得の仕方について見ていきましょう。

eager_load

このeager_loadメソッドは、関連するモデルのテーブルと元のテーブルを結合させてからクエリを発行することで一つのクエリでデータ取得を完結させるというものです。
具体的な使い方と発行されるクエリは下記のようになっています。

users = User.eager_load(:posts)
users.each do |user|
  user.posts.each do |post|
    puts post.title
  end
end

そして、発行されるクエリが下記です。

SELECT users.*, posts.* FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id;

このeager_loadは、テーブルをJOINするので複雑な条件で関連データを取得するときに使うのが適しています。

preload

最後に紹介するのは、preloadメソッドです。このメソッドは、最初に紹介したincludesメソッドと同様で、クエリを二つで関連データを取ってくるメソッドです。inclidesメソッドとの違いは、常に二つのクエリを作成して関連データを取得するところです(eager_loadの取り方はしない)。
includesメソッドのクエリを二つ発行するバージョンと同じ動きをするのでここでは、具体例を省略します。

まとめ

最後にまとめます。
Railsでよく出くわす、N+1問題を解決するには基本的には、includesメソッドを使えば良いと思います。
というのも、includesメソッドはeager_loadメソッドとpreloadメソッドの合わせ技で、Railsが自動でよしなにしてくれます。そのため、明確な理由(データが大きすぎるので必ず二つのクエリを発行させて負荷分散させたいとか)がない限りはincludesメソッドを使えば問題ないと思います。

Discussion