本記事はQmonus Value Streamの投稿キャンペーン記事です。
初めに
「N+1問題」自分が初めてこれを聞いたのは数年前。
おそらくこれからWeb開発に携わる方、特にSQLや他のクエリ操作をする機会がバックエンドを目指す人にとって、この入りの説明があるとイメージつきやすいのでは?と思ったので記事にします。
いまはTypeScriptでもバックエンドまでかけちゃう時代です。フロントエンド開発者でもDB回りのことを知っておいて損はないですし、初心者の方向けに解説していますので是非最後まで読んでみてください。
これを最後まで読めば、少なくとも現場で「N+1問題」という言葉が飛んできたときに拒否反応を起こすことはなくなるでしょう!
その1 図書館の係員(User と Book)
N+1問題が発生するコード
# app/controllers/users_controller.rb など
users = User.all
users.each do |user|
# ここで各ユーザーの本を取得するために追加のSQLが発行される(N+1)
books = user.books
end
-
User.allでユーザー一覧を取得するときに 1回 -
user.booksをユーザーごとに呼ぶたびにSELECT "books" ... WHERE "user_id" = ?が走る
最適化の例(Rails / includes を使った Eager Loading)
# ユーザーと、そのユーザーが借りた本をまとめて取得する
users = User.includes(:books).all
# users = User.eager_load(:books).all でもOK(INNER JOINになる)
users.each do |user|
# ここではすでに books がメモリ上に読み込まれているので追加クエリは発生しない
books = user.books
end
関連定義
この場合モデル側にはこのように関連が定義されています
# app/models/user.rb
class User < ApplicationRecord
has_many :books, dependent: :destroy
end
# app/models/book.rb
class Book < ApplicationRecord
belongs_to :user
end
その2 レストランのメニューと料理(Menu と Dish)
N+1問題が発生する例
menus = Menu.all # メニュー一覧を1回のクエリで取得
menus.each do |menu|
# ここで dishes を読むたびに追加のクエリが発行される
dishes = menu.dishes
end
- メニューが10件なら
Menu.allの1回 +menu.dishesが10回 → 合計11クエリ
最適化の例(Rails / includes)
menus = Menu.includes(:dishes).all # メニューと料理をまとめて取得
menus.each do |menu|
# dishes は既に読み込まれているため追加クエリは出ない
dishes = menu.dishes
end
関連定義
class Menu < ApplicationRecord
has_many :dishes, dependent: :destroy
end
class Dish < ApplicationRecord
belongs_to :menu
end
その3 ブログ記事とコメント(Post と Comment)
N+1問題が発生するコード
posts = Post.all # 1回のクエリで記事一覧を取得
posts.each do |post|
# 各記事の comments を読むたびに追加のクエリ
comments = post.comments
end
- 記事が50件なら 1 + 50 = 51クエリ
最適化の例
posts = Post.includes(:comments).all # 記事とコメントを一気に取得
posts.each do |post|
# comments はすでにロード済み
comments = post.comments
end
関連定義の例
class Post < ApplicationRecord
has_many :comments, dependent: :destroy
end
class Comment < ApplicationRecord
belongs_to :post
end
その4 Projects, Task, Comment
三階層の関連(Project → Task → Comment)を Rails で書くと、includes(tasks: :comments) のようにネストした形で eager load できます。
N+1問題が発生する例(Rails)
projects = Project.all # プロジェクト一覧を1回のクエリで取得
projects.each do |project|
tasks = project.tasks # プロジェクトごとに追加クエリ
tasks.each do |task|
comments = task.comments # タスクごとにさらに追加クエリ
end
end
- プロジェクト数 + タスク数の分だけクエリが増える構造
最適化の例(Rails / ネストした includes)
# プロジェクト + タスク + コメントをまとめてロード
projects = Project.includes(tasks: :comments).all
projects.each do |project|
puts "Project: #{project.name}"
project.tasks.each do |task|
puts " Task: #{task.title}"
task.comments.each do |comment|
puts " Comment: #{comment.body}"
end
end
end
-
includes(tasks: :comments)で- projects を1回
- tasks を1回
- comments を1回
という感じに、クエリ数をかなり減らせます(RDBや状況によって多少変わることはありますが「爆増」はしない)
関連定義
# app/models/project.rb
class Project < ApplicationRecord
has_many :tasks, dependent: :destroy
end
# app/models/task.rb
class Task < ApplicationRecord
belongs_to :project
has_many :comments, dependent: :destroy
end
# app/models/comment.rb
class Comment < ApplicationRecord
belongs_to :task
end
マイグレーション
# db/migrate/XXXXXXXXXXXXXX_create_projects.rb
class CreateProjects < ActiveRecord::Migration[7.1]
def change
create_table :projects do |t|
t.string :name, null: false
t.timestamps
end
end
end
# db/migrate/XXXXXXXXXXXXXX_create_tasks.rb
class CreateTasks < ActiveRecord::Migration[7.1]
def change
create_table :tasks do |t|
t.references :project, null: false, foreign_key: true
t.string :title, null: false
t.timestamps
end
end
end
# db/migrate/XXXXXXXXXXXXXX_create_comments.rb
class CreateComments < ActiveRecord::Migration[7.1]
def change
create_table :comments do |t|
t.references :task, null: false, foreign_key: true
t.text :body, null: false
t.timestamps
end
end
end
Rails的に押さえておくポイント(初心者向けまとめ)
-
Rails の
includes(:books)
→ 「関連をまとめて事前読み込みする」ための仕組み -
N+1チェックは実務では gem を入れておくことが多いです
- 例:
bulletを入れておくとログやブラウザで警告してくれる
- 例:
-
includes/preload/eager_loadのざっくり違い-
includes… 状況に応じてIN句 orJOINをよしなに選んでくれる -
preload… 2回目以降のクエリでまとめて取得(JOINしない) -
eager_load…LEFT OUTER JOINで一発取得
-
Next.js や TypeScript からRails APIを叩く構成でも、「クライアントから見えるレスポンスは同じでも、裏側で SQL を何回打っているか」がパフォーマンスにかなり効いてきます。
includes / preload を意識して書くと、実務のコードレビューでも評価されやすいです。