2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

N+1問題の発見方法と解決方法(備忘録)

Last updated at Posted at 2021-02-19

概要

  • 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に代入される値が変わる処理があるとします。

app/controllers/ideas_controller.rb
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の関係は

app/models/category.rb
has_many :ideas, dependent: :destroy
app/models/idea.rb
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)の場合

app/controllers/ideas_controller.rb

# 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の場合

app/controllers/ideas_controller.rb

# 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の場合

app/controllers/ideas_controller.rb

# 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モデルに複数の関連付けがある場合は下記のように書くことができます。

app/controllers/ideas_controller.rb

# 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等でカスタムもできます。
今回それは割愛

Gemfile

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を見て判断できるようになった方が良いと思いました。
(ただ解決できたらいいやーでは逆に遠回りになることを身を持って感じております。)

2
4
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
2
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?