0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

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アプリケーションを開発する者として、頭に入れておくべき知識なのかなと思いました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?