N+1問題オブザイヤー

この記事は [PORT Advent Calendar 2017](https://qiita.com/advent-calendar/2017/port) の24日目です。


# はじめに

弊社ではサーバーフレームワークとしてRuby on Railsを採用しています。Railsアプリケーションの開発で避けて通れないのが一般に「N+1問題」と呼ばれるパフォーマンスに関わる現象。この記事では今年遭遇したN+1問題と回避方法を表彰したいと思います。賞の名前は適当です。

なお、解決方法は **思いっきり自己流** なので、もっと簡潔な方法がある、既に公式またはOSSが解決済み、などの情報があれば共有したいと思っています。

## 前置き: N+1問題とは

具体的説明は外部の記事に譲ります。

[N+1問題 / Eager Loading とは - Rails Webook](http://ruby-rails.hatenadiary.com/entry/20141108/1415418367)

さて、「N+1問題」でググると大抵は `eager_load` や `includes` を使いましょう、という記事がヒットしますが、実際のところこれらで解決できない事例もあります。

# 敢闘賞: 関連テーブルから更に検索する

[先ほどの記事](http://ruby-rails.hatenadiary.com/entry/20141108/1415418367) で取り上げられた事例だと配列の要素に対して関連テーブルが一意に取得できますが、場合によっては他の情報も検索条件として考慮する必要があります。

## 背景

タスク管理が要素として入っているアプリで、こういうモデルに対して

```app/models/task.rb
# タスク情報
class Task < ApplicationRecord
  belongs_to :user # 別途 User モデルを定義
  has_many :checks
  validates :title, presence: true
end
```

```app/models/check.rb
# タスクと日付に対して実行状況(ユーザーの自己申告)
class Check < ApplicationRecord
  belongs_to :task
  validates :todo_at, presence: true # 対象日付 (Date型)
  validates :checked, inclusion: { in: [true, false] } # 実行したかどうか
end
```

実行状況 (`Check#checked`) を表として表示せよ、という要件がありました。

| タイトル | 12/1 | 12/2 | 12/3 | 12/4 | 12/5 | 12/6 | 12/7 |
| ------ | ---- | ---- | ---- | ---- | ---- | ---- | ---- |
| スクワット | ○ | ×   | ○    | ○    | ×    | -    | -    |
| ランジ    | - | -   | ○    | ○    | ×    | ○    | ○    |

- `check.checked == true` であれば「○」
- `check.checked == false` であれば「×」
- `check == nil` であれば「-」

## 問題発生

以下の通り実装すると要件通りの表示になりますが

```show.html.slim
/ @dates は別途取得
table
  tr
    td タイトル
    - @dates.each do |date|
      td = date
  - @tasks.each do |task|
    tr
      td = task.title
      - @dates.each do |date|
        td
          - check = task.checks.find_by(todo_at: date) # SQL発行!!
          - if check.nil?
            = '-'
          - else
            = check.checked ? '○' : '×'
```

ここで使っている `find_by` メソッドは必ずSQLを発行します。つまり上記の例だとSQLが `N(@dates) * N(@tasks) + 1` 回発行され、パフォーマンスに大きく影響します _(今更ながら「N+1問題」と呼べるのか不安ですが、この先も便宜上「N+1問題」と呼びます)_ 。

## `ActiveRecord::Relation` から離れる

これを回避するため `Check` にこういうクラスメソッドを定義しました。

```app/models/check.rb
  class << self
    def to_table
      table = {}
      current_scope.pluck(:task_id, :todo_at, :checked).each do |task_id, todo_at, checked|
        table[task_id] ||= {}
        table[task_id][todo_at.to_s] = checked ? '○' : '×'
      end
      table
    end
  end
```

イメージとしては、実行するとこのようなHashが得られます。

```ruby
{
  100 => { # Task#id
    '2017-12-01' => '○',
    '2017-12-02' => '×',
  },
  101 => {
    '2017-12-01' => '○',
  }
}
```

これをコントローラ内で `@checked_table` として保存すると、ビュー内で以下の通り実装することで上記実装の `find_by` を置き換えることができます。

```show.html.slim
/ 前略... @dates, @tasks の定義は変わらず
td = @checked_table.dig(task.id, date.to_s) || '-'
```

もちろん `@checked_table` はただのHashなのでSQLは発行されません。力技ですがN+1問題を回避できました。

# 技巧賞: カウントと併用

次の例はメディアサイトを実装した時から引用します。カテゴリ (`Category`) 別の記事 (`Article`) の数をリンク集に表示する要件がありました。

>
- 筋トレ (31)
- ストレッチ (39)
- 有酸素運動 (61)
- ヨガ (44)

_(モデル構成については詳しく触れません… `Article belongs to Category` とだけ)_

さて、ここで各 `Category` 毎に `category.articles.count` としてしまうとやはりN+1クエリとなります。そこでこういうヘルパークラスを定義しました。

```app/helplers/counter.rb
class Counter
  def initialize(table)
    @count_table = table
                     .joins(:articles)
                     .group(:id)
                     .select(:id, 'count(*) as count')
                     .to_a
  end

  def count(record)
    @count_table
      .find { |group| group.id == record.id }
      &.count || 0
  end
end
```

SQLでいう `SELECT COUNT(*) ... GROUP BY` により `count` SQLの発行が1回で済みます。これに加えてコンストラクタの `.to_a` により `ActiveRecord::Relation` 型からRuby標準のArray型に変換されます。Railsが提供している `find_by` を始め便利なメソッドは使えなくなりますが、その代わり余計なSQLの発行も削減できます。

これを使って先ほどのリストを作成するためには、

```categories_controller.rb
@counter = Counter.new(@articles)
```

```index.html.slim
ul
  - @categories.each do |category|
    li
      = link_to category_path(category)
        = category.name
        span = "(#{@counter.count(category)})"
```

とします。

# まとめ

ふりかえったところ、大体 `.to_a` でArrayもしくはなんらかのメソッドでHashに変換していました。ただし `find_by` や `where` がやりづらいので改善の余地はあるかと思います。この辺りは今後の課題にしたいと思います。