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を使うため、WHEREやORDERで関連テーブルのカラムを扱えるという利点があります。 - しかし
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