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

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

```ruby: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が１回ずつ読み込まれてます。N+1問題は発生していません。

```ruby:ターミナル
  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問題です。

```ruby:ターミナル

  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の違い](https://qiita.com/k0kubun/items/80c5a5494f53bb88dc58)


では、解決していきます。

### eager_load (LEFT OUTER JOIN)の場合

```ruby: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
```

```ruby:ターミナル

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

```ruby: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
```

```ruby:ターミナル

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

```ruby: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
```
```ruby:ターミナル

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

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