1
1

More than 1 year has passed since last update.

[Rails] eager_loadでN+1問題を解消し関連レコードを含んで一覧表示

Posted at

はじめに

ポートフォリオ作成中の学びを記載します。
記事を読んでいただくことで次の2点をお伝えできると思います。

  • eager_loadによるN+1の解決
  • scopeによるクエリ文の可読性向上

やりたいこと

複数の開催日をもったイベント情報一覧を表示したい
image.png

動作環境

Ruby: 3.1.2
Ruby on Rails: 6.1.7

関連の確認

アソシエーションは次の通りです。

実装してみる

泣けなしの知識で早速実装します。動作しますが、問題はController(Modelも)にあります。お分かりでしょうか。

Model


class Event < ApplicationRecord
  has_many :hosted_dates
end

class HostedDate < ApplicationRecord
  belongs_to :event
end

Controller

joinsselectによって関連先レコードを取得できそうだぞ、と鼻息が荒くなっています。
注意:このコードは危険です。絶対に真似しないでください。

class EventController < ApplicationController
  def index
    @events = Event.joins(:hosted_dates).select('hosted_dates.*, events_title, events_discription').where(is_published: true).order(:date_at)
  end
end

View

Controllerで取得した@events内からdate_atを取り出し表示します。

  <% @events.each do |event| %>
    <%= link_to event do %>
      <h1><%= event.title %></h1>
      <p><%= event.discription %></p>
      <ul>
        <% event.hosted_dates.each do |date| %>
          <li><%= l.(date.date_at, format: :long) %></li>
        <% end %>
      </ul>
    <% end %>
  <% end %>

何が問題なのか

上記コードでも動作する分に問題ありませんが次の問題があります。

  • SQLが悲惨(N+1問題)
  • Contollerにロジックを書いており、可読性が悪い

SQLが悲惨(N+1問題)

サーバーを走らせ、一覧を取得したときのSQLログは次の通りです。見事にN+1回のクエリが発生しています。

サーバーログからみるSQL
Processing by EventController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering events/index.html.erb within layouts/application
  Event Load (1.6ms)  SELECT "events".* FROM "events" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC  [["is_published", true]]
  ↳ app/views/events/index.html.erb:4
  HostedDate Load (3.6ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 15], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.5ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 14], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 14], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.3ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 13], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 13], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 12], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 12], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.9ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 11], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 11], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 10], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 10], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 9], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.1ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 8], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  CACHE HostedDate Load (0.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 8], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (1.0ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 7], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.8ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 4], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 3], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 2], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  HostedDate Load (0.7ms)  SELECT "hosted_dates".* FROM "hosted_dates" WHERE "hosted_dates"."event_id" = $1 ORDER BY "hosted_dates"."id" ASC LIMIT $2  [["event_id", 1], ["LIMIT", 1]]
  ↳ app/views/events/index.html.erb:8
  Rendered events/index.html.erb within layouts/application (Duration: 47.6ms | Allocations: 19073)
略
Completed 200 OK in 69ms (Views: 48.0ms | ActiveRecord: 17.9ms | Allocations: 30484)

どうやらView内の次のeach文で繰り返したN回分のクエリが発生しています。今回はレコード件数が少ないこともあり処理時間は69msで済んでいますが、レコードが増えてアクセス数も増えるとサーバーはダウンしてしまうでしょう。

<% event.hosted_dates.each do |date| %>
  <li><%= l(date.date_at, format: :long) %></li>
<% end %>

N+1問題の解決策としてeager_loadする

ActiveRecordが提供しているメソッドとして、 preload, eager_load, includesがあります。それぞれ微妙に挙動が違いますが、アソシエーションを結合してテーブルを作成しActiveRecordのキャッシュメモリに情報を保持することにより、クエリ発行数を1, 2回に収めて高速通信を実現するしくみです。

最初のjoinsselectも結合しアソシエーションで条件を絞ることができますが、関連先レコードを保持することができません。

今回はアソシエーションで条件を絞りたかったのでeager_loadを選択しました。次のように使用することで関連先のレコードを保持したままインスタンスを作成することができます。これより先ほどのViews内のeach文においても追加でSQLが発生することはありません。

@events = Event.eager_load(:hosted_dates).where(is_published: true).order(date_at: :desc)

Contollerにロジックを書いており、可読性も悪い

eager_loadにより一件落着。と言いたいところですが、まだ残っている問題としてはControllerにロジックを持たせているところです。
ControllerはユーザーとMode, Viewを繋ぐ指令等のため、できるだけ薄くシンプルにし、今回のようなロジックはModelに持たせるべきと言えます。

また、今回の例ではwhereorderなどのクエリメソッドは読みやすい方ではありますが、条件が複雑になってくると可読性に乏しくなることでしょう。

scopeによりクエリ条件のエイリアスを作る

Model内にscopeメソッドを定義し、第一引数にシンボルを使ったエイリアス名、第二引数にアロー記法を使ってクエリ文を渡します。

class Event < ApplicationRecord
  scope :published, -> { where(is_published: true) }
  scope :sorted, -> { order(updated_at: :desc) }
End

次のようなコマンドでクエリを出すことができます。とても直感的でわかりやすいですよね。

# Event.where(is_published: true)と等価
Event.published

# Event.order(updated_at: :desc)と等価
Event.sorted

また定義したscopeを再利用することも可能です。


scope :recent, -> { pulished.sorted }

# Event.where(is_published: true).order(updated_at: :desc)と等価
Event.recent

注意点としてscopeによる検索結果が存在しない場合あっても、必ずActiveRecord::Relationを返します。クラスメソッドでも同じような実装が可能ですが存在しない場合はnilが返されます。要件によって使い分ける必要がありそうですね。

改善後

修正したコードは次の通りになります。(Viewに変化はありません)
Modelにロジックを持たせたことで、Controllerの見通しも良く直感的になりました。

Model

class Event < ApplicationRecord
  has_many :hosted_dates

  scope :with_dates, -> { eager_load(:hosted_dates) }
  scope :published, -> { where(is_published: true) }
  scope :sorted, -> { order(date_at: :desc) }
  scope :with_recent_dates, -> { with_dates.published.sorted }
end

Controller

class EventController < ApplicationController
  def index
    @events = Event.with_recent_dates
  end
end

SQL削減結果

SQL発行数も1件となりスッキリ解決です。処理時間も69msから39msと43%削減することができました!

Processing by EventController#index as HTML
  Rendering layout layouts/application.html.erb
  Rendering events/index.html.erb within layouts/application
  SQL (2.4ms)  SELECT "events"."id" AS t0_r0, "events"."owner_id" AS t0_r1, "events"."name" AS t0_r2, "events"."place" AS t0_r3, "events"."title" AS t0_r4, "events"."discription" AS t0_r5, "events"."thumbnail_url" AS t0_r6, "events"."price" AS t0_r7, "events"."required_time" AS t0_r8, "events"."is_published" AS t0_r9, "events"."capacitiy" AS t0_r10, "events"."created_at" AS t0_r11, "events"."updated_at" AS t0_r12, "hosted_dates"."id" AS t1_r0, "hosted_dates"."event_id" AS t1_r1, "hosted_dates"."start_at" AS t1_r2, "hosted_dates"."end_at" AS t1_r3, "hosted_dates"."created_at" AS t1_r4, "hosted_dates"."updated_at" AS t1_r5 FROM "events" LEFT OUTER JOIN "hosted_dates" ON "hosted_dates"."event_id" = "events"."id" WHERE "events"."is_published" = $1 ORDER BY "events"."updated_at" DESC  [["is_published", true]]
  ↳ app/views/events/index.html.erb:4
  Rendered events/index.html.erb within layouts/application (Duration: 14.8ms | Allocations: 4094)
略
Completed 200 OK in 39ms (Views: 32.1ms | ActiveRecord: 3.8ms | Allocations: 15336)

最後に

ポートフォリオは実際にユーザーに使用いただく予定のためパフォーマンスは重要です。
また、改善を続けていけるように可読性・保守性も考慮する必要があります。
引き続きこれらを心がけて実装を進めていきます。

参考

Railsガイド Active Record クエリインターフェイス
ActiveRecordのincludesは使わずにpreloadとeager_loadを使い分ける理由
[Rails]モデルのscopeメソッド

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