🔰

研修中にN+1問題についてまとめたこと

2024/12/25に公開

はじめに

2024年6月にラグザイアに入社しました、Tamaと申します。
ここでは、新人研修で詰まったN+1問題について自分なりに分析したものを紹介します。
これが新人エンジニアの一助になれば幸いです。

自己紹介

  • 社会人5年目、業務未経験でRubyエンジニアとしてラグザイアに入社
  • 前職はサービス業
  • 神奈川県在住、フルリモートで働いています

調べた経緯(なぜN+1問題を調べることになったのか)

ラグザイアでは、研修として自分でRuby on Ralisアプリの要件定義から実装までを行うカリキュラムがあります。
私はこの研修で、GitHubのように活動履歴を可視化するアプリを開発しました。活動履歴を見える化することで、達成感を得やすくし、モチベーションを維持できると考えたからです。
アプリのイメージはこちらです。

ER図はこんな感じです。

本題

日報(Report)を可視化するためのCalenderクラスを作成し、目標一覧でカレンダーを表示する機能を実装している時にN+1問題でつまづきました。

修正前のコード

class GoalsController < ApplicationController
  def index
    @goals = current_user.goals.includes(:reports).order(:created_at).page(params[:page]).per(PAGINATE_PER)
    @calendars = @goals.map do |goal|
      Calendar.new(goal, 14.days).generate_line
    end
  end
end
class Calendar
  def initialize(goal)
    @goal = goal
    @target_reports = build_target_reports
  end

  def build_target_reports
    @goal.reports.where(target_date: @range_start_date..@range_last_date)
                 .select(:target_date, :progress_value)
                 .index_by(&:target_date)
  end
end

指摘の内容

bullet gem(N+1を検知して警告してくれるジェム)に、@goals配列を取得するときのincludes(:reports)を外すように警告されたので、疑問に思いながらも該当の記述を削除しました。
しかしその後ログを見ていると想定以上にクエリが発生しているのです。よく確認すると、current_userの目標を取得するクエリだけでなく、目標一覧に表示される目標の件数だけクエリが追加で発生しているようです。
研修担当に相談したところ、以下のアドバイスをいただきました。

whereではなくeachで回すようにすれば、includes(:reports)が使えるようになって実質的なN+1がなくなるかも

調べてまとめた内容

調べたところ、絞り込みに使用していたwhereはクエリメソッドというものに分類されるようで、これを使うたびにクエリを発行してしまうようです。
参考: https://railsguides.jp/active_record_querying.html

つまり、修正前のコードは

  1. indexアクションの中でcurrent_userが作成したgoalを取得してgoalsに格納
  2. indexアクションの中で繰り返しCalenderクラスのインスタンスを生成しており、その度にgoalに紐づいているReportを取得する(whereを使用しているせいで、新しくクエリを発行している)
  3. goalsに収まっているgoalの件数だけ追加でクエリが発生

このような流れになっているようです。

修正後のコード

class Calendar
  def build_target_reports
    reports_hash = {}
    @goal.reports.each do |report|
      if (@range_start_date..@range_last_date).include?(report.target_date)
        reports_hash[report.target_date] = { progress_value: report.progress_value }
      end
    end

    reports_hash
  end
end
  1. indexアクション内で、current_userが作成したgoalとその子のデータであるReportをgoals配列に収める
  2. build_target_reportsメソッドの中でgoals配列の中に含まれたReportの情報をeachを使用してハッシュに収める(追加でReport配列のデータを取得する必要がない)
  3. current_userの作成したgoalを取得するクエリとそのgoalに紐づいているReportのデータを取得するクエリの2回で終了する

これでクエリの発生件数を抑えることができました。

まとめ

whereを使った絞り込みはクエリを発生させてしまうということを認識していなかったのが原因でした。
N+1を防ぐには、繰り返し処理の中でクエリが複数回発行されていないかを意識して確認すること、includesやwhereなどのメソッドが発生させる挙動やクエリについて理解することが大切なのだと感じました。
新人エンジニアは、Railsガイドやリファレンスマニュアル等信頼できるドキュメントを確認する癖をつけていきましょう。

ラグザイア

Discussion