🦉

N+1問題とは?

2023/06/30に公開

はじめに

プログラミング初学者です🔰
今日は、やらなければ!と思っていたN+1問題について学んでいきます!

N+1問題とは?

N+1問題とは、一つのデータベースクエリが発行され、その結果をもとにN回の追加のクエリが発行される状況を指します。

この問題は、アプリケーションのパフォーマンスに大きな影響を及ぼします。
データベースへの問い合わせが増えると、応答時間が遅くなり、ユーザーエクスペリエンスが低下してしまうからです。

gem「bullet
N+1問題が起きている箇所を特定・アラート表示してくれるgemもあります!
https://techtechmedia.com/nplusone_query-bullet-rails/

具体例

  • ユーザーはたくさんのブログ記事を持っている
  • ブログ記事は1つのユーザーに所属している
model
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が発行されます。

sql
SELECT "blogs".* FROM "blogs"

これはブログ記事の全データを取得するためのSQLです。

ここまでは問題ありません!

しかしその後 .each を使って各ブログ記事のユーザー名を取得しようとすると、
ブログ記事一つ一つに対して以下のようなSQLが発行されます。

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_manybelongs_toといったアソシエーションが組まれているためです。

これを解決するためには、ActiveRecordのincludesメソッドを使って、ブログ記事を取得するときに同時にユーザー情報も取得しておくことが一般的です。

blogs = Blog.includes(:user)

blogs.each do |blog|
  puts "#{blog.title} was written by #{blog.user.name}"
end

こうすることで、以下のようなSQLが発行されます。

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メソッドは、最も柔軟性があり、内部的には状況によりpreloadeager_loadを使うことで最適な方法でデータを取得します。

上記でも説明しましたが、例えば、次のようにincludesを使ってユーザーとそのブログ記事を取得するとします。

users = User.includes(:blogs)

上記のコードは、次の2つのSQLクエリを発行します。

sql
SELECT "users".* FROM "users"
SELECT "blogs".* FROM "blogs" WHERE "user_id" IN (?, ?, ..., ?)

しかし、もし以下のようにincludeswhereを一緒に使った場合、
includesは内部的にeager_loadを使います👀

users = User.includes(:blogs).where(blogs: {title: 'My Title'})

上記のコードは次のSQLクエリを発行します。

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と同様の動作をしますが、ここでは関連するブログをフィルタリングすることはできません。

sql
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クエリを発行します。

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文についての理解があまりできていないので、まずはそこからですね〜〜
とりあえず、ポートフォリオを直しながらどう変わるのかみていきたいと思います!

間違いなどあれば、ぜひ教えていただけますと幸いです!

参考にさせていただいた記事🌱

https://qiita.com/massaaaaan/items/4eb770f20e636f7a1361

https://zenn.dev/iloveomelette/articles/6be524dd7628b6

https://qiita.com/seiyatakahashi/items/37bef984168b9f1ce25e

Discussion