Tanaka Soft

Railsの取得戦略を、N+1・JOIN・アプリメモリの負荷から考える

RailsのN+1・JOIN・アプリメモリ負荷を分け、preload・eager_load・includes・joinsなどの選び方を整理します。

遅さはN+1だけではない

画面やAPIが遅いとき、最初から「N+1を消す」と決めるのは危険です。まず、どこで負荷が発生しているのかを分けて確認します。

Rails/ActiveRecordの取得設計では、次の三つを分けて考えます。

  • SQLの本数・往復が増えるN+1
  • JOINによってDB側の処理が重くなる問題
  • 大量取得によってRails側のオブジェクト生成・アプリメモリが重くなる問題

この記事では、Rails/ActiveRecord側の取得方法を扱います。インデックス、テーブル定義、DBサーバー設定、レプリカ、シャーディング、DBエンジン固有の実行計画改善など、RDBMS側の最適化は対象にしません。SQLログやEXPLAINは、ActiveRecordが発行したSQLを確認するために使います。

1. 遅いときは、まず5つの値を見る

画面・APIが遅い
  └─ DB処理、SQL回数、Rails側メモリ、オブジェクト生成のどこかに負荷がある

最初に、次の値を確認します。

観測値何が分かるか
SQL回数N+1、不要な往復
SQL時間DB・JOINの重さ
返却行数・カラム数JOINによる行増加、DBが処理・返却する結果の大きさ
ActiveRecord生成数・アプリメモリ大量取得・先読みの負荷
全体の処理時間実際のレイテンシー

DBからRailsへの結果の転送量が増えることもあります。ただし、ここで主に見るのは通信そのものではありません。JOINで結果を作るDB処理、Railsでオブジェクトを生成・保持する処理、SQLの往復回数を観測します。

2. 負荷をN+1・JOIN・Railsメモリに分ける

2.1 N+1:SQLの本数・往復が増えている

親のレコードを取得した後、ループの中で関連を一つずつ参照すると、親の取得に加えて関連取得が繰り返されます。

job_applications = JobApplication
  .where(status: "active")
  .limit(100)

job_applications.each do |job_application|
  puts job_application.job.title
end

このコードでは、job_applicationsの取得が1回発生し、job_application.jobの取得が最大で件数分発生します。これが典型的なN+1です。

N+1の問題は、1回のSQLが必ず重いことではありません。親の件数に応じてSQLの本数とDBとの往復が増えることが問題です。

2.2 JOIN:SQLをまとめた結果、DB側の処理が重くなる

N+1を避けるために関連をJOINで一度に取得すると、SQLの本数は減らせます。しかし、一対多の関連をJOINすると、親の情報が関連の件数分だけ繰り返され、結果行が増えることがあります。

特に次の条件が重なると、DB側の処理が大きくなりやすくなります。

  • 一対多の関連をJOINする
  • 複数の関連を同時にJOINする
  • JOIN前に親の対象範囲を絞っていない
  • 不要なJOINが残っている
  • JOIN後のソートや条件判定が重い

SQLが1本になったかどうかだけでは、改善を判断できません。SQLの本数が減っても、その1本でDBが処理する結合や結果行が大きくなっていないかを確認する必要があります。

2.3 大量取得:Rails側のオブジェクト生成・アプリメモリが重くなる

preloadなどで親と関連をまとめて取得すると、N+1やJOINによる行増加を避けられる場合があります。一方で、取得した親と関連はRails側でActiveRecordオブジェクトとして保持されます。

  • 親の件数が多い
  • 一つの親に紐づく関連件数が多い
  • 不要なカラムまでActiveRecordオブジェクトにする
  • Rubyのselectやmapで全件を配列化する

この場合は、DBのJOINを避けた代わりに、Rails側のオブジェクト数、配列、GC、メモリ使用量が増える可能性があります。

3. 負荷に合わせて取得方法を選ぶ

3.1 preload:JOINせずに関連を先読みする

job_applications = JobApplication
  .where(status: "active")
  .preload(:job)

preloadは、親と関連を別のSQLで取得し、ループ中の関連参照で追加SQLが発生しないようにします。JOINによる行の増幅を避けたい場合の候補です。

ただし、親と関連の両方をRailsのメモリに載せます。関連のカーディナリティが大きい場合は、N+1を抑えた代わりにアプリメモリの使用量が増えます。

3.2 eager_load・joins:関連条件をDB側で適用する

関連テーブルの条件をDB側で適用したい場合は、eager_loadやjoinsを検討します。

job_applications = JobApplication
  .eager_load(:job)
  .where(jobs: { published: true })

eager_loadは関連オブジェクトの先読みまで行います。一方、joinsは主に関連テーブルを条件や絞り込みに使うもので、関連オブジェクトを先読みしません。

job_applications = JobApplication
  .joins(:job)
  .where(jobs: { published: true })

joinsの後でjob_application.jobを参照するなら、別途SQLが発生する可能性があります。JOINによる絞り込みと、関連オブジェクトの先読みは分けて考えます。

3.3 includes:便利だが、SQL形状を確認する

includesは一般的な先読みの入口になりますが、条件の書き方によってpreload相当になったり、JOIN相当になったりします。

JobApplication.includes(:job)

「includesを書いたからJOINは起きない」「includesを書いたから必ず安全」とは判断できません。実際に発行されたSQLを確認し、JOINによるDB処理や結果行の増加が許容できるかを見ます。

3.4 先にwhereで対象を絞る

JobApplication
  .where(status: "active")
  .where(created_at: period)
  .preload(:job)

対象期間や状態を先に絞ってから関連を取得すると、DBが処理する親集合とRailsが保持する集合を小さくできます。N+1、JOIN、アプリメモリの複数の問題に有効な場合があります。

ただし、親を絞っても一つの親に大量の関連があれば、preloadのメモリ負荷は残ります。条件の置き場所と実際の件数を確認します。

3.5 select・pluck:不要なカラムとオブジェクト生成を減らす

JobApplication
  .where(status: "active")
  .pluck(:id, :job_id)

必要なカラムだけを取得し、ActiveRecordオブジェクト自体が不要ならpluckを使います。不要なカラムの取得とオブジェクト生成を抑えられます。

ただし、selectやpluckはSQLの本数やJOINそのものを自動的に減らしません。後続処理で必要な属性を取得対象から外すと、追加の取得や実装上の問題につながります。

3.6 ページング:一度に扱う量を制限する

一覧画面で一度に取得する親の件数を制限し、関連の先読み範囲もページ単位にします。1回のリクエストでRailsが保持するオブジェクト数を抑える方法です。

総リクエスト数や画面操作は増える可能性があるため、メモリ、レイテンシー、UXを合わせて確認します。

3.7 フロントエンドで分けて取得する

初期表示に必要な情報と、後から表示できる重い情報を別リクエストに分ける方法もあります。

初期レスポンスの待ち時間を短くし、1リクエストに集中する負荷を分けられます。重い関連情報を必要なタイミングで取得することもできます。

ただし、総SQL量や総リクエスト数が減るとは限りません。DB最適化の代わりではなく、初期表示の負荷を分散する補助策として扱います。

4. 方法ごとに効く負荷を比較する

表中の◎は主な解決対象、○は条件次第で有効、△は副作用や限界を伴う補助的な効果を表します。

解決方法N+1JOINによるDB負荷大量取得・Railsメモリ備考
preload◎◎△JOINを避けられるが、関連を別クエリで取得してRailsメモリへ載せる
eager_load◎△△N+1を解決する一方、JOINによるDB処理・行増加が発生し得る
includes◎○△条件によってpreloadまたはJOIN相当になり得る
joins△○○絞り込み用。関連オブジェクトの先読みではない
where・期間・状態で先に絞る○◎◎DBが処理する対象集合とRailsが保持する集合を小さくする
select・pluck△△◎SQL本数ではなく、1回あたりのカラム・オブジェクト量を減らす
ページング○○◎1回あたりの行数を制限する。総リクエスト数は増え得る
フロントエンド分離取得○○○1リクエストの負荷を分ける補助策。DBの総処理量削減とは限らない

5. 二つの負荷軸を分ける

取得戦略を比べるときは、「SQLを何本発行するか」と「1本のSQLでDBがどれだけ処理するか」を分けて考えます。

軸典型的な問題主な解決方法主な観測値
SQLの本数・往復回数ループ内で関連SQLが繰り返されるN+1preload、eager_load、includes、必要に応じた集約SQL回数、往復時間、総SQL時間
1回あたりのDB処理・結果量JOINで行が増える、不要な行・カラムをDBが処理・返却する先行where、select、pluck、ページング、preloadDB時間、行数、カラム数、Railsのオブジェクト数・メモリ

N+1を解決してSQL本数を減らしても、eager_loadのJOINでDB処理や結果行が大きくなる場合があります。逆にselectやpluckでカラムやオブジェクトの量を減らしても、SQL本数そのものは減りません。

6. 解決後に発生し得る落とし穴

取得方法を変えると、負荷の場所が移ることがあります。

解決したい問題解決方法改善するもの新たに起こり得る問題
N+1preloadSQL本数、関連取得の往復関連データを別クエリで大量取得し、Railsメモリが増える
N+1eager_loadSQL本数、関連オブジェクトの先読みJOINによるDB処理、行の増加、結果の重複
N+1・関連条件での絞り込みincludes条件に応じた先読み条件によってJOINへ切り替わり、SQL形状が変わる
JOINによるDB負荷preloadへ分割JOIN処理、行の増幅SQL本数とRails側のメモリ負荷が増える可能性
大量取得・Railsメモリselect・pluckオブジェクト生成、保持量必要な属性が後続処理から失われる
大量取得・Railsメモリ先行where・ページング1回あたりの行数とRailsメモリ条件の分散、ページングによる追加リクエスト
初期リクエストの負荷フロントエンド分離取得初期レスポンス、1リクエストの取得量総リクエスト数・総SQL量・状態管理が増える可能性

7. 取得戦略はデータ量と用途で決める

flowchart TD
    A[目的を定義する<br/>表示・件数・更新・存在判定] --> B[データ量を確認する<br/>親件数・関連件数・カーディナリティ]
    B --> C{関連条件をDBで<br/>適用する必要があるか}
    C -->|はい| D[joins / eager_load / サブクエリ / 集計SQL]
    C -->|いいえ| E{関連オブジェクトを<br/>Rubyで使うか}
    E -->|はい| F[preload / 明示的な関連取得]
    E -->|いいえ| G[pluck / select / exists / count]
    D --> H{取得量・行増加・メモリを<br/>許容できるか}
    F --> H
    G --> H
    H -->|大きい| I[期間・ページング・集計・専用クエリ]
    H -->|許容| J[SQLログ・EXPLAIN・時間・メモリで検証]
    I --> J
    J --> K[必要なら初期表示と重い情報をAjax分離]

このフローは、N+1を消すことだけを目的にしません。目的、データ量、関連条件をDBで扱う必要性、Rails側で関連オブジェクトを使う必要性を確認し、どの負荷を減らすかを決めます。

まとめ

N+1を抑えるなら、preloadはJOINを避けながら関連を先読みできます。ただし、関連データをRailsのメモリに載せます。eager_loadは関連条件をDBで扱いやすい一方、JOINによるDB処理や行の増加に注意が必要です。

先行where、select、pluck、ページングは、DBとRailsが一度に扱う対象を小さくします。フロントエンドで分けて取得する方法は、初期表示の負荷を分散する補助策であり、DB最適化そのものではありません。

取得戦略の正解は、SQLを最少にすることではありません。画面要件とデータ量に合わせて、DB処理、SQLの往復、Railsメモリのどこを抑えるかを決め、実測で確かめます。

参考資料