この記事は PORT Advent Calendar 2017 の24日目です。
はじめに
弊社ではサーバーフレームワークとしてRuby on Railsを採用しています。Railsアプリケーションの開発で避けて通れないのが一般に「N+1問題」と呼ばれるパフォーマンスに関わる現象。この記事では今年遭遇したN+1問題と回避方法を表彰したいと思います。賞の名前は適当です。
なお、解決方法は 思いっきり自己流 なので、もっと簡潔な方法がある、既に公式またはOSSが解決済み、などの情報があれば共有したいと思っています。
前置き: N+1問題とは
具体的説明は外部の記事に譲ります。
N+1問題 / Eager Loading とは - Rails Webook
さて、「N+1問題」でググると大抵は eager_load
や includes
を使いましょう、という記事がヒットしますが、実際のところこれらで解決できない事例もあります。
敢闘賞: 関連テーブルから更に検索する
先ほどの記事 で取り上げられた事例だと配列の要素に対して関連テーブルが一意に取得できますが、場合によっては他の情報も検索条件として考慮する必要があります。
背景
タスク管理が要素として入っているアプリで、こういうモデルに対して
# タスク情報
class Task < ApplicationRecord
belongs_to :user # 別途 User モデルを定義
has_many :checks
validates :title, presence: true
end
# タスクと日付に対して実行状況(ユーザーの自己申告)
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
であれば「-」
問題発生
以下の通り実装すると要件通りの表示になりますが
/ @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
にこういうクラスメソッドを定義しました。
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が得られます。
{
100 => { # Task#id
'2017-12-01' => '○',
'2017-12-02' => '×',
},
101 => {
'2017-12-01' => '○',
}
}
これをコントローラ内で @checked_table
として保存すると、ビュー内で以下の通り実装することで上記実装の find_by
を置き換えることができます。
/ 前略... @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クエリとなります。そこでこういうヘルパークラスを定義しました。
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の発行も削減できます。
これを使って先ほどのリストを作成するためには、
@counter = Counter.new(@articles)
ul
- @categories.each do |category|
li
= link_to category_path(category)
= category.name
span = "(#{@counter.count(category)})"
とします。
まとめ
ふりかえったところ、大体 .to_a
でArrayもしくはなんらかのメソッドでHashに変換していました。ただし find_by
や where
がやりづらいので改善の余地はあるかと思います。この辺りは今後の課題にしたいと思います。