概要
- N+1問題とは
- N+1問題の解決方法
- 便利なgem bullet(N+1を検出してくれる)の導入
- 最後に
初学者のただの備忘録です。
環境
- Ruby: 2.6.6
- Rails: 6.0.3.5
N+1問題とは
N+1問題とは必要以上にSQL文が発行されてパフォーマンスが低下する問題です。
実装例
例えば、パラメータのcategory_nameがDBに存在するかどうかによって
@ideasに代入される値が変わる処理があるとします。
def index
category = Category.find_by(name: params[:category_name])
if category.present?
@ideas = category.ideas
render formats: :json
else
@ideas = Idea.all
render formats: :json
end
end
categoryとideaの関係は
has_many :ideas, dependent: :destroy
belongs_to :category
N+1問題が発生してない場合
category.present?がtrueの場合は@ideasにcategory.ideasが代入されて
レンダリングされる。
そのときのログを一応確認(わかりやすいように一部ログをはしょっています)
CategoryとIdeaが1回ずつ読み込まれてます。N+1問題は発生していません。
Category Load (0.5ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`name` = 'hoge1' LIMIT 1
↳ app/controllers/api/v1/ideas_controller.rb:13:in `index'
Idea Load (0.4ms) SELECT `ideas`.* FROM `ideas` WHERE `ideas`.`category_id` = 1
↳ app/views/api/v1/ideas/index.json.jbuilder:2
N+1問題が発生している場合
category.present?がfalseの場合、@ideasにIdea.allが代入されてレンダリングされる。
ログを確認するとCategory Loadが何回も...
これがN+1問題です。
Idea Load (0.5ms) SELECT `ideas`.* FROM `ideas`
↳ app/views/api/v1/ideas/index.json.jbuilder:2
Category Load (0.3ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.3ms)
↳ app/models/idea.rb:4:in category_name
CACHE Category Load (0.0ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.3ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.4ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.3ms)
↳ app/models/idea.rb:4:in category_name
CACHE Category Load (0.0ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.4ms)
↳ app/models/idea.rb:4:in category_name
CACHE Category Load (0.0ms)
↳ app/models/idea.rb:4:in category_name
Category Load (0.4ms)
↳ app/models/idea.rb:4:in category_name
N+1問題を解決する方法
結論から言いますとincludes、preload、eager_loadのいずれかを使用すると解決できます。
詳しく知りたい方は
下記の記事を読んでみてください。
ActiveRecordのjoinsとpreloadとincludesとeager_loadの違い
では、解決していきます。
eager_load (LEFT OUTER JOIN)の場合
# def index
# category = Category.find_by(name: params[:category_name])
# if category.present?
# @ideas = category.ideas
# render formats: :json
# else
@ideas = Idea.eager_load(:category) # もとはIdea.all
# render formats: :json
# end
# end
Category Load (0.6ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`name` = 'hoge1ddd' LIMIT 1
↳ app/controllers/api/v1/ideas_controller.rb:13:in `index'
Rendering api/v1/ideas/index.json.jbuilder
SQL (0.5ms) SELECT `ideas`.`id` AS t0_r0, `ideas`.`category_id` AS t0_r1, `ideas`.`body` AS t0_r2, `ideas`.`created_at` AS t0_r3, `ideas`.`updated_at` AS t0_r4, `categories`.`id` AS t1_r0, `categories`.`name` AS t1_r1, `categories`.`created_at` AS t1_r2, `categories`.`updated_at` AS t1_r3 FROM `ideas` LEFT OUTER JOIN `categories` ON `categories`.`id` = `ideas`.`category_id`
↳ app/views/api/v1/ideas/index.json.jbuilder:2
何回もCategoryがLoadされていたSQL文が発行されなくなりました。
preloadの場合
# def index
# category = Category.find_by(name: params[:category_name])
# if category.present?
# @ideas = category.ideas
# render formats: :json
# else
@ideas = Idea.preload(:category) # もとはIdea.all
# render formats: :json
# end
# end
Category Load (0.4ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`name` = 'hoge1ddd' LIMIT 1
↳ app/controllers/api/v1/ideas_controller.rb:13:in `index'
Rendering api/v1/ideas/index.json.jbuilder
Idea Load (0.5ms) SELECT `ideas`.* FROM `ideas`
↳ app/views/api/v1/ideas/index.json.jbuilder:2
Category Load (0.4ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (1, 2, 3, 4, 5, 6, 7)
↳ app/views/api/v1/ideas/index.json.jbuilder:2
includesの場合
# def index
# category = Category.find_by(name: params[:category_name])
# if category.present?
# @ideas = category.ideas
# render formats: :json
# else
@ideas = Idea.includes(:category) # もとはIdea.all
# render formats: :json
# end
# end
Category Load (0.3ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`name` = 'hoge1ddd' LIMIT 1
↳ app/controllers/api/v1/ideas_controller.rb:13:in `index'
Rendering api/v1/ideas/index.json.jbuilder
Idea Load (0.4ms) SELECT `ideas`.* FROM `ideas`
↳ app/views/api/v1/ideas/index.json.jbuilder:2
Category Load (0.2ms) SELECT `categories`.* FROM `categories` WHERE `categories`.`id` IN (1, 2, 3, 4, 5, 6, 7)
↳ app/views/api/v1/ideas/index.json.jbuilder:2
おまけ
Ideaモデルに複数の関連付けがある場合は下記のように書くことができます。
# def index
# category = Category.find_by(name: params[:category_name])
# if category.present?
# @ideas = category.ideas
# render formats: :json
# else
@ideas = Idea.eager_load(:category, :hoge, :huga)
# render formats: :json
# end
# end
gem "bullet"の導入方法
N+1問題を検出してくれるgemです。
通常はターミナルに出ますが、jquery等でカスタムもできます。
今回それは割愛
gem "bullet"
bundleだけでも可能
$ bundle install
yでconfig/environments/development.rbを自動作成
$ bundle exec rails g bullet:install
Would you like to enable bullet in test environment? (y/n)
N+1問題が発生するとターミナルに下記のようにでます。(一部省略)
IdeasテーブルにCategoriesテーブルをincludesしてねということ
user: shu
GET /api/v1/ideas?category_name=
USE eager loading detected
Idea => [:category]
Add to your query: .includes([:category])
以上。
間違っている等ありましたらご指摘頂けると幸いです。
最後に
N+1問題の解決方法自体はそんなに難しいコードを書くわけではないですがSQLを読んで
なにが起こっているのかしっかり理解することが大切だと思います。
bulletも便利ですけど自身でSQLを見て判断できるようになった方が良いと思いました。
(ただ解決できたらいいやーでは逆に遠回りになることを身を持って感じております。)