🔀

Railsアプリケーションにおいて、N+1問題を解消する手法

に公開

RailsアプリケーションでのN+1問題の解消手法

Railsアプリケーションにおいて、N+1問題を解消する手法はいくつか存在しており、ケースによって使い分けが必要です。

本記事では、ひとつひとつの手法をどういったケースで使い分ければいいか紹介します。

N+1 問題とは

N+1問題は、関連するレコードを繰り返し取得する際に、同じようなSQLが何度も実行されてしまうことで、パフォーマンスが低下する問題です。

たとえば、下記のようなコードで投稿記事を取得するとします。

`posts = Post.all # 投稿記事の一覧取得

posts.each { |post| puts post.user.name } # 投稿記事ごとに、記事作成ユーザの氏名を出力する`

上記では、投稿記事を一覧取得したあとに、各投稿に紐づくユーザの氏名を出力しています。

一見問題なさそうですが、裏側では以下のようにSQLクエリが複数回発行されます。

`SELECT * FROM posts

SELECT * FROM users WHERE id = 1
SELECT * FROM users WHERE id = 2
SELECT * FROM users WHERE id = 3
-- postsに紐づくuserのレコード数だけ、SELECTが続く...`

このようにpostsを取得したのち、紐づくusersのレコード数だけSELECTクエリが発生してしまいます。

たとえばユーザが5名であれば、postsの取得1回+usersの取得5回で合計6回のSQLクエリが発行されることになります。

クエリの多重発行は、データベース負荷を高めてしまい、パフォーマンスに悪い影響(※)を与えてしまいます。

※ DBとの通信回数・クエリ発行回数が増えることによるI/Oコストの増大、レスポンス速度低下など。

解決方法

N+1問題の代表的な解決策はEager Loading(事前読み込み)です。

Eager Loadingは、データベースから関連するデータをまとめて一度に取得する仕組みです。Eager Loadingを使うと、1度のクエリで必要な情報をすべて取得でき、無駄なDBアクセスを減らすことができます。

RailsにおけるEager loadingの実装手法

Railsでは、関連モデルを事前に読み込むために3つのメソッドが用意されています。

includes

  • 最もよく使われるメソッド。Railsが自動的に最適な読み込み方法を選択してくれる。
    • 取得結果の中で関連テーブルのカラムを参照しない場合→preloadを呼び出す(別クエリで取得)
    • 関連テーブルのカラムをWHEREやORDERで参照する場合→eager_loadを呼び出す(JOINで取得)

つまりincludesは、preloadとeager_loadを自動で切り替えるスマートなインターフェースとなっているわけです。

preload

  • 関連テーブルを別のクエリでまとめて取得する方式です。
  • Railsはまずメインテーブルを取得し、その後で関連レコードをまとめて1クエリで取得します。
  • JOINしないため、条件指定を関連テーブルにまたいで行えないという制約がありますが、単純な読み込みには高速です。
    • 取得した関連先の属性ごとにテーブルをグルーピングし、Ruby側で「どのメインテーブルに、関連テーブルを紐づけるか」を組み立てています。
    • これはDBのJOINではなく、アプリケーションレベルで紐づけを再構築しています。
    • preloadは「関連データを別クエリで効率的にまとめて読む」ことを目的としているため、結合結果をDB側で構築する(JOIN)する必要はありません。

eager_load

  • SQLの LEFT OUTER JOIN を使って、1回のクエリで関連データをまとめて取得する方式です。
  • LEFT OUTER JOIN を使うため、 WHEREORDER で関連テーブルのカラムを扱えるという利点があります。
  • しかしJOINの結果データが膨大になる場合はメモリ負荷が高くなることがあります。

結果データが膨大になるケースとは?

例えばUsersテーブルと、Postsテーブルがあるとします。Usersには10,000件のレコードがあり、各Userが平均10件Postsを投稿しているとします。

この場合、JOINの結果は 10,000 * 10 = 100,000行になります。

user_id post_id
1 1
1 2
1 3
2 4
2 5
3 6

Railsはこの10万行を一旦すべてRubyオブジェクトとして読み込み、内部で「UserごとにPostsをグルーピング」し直すため、メモリ消費とパースコストが急増します。

LEFT OUTER JOIN はpostsを持たないユーザーも結果に含まれますが、データ量を大きくする主因でなく、データ量を大きくする最も大きな理由は、上記のような「投稿をたくさん持つユーザー側」です。

このようなケースで、eager_loadは取得行数・転送量・メモリ負荷が大きくなりやすいです。

eager_loadを扱うときの、上記の問題に対する対策としては

  • 参照側で条件をつける( joins + where.not(posts: { id: nil } )など
  • もしくはJOINを避けてpreloadで分割取得(親子テーブルを別々のクエリで取得)する

があります。

Railsのincludesによる自動判定の仕組み

上記のようにRailsのincludesは、内部的にpreloadとeager_loadのどちらを使うかを自動で切り替えます。

判定ルールは以下のようになっています。

条件 発行される内部メソッド 動作
関連テーブルのカラムをWHERE句やORDER BY句で参照していない preload 親テーブルと子テーブルを別クエリで取得(JOINなし)
関連テーブルのカラムをWHERE句やORDER BY句で参照している eager_load LEFT OUTER JOIN によって1回のクエリで取得
referencesを明示的に指定した場合 eager_load 強制的にJOINされる
# preloadとして動作(JOINなし)
User.includes(:posts).all

# eager_loadとして動作(JOINあり)
User.includes(:posts).where(posts: { published: true })

# JOINを明示したい場合
User.includes(:posts).references(:posts).where("posts.title LIKE ?", "%Rails%")

このようにincludesはシンプルに書ける一方で、Railsが内部でどちらを選ぶかを理解しておくと、パフォーマンスチューニングの際に迷いがなくなります。

まとめ

N+1問題はRailsで頻出するパフォーマンス課題ですが、includes/preload/eager_loadを正しく使い分けることで、クエリ発行数を最小化し、アプリケーション全体のレスポンスを改善できます。Railsがどのように関連を読み込むのかを理解するのは、Railsアプリケーションを開発する者として、頭に入れておくべき知識なのかなと思いました。

Discussion