LoginSignup
9
7

More than 5 years have passed since last update.

N+1問題オブザイヤー

Last updated at Posted at 2017-12-23

この記事は 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_loadincludes を使いましょう、という記事がヒットしますが、実際のところこれらで解決できない事例もあります。

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

先ほどの記事 で取り上げられた事例だと配列の要素に対して関連テーブルが一意に取得できますが、場合によっては他の情報も検索条件として考慮する必要があります。

背景

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

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が得られます。

{
  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_bywhere がやりづらいので改善の余地はあるかと思います。この辺りは今後の課題にしたいと思います。

9
7
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
9
7