準備
まずは、モデルを作成します。
1 2 |
rails g model user name:string rails g model post name:string |
マイグレーションファイルをそれぞれ作成します。
user
1 2 3 4 5 6 7 8 9 |
class CreateUsers < ActiveRecord::Migration[6.0] def change create_table :users do |t| t.string :name t.timestamps end end end |
post
1 2 3 4 5 6 7 8 9 10 |
class CreatePosts < ActiveRecord::Migration[6.0] def change create_table :posts do |t| t.string :name t.references :user t.timestamps end end end |
modelファイルもそれぞれ設定します。
user
1 2 3 |
class User < ApplicationRecord has_many :posts end |
post
1 2 3 |
class Post < ApplicationRecord belongs_to :user end |
データ投入
「user:post = 1:多」になるようにデータを作成します。(rails cでRailsコンソール上で実行すると良いでしょう。)、ただ、userは1ですが、N+1問題を再現するために複数人作成します。
user(1)
1 2 3 |
user1 = User.create!(name:"太郎") user2 = User.create!(name:"花子") user3 = User.create!(name:"次郎") |
post(多)
1 2 3 4 5 6 7 8 |
user1.posts.create!(name:"投稿1") user1.posts.create!(name:"投稿2") user2.posts.create!(name:"投稿1") user2.posts.create!(name:"投稿2") user2.posts.create!(name:"投稿3") user3.posts.create!(name:"投稿1") user3.posts.create!(name:"投稿2") user3.posts.create!(name:"投稿3") |
コントローラの作成
1 |
rails g controller users index |
コントローラ側
1 2 3 4 5 |
class UsersController < ApplicationController def index @users = User.all end end |
ビュー側
1 2 3 4 5 6 7 |
<% @users.each do |user| %> <%= user.name %> | <% user.posts.each do |post| %> <%= post.name %> <% end %> <br> <% end %> |
url
1 |
http://localhost:3000/users |
上記URLにアクセスすると下記のような感じでselect文が大量に発行されるN+1問題が発生します。
bulletの導入
bulletは開発環境でしか基本使わないのでdevelopmentグループ配下に設定します。
1 2 3 |
group :development do gem 'bullet' end |
bundleインストールします。
1 |
bundle install |
以下の場所の設定ファイルにbulletで検知できるように設定します。
1 |
config/environments/development.rb |
設定内容
1 2 3 4 5 6 7 |
config.after_initialize do Bullet.enable = true Bullet.alert = true Bullet.bullet_logger = true Bullet.console = true Bullet.rails_logger = true end |
Railsサーバーを再起動します。
1 |
rails s |
また、上記URLにアクセスすると下記のポップアップ表示がなされています。
ポップアップの指示通りコントローラのクエリに「includes(:posts)」を追加します。
1 |
@users = User.all.includes(:posts) |
再びページにアクセスすると今度はSQLが下記のようになりポップアップが出なくなっています。
1 2 3 |
User Load (0.3ms) SELECT "users".* FROM "users" ↳ app/views/users/index.html.erb:1 Post Load (0.2ms) SELECT "posts".* FROM "posts" WHERE "posts"."user_id" IN (?, ?, ?) [["user_id", 1], ["user_id", 2], ["user_id", 3]] |
余分な場合もある。
余分にincludes等のN+1対策をしてしまっている場合は逆に下記のようなメッセージが出ます。
1 2 |
AVOID eager loading detected Remove to your query: .includes([:モデル名]) |
その場合はincludes等を外しましょう。
N+1問題とは?
Railsでループ処理の都度SQLを発行してしまいパフォーマンスが低下する問題のことです。
具体的にはどんな動作か?
1:多のリレーションのテーブル同士で1に紐づいている多の情報を取得しようとした際に、最初に一括で1の方のテーブルのデータをSQLで取得した後に、他の多の方の関連データを1件1件ずつ合計N件になるまでSQLを発行し続けます。数件程度であれば問題ないのですがデータ量が増えると多大なパフォーマンスの損失に繋がりかねません。
具体例
具体的にはjoinsを利用していて結合したテーブルをループして関連先のテーブルのデータを参照しようとした場合に発生します。
1 2 3 4 5 6 7 8 9 10 |
projects = Project.joins(:category_projects) Project Load (7.5ms) SELECT "projects".* FROM "projects" INNER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" LIMIT ? [["LIMIT", 11]] => #<ActiveRecord::Relation [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">]> irb(main):011:0> projects.each do |project| p project.category_projects end CategoryProject Load (0.8ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" = ? LIMIT ? [["project_id", 1], ["LIMIT", 11]] #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 1, category_id: 1, project_id: 1, created_at: "2020-12-19 09:56:20", updated_at: "2020-12-19 09:56:20">]> CategoryProject Load (0.5ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" = ? LIMIT ? [["project_id", 2], ["LIMIT", 11]] #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 2, category_id: 1, project_id: 2, created_at: "2020-12-19 09:56:28", updated_at: "2020-12-19 09:56:28">]> => [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">] |
上記例では「Project:Category_project」=「1:多」になっている場合で関連先のテーブルを呼ぶたびにSELECT文が呼ばれていることがわかります。これはjoinsの特性としてメモリ上に関連先テーブル情報(CategoryProject)までキャッシュしないためです。
N+1問題への対策
下記のActiveRecordのメソッドを使うことにより比較的パフォーマンスを負荷を軽減できます。どちらも処理の前にassociationをキャッシュすることによりN回のSQL実行を1回にまとめることができます。
- preloadメソッド
- eager_loadメソッド
preloadメソッド
IN句を使ってSQLを一つにまとめます。あまりJOINしたくないでかいテーブル同士の結合の際はこちらを使うと良いでしょう。
1 2 3 4 5 6 7 8 9 10 11 |
irb(main):019:0> projects = Project.preload(:category_projects) Project Load (5.7ms) SELECT "projects".* FROM "projects" LIMIT ? [["LIMIT", 11]] CategoryProject Load (0.6ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" IN (?, ?) [["project_id", 1], ["project_id", 2]] => #<ActiveRecord::Relation [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">]> irb(main):020:0> projects.each do |project| p project.category_projects end Project Load (0.8ms) SELECT "projects".* FROM "projects" CategoryProject Load (0.4ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" IN (?, ?) [["project_id", 1], ["project_id", 2]] #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 1, category_id: 1, project_id: 1, created_at: "2020-12-19 09:56:20", updated_at: "2020-12-19 09:56:20">]> #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 2, category_id: 1, project_id: 2, created_at: "2020-12-19 09:56:28", updated_at: "2020-12-19 09:56:28">]> => [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">] |
eager_loadの使い分けとしては、「1:多」でデータ量が多いテーブルの結合の際に使用すると良いでしょう。
whereが使えない。
相手先のテーブルの絞り込みをしようとして、whereを使うとエラーになります。
1 2 3 4 |
irb(main):030:0> projects = Project.preload(:category_projects).where(category_projects: { project_id:1} ) Project Load (3.6ms) SELECT "projects".* FROM "projects" WHERE "category_projects"."project_id" = ? LIMIT ? [["project_id", 1], ["LIMIT", 11]] Traceback (most recent call last): ActiveRecord::StatementInvalid (SQLite3::SQLException: no such column: category_projects.project_id) |
includesメソッド
1 2 3 4 5 6 7 8 9 10 11 |
irb(main):023:0> projects = Project.includes(:category_projects) Project Load (1.6ms) SELECT "projects".* FROM "projects" LIMIT ? [["LIMIT", 11]] CategoryProject Load (2.4ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" IN (?, ?) [["project_id", 1], ["project_id", 2]] => #<ActiveRecord::Relation [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">]> irb(main):024:0> projects.each do |project| p project.category_projects end Project Load (0.4ms) SELECT "projects".* FROM "projects" CategoryProject Load (4.4ms) SELECT "category_projects".* FROM "category_projects" WHERE "category_projects"."project_id" IN (?, ?) [["project_id", 1], ["project_id", 2]] #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 1, category_id: 1, project_id: 1, created_at: "2020-12-19 09:56:20", updated_at: "2020-12-19 09:56:20">]> #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 2, category_id: 1, project_id: 2, created_at: "2020-12-19 09:56:28", updated_at: "2020-12-19 09:56:28">]> => [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">] |
eager_loadメソッド
LEFT OUTER JOINを使ってSQLを一つにまとめます。
1 2 3 4 5 6 7 8 9 10 |
irb(main):015:0> projects = Project.eager_load(:category_projects) SQL (0.5ms) SELECT DISTINCT "projects"."id" FROM "projects" LEFT OUTER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" LIMIT ? [["LIMIT", 11]] SQL (0.7ms) SELECT "projects"."id" AS t0_r0, "projects"."title" AS t0_r1, "projects"."created_at" AS t0_r2, "projects"."updated_at" AS t0_r3, "category_projects"."id" AS t1_r0, "category_projects"."category_id" AS t1_r1, "category_projects"."project_id" AS t1_r2, "category_projects"."created_at" AS t1_r3, "category_projects"."updated_at" AS t1_r4 FROM "projects" LEFT OUTER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" WHERE "projects"."id" IN (?, ?) [["id", 1], ["id", 2]] ↑関連先が「多」になっていると上のSQLでDISTINCTが発生してしまいコストが増える。 irb(main):016:0> projects.each do |project| p project.category_projects end SQL (0.8ms) SELECT "projects"."id" AS t0_r0, "projects"."title" AS t0_r1, "projects"."created_at" AS t0_r2, "projects"."updated_at" AS t0_r3, "category_projects"."id" AS t1_r0, "category_projects"."category_id" AS t1_r1, "category_projects"."project_id" AS t1_r2, "category_projects"."created_at" AS t1_r3, "category_projects"."updated_at" AS t1_r4 FROM "projects" LEFT OUTER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 1, category_id: 1, project_id: 1, created_at: "2020-12-19 09:56:20", updated_at: "2020-12-19 09:56:20">]> #<ActiveRecord::Associations::CollectionProxy [#<CategoryProject id: 2, category_id: 1, project_id: 2, created_at: "2020-12-19 09:56:28", updated_at: "2020-12-19 09:56:28">]> => [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">, #<Project id: 2, title: "project2", created_at: "2020-12-19 09:54:40", updated_at: "2020-12-19 09:54:40">] |
joinsと同じように記述していますが、ループを回す際のSQLの実行が1回で済んでいるのでSQL発行回数は最小に抑えることができます。ただ、相手が関連先テーブルがNの場合は最初に結合する際に、DISTINCTが発生するのでデータ量が少なく、「1:1」の関連の場合のみ使用すると良いでしょう。
whereも使える。
eager_loadなら相手先のテーブルで絞り込んだ結果を返せます。
1 2 3 4 |
irb(main):029:0> projects = Project.eager_load(:category_projects).where(category_projects: { project_id:1} ) SQL (1.7ms) SELECT DISTINCT "projects"."id" FROM "projects" LEFT OUTER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" WHERE "category_projects"."project_id" = ? LIMIT ? [["project_id", 1], ["LIMIT", 11]] SQL (174.9ms) SELECT "projects"."id" AS t0_r0, "projects"."title" AS t0_r1, "projects"."created_at" AS t0_r2, "projects"."updated_at" AS t0_r3, "category_projects"."id" AS t1_r0, "category_projects"."category_id" AS t1_r1, "category_projects"."project_id" AS t1_r2, "category_projects"."created_at" AS t1_r3, "category_projects"."updated_at" AS t1_r4 FROM "projects" LEFT OUTER JOIN "category_projects" ON "category_projects"."project_id" = "projects"."id" WHERE "category_projects"."project_id" = ? AND "projects"."id" = ? [["project_id", 1], ["id", 1]] => #<ActiveRecord::Relation [#<Project id: 1, title: "project1", created_at: "2020-12-19 09:54:11", updated_at: "2020-12-19 09:54:11">]> |
includesメソッド
例えば、Userテーブルに1:多のリレーションがあるpcテーブルがある場合に下記のように記述する場合
1 |
User.pc |
上記のように記述するとUserごとにslect文を発行して1つずつpcを取得する動作になってしまいます。
1 |
User.includes(:pc) |
includesを使えばIN句を使ってユーザーに紐づくpcを一括して取得することができます。
この記事へのコメントはありません。