【Ruby on Rails】N+1問題の解決方法を解説します

最近、扱うデータ量の多い現場でN+1問題に直面し、レビュー指摘を頂きました。

正直、今までは比較的小規模なプロダクトしか携わった経験がなく、N+1問題についてはあまり意識していなかったため、自分の理解のために改めて「N+1問題」と「Ruby on Railsでの解決方法」を整理しておきます。

対象読者
・N+1問題を知らない、聞いたことあるけど詳しくない人
・Ruby on Rails初心者 ~ 中級者の人
・パフォーマンス改善を行いたい人

N+1問題とは

ループ処理の中で、都度SQLを発行し、結果的に大量のSQLが実行されてパフォーマンスが悪化する問題です。

Ruby on RailsではActiveRecordを利用してモデル操作を行いますが、ActiveRecordが便利すぎるあまりSQLを意識しなくなるので、N+1のコードが普通に実装され、パフォーマンスが悪化してから発覚することが多々あります。

よくある実装

1つの記事(article)に複数のタグ(tag)が紐づくこととします。

app/models/article.rb
class Article < ApplicationRecord
  has_many :tags
end

app/models/tag.rb
class Tag < ApplicationRecord
  belongs_to :article
end

記事一覧画面では、記事の一覧画面で記事に紐づくタグも一緒に表示させます。

記事一覧画面のイメージはこんな感じ

記事タイトルタグ投稿日
【RSpec】テストやるぞ#プログラミング #テスト2023-03-01
【初心者向け】SQL入門#SQL #初心者2023-02-01
はじめてのRuby#プログラミング #初心者2023-01-01

記事一覧画面を取得して画面描画するところまでは、何も考えないと以下のように、Controllerで記事を全て取得し、

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.all
  end
end

Viewでは、記事ごとにループして一覧を作成し、さらに記事に紐づくタグをループして表示するコードとなります。

app/views/articles/index.html.erb
<% @articles.each do |article| %>
  <tr>
    <td><%= article.title %></td>
    <td>
      <% article.tags.each do |tag| %>
        <div><%= tag.name %></div>
      <% end %>
    </td>
    <td><%= article.post_date %></td>
  </tr>
<% end %>

記事のループの中でタグのループを行なっており「記事数×タグ数」分のクエリが実行され、結果的にパフォーマンス悪化につながります。

Ruby on RailsでのN+1解決方法

eager_load または preload を利用する

eager_load

指定したモデルをLEFT OUTER JOINで結合しキャッシュする。

eager_loadで発行されるSQLイメージ
Article.eager_load(:tags)
# SELECT * FROM articles LEFT JOIN tags ON articles.id = tags.article_id;

Article.eager_load(:tags).where(tags: { type: 1 })
# SELECT * FROM articles LEFT JOIN tags ON articles.id = tags.article_id WHERE tags.type = 1;

  • クエリの数が1個で済むので場合によってはpreloadより速い
  • JOINしているので、preloadと違って、JOINしたテーブルで絞込ができる

preload

指定したモデルを複数のクエリに分けてキャッシュする。

eager_loadで発行されるSQLイメージ
Article.preload(:tags)
# SELECT * FROM articles;
# SELECT * FROM tags WHERE tags.article_id IN (1, 2, 3, 4, 5...);

Article.preload(:tags).where(tags: { type: 1 })
# SELECT * FROM articles  WHERE tags.type = 1
# => Mysql2::Error: Unknown column 'tags.id' in 'where clause': SELECT * FROM articles  WHERE tags.type = 1
  • 複数のモデルをキャッシュする場合か、あまりJOINしたくないでかいテーブルを扱うときはpreloadを使うのがよさそう
  • preloadしたテーブルで絞り込もうとすると例外を投げる

まとめ

今回の例だと、こんな感じで予めキャッシュしておくとView側でtagをループした時にクエリは発行されません👍

app/controllers/articles_controller.rb
class ArticlesController < ApplicationController
  def index
    @articles = Article.eager_load(:tags).all
  end
end

少ないデータではほとんどパフォーマンスに影響が出ないのでなかなか意識しませんが、アクセス頻度が高い場合や、扱うデータ量が多い場合などは大きな差が出てきますので、普段から意識してN+1問題を回避するよう心がけましょう。

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です