最近、扱うデータ量の多い現場で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)が紐づくこととします。
class Article < ApplicationRecord
has_many :tags
end
class Tag < ApplicationRecord
belongs_to :article
end
記事一覧画面では、記事の一覧画面で記事に紐づくタグも一緒に表示させます。
記事一覧画面のイメージはこんな感じ
記事タイトル | タグ | 投稿日 |
---|---|---|
【RSpec】テストやるぞ | #プログラミング #テスト | 2023-03-01 |
【初心者向け】SQL入門 | #SQL #初心者 | 2023-02-01 |
はじめてのRuby | #プログラミング #初心者 | 2023-01-01 |
記事一覧画面を取得して画面描画するところまでは、何も考えないと以下のように、Controllerで記事を全て取得し、
class ArticlesController < ApplicationController
def index
@articles = Article.all
end
end
Viewでは、記事ごとにループして一覧を作成し、さらに記事に紐づくタグをループして表示するコードとなります。
<% @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で結合しキャッシュする。
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
指定したモデルを複数のクエリに分けてキャッシュする。
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をループした時にクエリは発行されません👍
class ArticlesController < ApplicationController
def index
@articles = Article.eager_load(:tags).all
end
end
少ないデータではほとんどパフォーマンスに影響が出ないのでなかなか意識しませんが、アクセス頻度が高い場合や、扱うデータ量が多い場合などは大きな差が出てきますので、普段から意識してN+1問題を回避するよう心がけましょう。