0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

本記事は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 句 or JOIN をよしなに選んでくれる
    • preload … 2回目以降のクエリでまとめて取得(JOINしない)
    • eager_loadLEFT OUTER JOIN で一発取得

Next.js や TypeScript からRails APIを叩く構成でも、「クライアントから見えるレスポンスは同じでも、裏側で SQL を何回打っているか」がパフォーマンスにかなり効いてきます。
includes / preload を意識して書くと、実務のコードレビューでも評価されやすいです。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?