N+1問題とは?
はじめに
プログラミング初学者です🔰
今日は、やらなければ!と思っていたN+1問題について学んでいきます!
N+1問題とは?
N+1問題とは、一つのデータベースクエリが発行され、その結果をもとにN回の追加のクエリが発行される状況を指します。
この問題は、アプリケーションのパフォーマンスに大きな影響を及ぼします。
データベースへの問い合わせが増えると、応答時間が遅くなり、ユーザーエクスペリエンスが低下してしまうからです。
gem「bullet」
N+1問題が起きている箇所を特定・アラート表示してくれるgemもあります!
具体例
- ユーザーはたくさんのブログ記事を持っている
- ブログ記事は1つのユーザーに所属している
class User < ApplicationRecord
has_many :blogs
end
class Blog < ApplicationRecord
belongs_to :user
end
ここで、すべてのブログ記事とそれぞれの記事の作者(ユーザー)の名前を表示するとします。
以下のようなコードで実現できます。
blogs = Blog.all
blogs.each do |blog|
puts "#{blog.title} was written by #{blog.user.name}"
end
まず、Blog.allが実行されると以下のSQLが発行されます。
SELECT "blogs".* FROM "blogs"
これはブログ記事の全データを取得するためのSQLです。
ここまでは問題ありません!
しかしその後 .each を使って各ブログ記事のユーザー名を取得しようとすると、
ブログ記事一つ一つに対して以下のようなSQLが発行されます。
SELECT "users".* FROM "users" WHERE "users"."id" = ? [["id", 1]]
SELECT "users".* FROM "users" WHERE "users"."id" = ? [["id", 2]]
SELECT "users".* FROM "users" WHERE "users"."id" = ? [["id", 3]]
...
ブログ記事の数だけ(N回)、データベースにアクセスしてユーザーの名前を取得しています。
つまり、100個のブログ記事があれば100回もデータベースにアクセスしてしまいます😵
(ここで "?" の部分はブログ記事のユーザーIDに置き換えられます。)
この「N回+1回(最初の全ブログ記事取得の1回)」が、N+1問題と呼ばれる由来です。
なぜこのような問題が起きるかというと、has_many
やbelongs_to
といったアソシエーションが組まれているためです。
これを解決するためには、ActiveRecordのincludesメソッドを使って、ブログ記事を取得するときに同時にユーザー情報も取得しておくことが一般的です。
blogs = Blog.includes(:user)
blogs.each do |blog|
puts "#{blog.title} was written by #{blog.user.name}"
end
こうすることで、以下のようなSQLが発行されます。
SELECT "blogs".* FROM "blogs"
SELECT "users".* FROM "users" WHERE "users"."id" IN (?, ?, ?, ...)
必要な情報を2回のデータベースアクセス(ブログ記事とユーザー情報の取得)で済ませられるため、パフォーマンスが向上します!(ここでも "?" の部分はブログ記事のユーザーIDに置き換えられます。)
このように、N+1問題はデータベースへのアクセス回数が多くなりすぎてしまう問題であり、これを解決することによりアプリケーションのパフォーマンスを保つことができます!!
適切なメソッドの適用
私が調べた中だとincludes
の他にも
preload
, eager_load
というメソッドがあるようです!
違いを調べてみました!
includes
Railsが自動的に最適な戦略を選択します(preloadまたはeager_load)。
includes
メソッドは、最も柔軟性があり、内部的には状況によりpreload
かeager_load
を使うことで最適な方法でデータを取得します。
上記でも説明しましたが、例えば、次のようにincludes
を使ってユーザーとそのブログ記事を取得するとします。
users = User.includes(:blogs)
上記のコードは、次の2つのSQLクエリを発行します。
SELECT "users".* FROM "users"
SELECT "blogs".* FROM "blogs" WHERE "user_id" IN (?, ?, ..., ?)
しかし、もし以下のようにincludes
とwhere
を一緒に使った場合、
includes
は内部的にeager_load
を使います👀
users = User.includes(:blogs).where(blogs: {title: 'My Title'})
上記のコードは次のSQLクエリを発行します。
SELECT "users".*
FROM "users"
LEFT OUTER JOIN "blogs" ON "blogs"."user_id" = "users"."id"
WHERE "blogs"."title" = 'My Title'
preload
関連付けられたオブジェクトを一括で読み込みます。
preload
メソッドは、必ず2つのクエリ(まず元のデータを取得し、次に関連付けられたデータを一括で取得)を発行します。これはincludes
が最初の例で行っていたのと同じです。
users = User.preload(:blogs)
上記のコードは、次の2つのSQLクエリを発行します。
includesと同様の動作をしますが、ここでは関連するブログをフィルタリングすることはできません。
SELECT "users".* FROM "users"
SELECT "blogs".* FROM "blogs" WHERE "user_id" IN (?, ?, ..., ?)
eager_load
関連付けられたオブジェクトを一度に読み込みます。
eager_load
メソッドは、必ず1つのクエリ(LEFT OUTER JOINを使って元のデータと関連付けられたデータを一度に取得)を発行します。
users = User.eager_load(:blogs)
上記のコードは次のSQLクエリを発行します。
SELECT "users"."id" AS t0_r0, "users"."name" AS t0_r1, "users"."created_at" AS t0_r2, "users"."updated_at" AS t0_r3, "blogs"."id" AS t1_r0, "blogs"."user_id" AS t1_r1, "blogs"."title" AS t1_r2, "blogs"."created_at" AS t1_r3, "blogs"."updated_at" AS t1_r4
FROM "users"
LEFT OUTER JOIN "blogs" ON "blogs"."user_id" = "users"."id"
このコードは、全てのユーザーとそれぞれのユーザーに関連するブログを一度に取得します。
JOINを使用しているので、結果のセットはユーザーとブログの全ての組み合わせを含みます。
大量のデータがあるときにはこの方法が効率的とは限りません。
具体的な例
- ブログポストとその著者を一覧表示する場合
includes
またはpreload
を使うと、一度に全てのブログポストを取得し、
それから全ての著者を取得する2つのクエリが発行されます。効率的💡
-
特定のブログポストの著者の名前でフィルタリングする場合
(例えば、'John'という名前の著者の全てのブログポストを取得する)
eager_load
を使用して、ブログポストとその著者を同時にフィルタリングできます。
一度のクエリで可能になります💡
さいごに
それぞれがどのようにデータを取得するかを理解し、要件やデータ量に応じて最適なものを選択することが大切そうです🤔
私はまだSQL文についての理解があまりできていないので、まずはそこからですね〜〜
とりあえず、ポートフォリオを直しながらどう変わるのかみていきたいと思います!
間違いなどあれば、ぜひ教えていただけますと幸いです!
参考にさせていただいた記事🌱
Discussion