【Ruby on Rails】N+1問題とincludesでの解決方法

Ruby

こんにちは、かつコーチです。

開発中は問題なく動いていたのに、本番データが増えた途端に画面表示が極端に遅くなる。

Railsを使っていると一度は経験する現象ですが、その原因の多くがN+1問題(関連データを取得する際に、1件ごとに追加のクエリが発行され、データベースへの問い合わせが不必要に増える問題)です。

今回は、記事とコメントの一覧表示を例に、N+1問題が実際に発生する様子をログで確認しながら、includesを使った解決方法を解説します。

N+1問題とは何か

具体例で発生する様子を確認する

Article(記事)とComment(コメント)がhas_many/belongs_toの関係にある前提で考えます。

一覧画面で、各記事のコメント件数を表示するコードを書いてみます。

# app/controllers/articles_controller.rb
def index
  @articles = Article.all
end
<!-- app/views/articles/index.html.erb -->
<% @articles.each do |article| %>
  <p><%= article.title %>(コメント<%= article.comments.count %>件)</p>
<% end %>

このコードを実際にログを見ながら実行すると、以下のようなSQLが発行されているのが確認できました。

Article Load (0.3ms)  SELECT "articles".* FROM "articles"
Comment Count (0.4ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."article_id" = 1
Comment Count (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."article_id" = 2
Comment Count (0.3ms)  SELECT COUNT(*) FROM "comments" WHERE "comments"."article_id" = 3
...

記事一覧を取得するクエリが1回(これが「1」の部分)、そして記事の件数分だけコメント数を数えるクエリが発行されています(これが「N」の部分)。

開発環境では記事が数件しかないため気づきにくいのですが、本番で記事が1,000件になった瞬間に、1,001回のクエリが発行されることになります。

私が実際に担当した案件でも、開発中はサクサク動いていた一覧ページが、本番データを投入した途端にレスポンスが数秒かかるようになり、原因を調べたらこのN+1問題でした。

なぜN+1問題が起きるのか

原因は、Railsの遅延読み込み(Lazy Loading。関連データを実際に呼び出したタイミングで初めて取得する仕組み)にあります。

@articles = Article.allの時点では、コメントに関するデータは一切取得されていません。

ビューのeachループの中でarticle.comments.countが呼ばれるたびに、そのつど新しいクエリがデータベースに投げられているのです。

便利な仕組みである一方、ループの中で関連モデルを参照すると、意図せずクエリが大量発行されてしまう典型的な落とし穴になります。

includesによる解決方法

includesで事前に関連データをまとめて取得する

N+1問題の基本的な解決方法がincludesです。

# app/controllers/articles_controller.rb
def index
  @articles = Article.includes(:comments)
end

includes(:comments)を指定すると、Railsは以下のように、記事とコメントをそれぞれ1回のクエリでまとめて取得します。

Article Load (0.3ms)  SELECT "articles".* FROM "articles"
Comment Load (0.4ms)  SELECT "comments".* FROM "comments" WHERE "comments"."article_id" IN (1, 2, 3, ...)

クエリの発行回数が、記事の件数に関わらず常に2回で済むようになります。

ビュー側では、事前に読み込まれたコメントのデータを使ってcomments.sizeのように書けば、追加のクエリを発行せずに件数を取得できます。

<% @articles.each do |article| %>
  <p><%= article.title %>(コメント<%= article.comments.size %>件)</p>
<% end %>

countはSQLのCOUNTを都度発行するのに対し、sizeはすでにメモリに読み込まれている配列があればその件数を返すため、includesと組み合わせる場合はsizeを使うのがポイントです。

joinsとの違い

includesと似たメソッドにjoinsがありますが、役割が異なります。

# joins:SQLのJOINで絞り込み条件として使う(関連データ自体は取得しない)
Article.joins(:comments).where(comments: { approved: true })

# includes:関連データをまとめて取得し、N+1を防ぐ
Article.includes(:comments)

joinsは「コメントの承認状態で記事を絞り込みたい」といった、検索条件として関連テーブルを使いたい場合に向いています。

一方includesは「関連データそのものをビューで使いたい」場合に向いており、目的によって使い分ける必要があります。

両方の目的を同時に満たしたい場合は、includesreferencesを組み合わせるか、後述のeager_loadを使います。

eager_loadとpreloadの違い

includesは内部的に2つの戦略を自動で使い分けています。

  • preload:関連データを別クエリで取得する方式(今回のログの例)
  • eager_loadLEFT OUTER JOINで1つのクエリにまとめる方式

条件に関連テーブルのカラムを使う場合(whereで関連テーブルの条件を指定する場合など)、Railsは自動的にeager_load方式に切り替えます。

明示的に指定したい場合は、それぞれ以下のように書けます。

Article.preload(:comments)                       # 常に別クエリ方式
Article.eager_load(:comments)                     # 常にLEFT OUTER JOIN方式
Article.includes(:comments).where(comments: { approved: true })  # 自動でeager_loadに切り替わる

基本的にはincludesに任せておけば問題ありませんが、動作を細かく制御したい場面ではこの違いを知っておくと役立ちます。

よくあるつまずきポイント・エラー対処

includesを付けたのにN+1が解消されない

❌ Before:ネストした関連先まではincludesの対象にならない

コメントにさらに「いいね」(Like)が紐づく構成で、いいね数も一覧に表示したい場合を考えます。

@articles = Article.includes(:comments)
<% @articles.each do |article| %>
  <% article.comments.each do |comment| %>
    <p>いいね数: <%= comment.likes.count %></p>
  <% end %>
<% end %>

includes(:comments)はコメントまでしか事前読み込みしていないため、comment.likes.countの部分で改めてN+1問題が発生してしまいました。

✅ After:ネストした関連をハッシュで指定する

@articles = Article.includes(comments: :likes)

このように、関連をネストしたい場合はハッシュで指定します。

さらに深くネストする場合はincludes(comments: { likes: :user })のように書き足していけます。

bulletgemでN+1問題を自動検出する

N+1問題は、目視でコードを読むだけでは見落としやすいのが実情です。

そこで役立つのが、開発中にN+1問題を自動検出してくれるbulletgemです。

# Gemfile
group :development do
  gem "bullet"
end
# config/environments/development.rb
config.after_initialize do
  Bullet.enable = true
  Bullet.alert = true
  Bullet.bullet_logger = true
  Bullet.console = true
end

bulletを導入しておくと、N+1が発生した箇所をブラウザのアラートやログで教えてくれるため、includesの付け忘れにその場で気づけるようになります。

私はこのgemを導入してから、「動かしてみたら遅かった」ではなく「開発中にアラートで気づいて直す」というサイクルに変わり、本番で慌てることが大幅に減りました。

CI環境で自動チェックしたい場合は、Bulletが検出した際に例外を投げるBullet.raise = trueをテスト環境に設定しておく方法もあります。

まとめ

この記事のポイント

  • N+1問題は、一覧表示のループ内で関連モデルを参照した際に、件数分の追加クエリが発行される問題
  • 遅延読み込みの仕組みが原因で、開発環境の少ないデータでは気づきにくい
  • includesを使うと、関連データを事前にまとめて取得しクエリ数を抑えられる
  • joinsは絞り込み条件、includesは関連データの取得と、目的によって使い分ける
  • ネストした関連はincludes(comments: :likes)のようにハッシュで指定する
  • bulletgemを導入すると、N+1問題を開発中に自動検出できる

次に読むべき記事

→ 次の記事:has_secure_passwordで認証機能を実装する

タグ: Ruby on Rails, 上級者向け, データベース

タイトルとURLをコピーしました