(2025/6/4追記: 記事の主旨がより正確に伝わるタイトルに変更しました。ご意見をくださった皆様に感謝いたします。)
こんにちは、SmartHRのプロダクトエンジニアの@masaruです。
Ruby on Railsでの関連データ取得にはpreload
、eager_load
、includes
、という3つのメソッドがよく用いられます。
似たような機能に思えるこれらのメソッドですが、とりわけ関連を多数持つモデルに対して使用する場合には注意すべきポイントがあります。実際に私がSmartHRで遭遇した事例の紹介とともに、これらの違いについて見ていってみましょう。
preload, eager_load と includes の違い
Active Recordには関連するレコードを効率的に読み込むための様々なメソッドが用意されています。主要な3つのメソッドについて解説します。
preloadの動作
preload
は関連データをそれぞれ別のクエリで取得します。
# ユーザーとその投稿を取得する場合 users = User.preload(:posts).to_a
このコードは以下の2つのSQLクエリを発行します。
SELECT * FROM users; SELECT * FROM posts WHERE user_id IN (1, 2, 3, ...);
eager_loadの動作
eager_load
は常にLEFT OUTER JOIN
を使用して関連データを取得します。
# ユーザーとその投稿を取得する場合 users = User.eager_load(:posts)
このコードは以下のような1つのSQLクエリを発行します。
SELECT users.*, posts.* FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id;
includesの動作
includes
は状況に応じてクエリの発行方法が変わります。
- 単純に関連データを取得する場合は
preload
と同様に別クエリを発行 - 関連データに対して条件を指定する場合は
eager_load
と同様にLEFT OUTER JOIN
を使用したクエリを発行
# 基本的な使用方法 users = User.includes(:posts) # 関連データに条件を指定する場合 users = User.includes(:posts).where(posts: { published: true })
1つ目の例ではpreload
と同じく2つの別クエリを発行しますが、2つ目の例ではeager_load
と同様に、以下のようなJOINを使ったクエリになります。
SELECT users.*, posts.* FROM users LEFT OUTER JOIN posts ON posts.user_id = users.id WHERE posts.published = true;
パフォーマンスの違い
実際のアプリケーションでは、これらの違いが大きなパフォーマンスの差を生み出すことがあります。
SmartHRで発生した問題
SmartHRのあるAPIエンドポイントで、多数の関連データを含むエントリー情報を取得する処理がありました。具体的には以下のようなコードです。
@similar_entries = @entry.entries_by_similar_talents.includes( { applicant: :talent }, { applicant: { talent: :talent_similarity_suggestions_as_talent1 } }, { applicant: { talent: :talent_similarity_suggestions_as_talent2 } }, :job_post, :entry_channel, :entry_channel_detail )
このコードは採用管理システムにおいて、特定のエントリー(応募)に類似した人材の応募情報を取得するものです。データの関連性を図で表すと以下のようになります。
entry ├── applicant │ └── talent │ ├── talent_similarity_suggestions_as_talent1 │ └── talent_similarity_suggestions_as_talent2 ├── job_post ├── entry_channel └── entry_channel_detail
entries_by_similar_talents
は以下のように関連モデルであるapplicant
の持つtalent_id
を条件に使っています。
joins(applicant: :talent) .where(applicants: { talent_id: <必要なIDの一覧を取得する処理(今回のテーマと関係がないため割愛)> }) .where.not(id: entry.id)
ただし、talent_similarity_suggestions_as_talent
やjob_post
, entry_channel
, entry_channel_detail
はただの関連モデルで、where句をはじめとしたクエリの条件には関係しません。なのでeager_load
する必要があるのはapplicant
のみなのですが、このコードは一度に全ての関連モデルをJOINして取得するクエリを発行してしまいます。
そのような巨大なクエリを用いると、特に大量のデータを扱う場合に非常に遅くなります。今回の例では取得対象のtalent_id
が100件程度あるだけで結果を返すのに20秒程度かかり、たったの500件程度でテスト環境のサーバーは結果を返すことができませんでした。
巨大なデータセットを取得することは、メモリ効率の点でも悪手となります。
以下のようなコードを実行してメモリの消費量の計測をしてみました。
module MemoryHelper def self.measure GC.start memory_before = GetProcessMem.new.mb gc_stat_before = GC.stat result = yield # クエリ結果を関連データを含めて読み込む if result.is_a?(ActiveRecord::Relation) result.to_a.each do |record| next unless record.respond_to?(:association_cache) record.association_cache.each_value do |assoc| assoc.target.to_a if assoc.target.respond_to?(:to_a) end end end gc_stat_after = GC.stat memory_after = GetProcessMem.new.mb puts "メモリ使用量: #{(memory_after - memory_before).round(2)} MB" puts "割り当てられたオブジェクト数: #{gc_stat_after[:total_allocated_objects] - gc_stat_before[:total_allocated_objects]}" result end end # ... MemoryHelper.measure do @similar_entries = @entry.entries_by_similar_talents.includes( { applicant: :talent }, { applicant: { talent: :talent_similarity_suggestions_as_talent1 } }, { applicant: { talent: :talent_similarity_suggestions_as_talent2 } }, :job_post, :entry_channel, :entry_channel_detail ) end
サンプルデータで計測してみると、100件程度のtalent_id
から検索する場合、割り当てられたメモリ/オブジェクトの数は以下となりました。
メモリ使用量: 264.3 MB 割り当てられたオブジェクト数: 15263695
一つのリクエストでこれだけのメモリを消費するのはかなり厳しいでしょう。
includesの内部動作と問題点
これらの処理はincludes
が各関連モデルを取得するのに、preload
を使うか、eager_load
を使うか、一律で決めてしまうというところに問題があります。
includes
を使用した場合に、ActiveRecord は以下のロジックで発行するクエリを決定します。
- 関連テーブルへの参照(whereやjoinなど)があるかどうかを判定する
- 関連テーブルへの参照がある場合、すべての
includes
に指定したモデルに対してJOINクエリが生成される
ですので、eager_load
の必要がない関連モデルを雑にincludes
に含ませるのは非常にパフォーマンスの観点で無駄が多い選択となります。
改善策:preloadへの変更
コードを以下のように変更しました。
@similar_entries = @entry.entries_by_similar_talents.preload( { applicant: :talent }, { applicant: { talent: :talent_similarity_suggestions_as_talent1 } }, { applicant: { talent: :talent_similarity_suggestions_as_talent2 } }, :job_post, :entry_channel, :entry_channel_detail )
この変更は非常にシンプルですが、効果は絶大です。対象が100件の場合のレスポンスタイムは1s程度で、500件程度のデータ量でクラッシュすることもありません。
割り当てられるメモリの量も、includes
の使用時と比べて、1/100程度で済みます。
メモリ使用量: 1.23 MB 割り当てられたオブジェクト数: 568515
join先の関連モデルをwhere句に使いたいからincludes
を使っていたのでは?という指摘がありそうなのですが、preload
であってもその点は問題ないです。
preload
が関連クエリを取得するために、別のクエリを生成・実行するのは「メインのクエリ」を実行した後の結果に限ります。すなわちentry
がapplicants
をjoinして、applicants
のフィールドを使ったwhere句までが一度実行され、別々のクエリになるのはそれ以降にpreload
に指定されたモデルだけが対象になります。
実際に発行されたクエリを抜粋して紹介します。
SELECT "entries".* FROM "entries" INNER JOIN "applicants" ON "applicants"."id" = "entries"."applicant_id" INNER JOIN "talents" ON "talents"."id" = "applicants"."talent_id" WHERE "applicants"."talent_id" IN ( ... ) SELECT "applicants".* FROM "applicants" WHERE "applicants"."id" IN (... SELECT "talents".* FROM "talents" WHERE "talents"."id" IN (... SELECT "talent_similarity_suggestions".* FROM "talent_similarity_suggestions" WHERE "talent_similarity_suggestions"."talent1_id" IN (... SELECT "talent_similarity_suggestions".* FROM "talent_similarity_suggestions" WHERE "talent_similarity_suggestions"."talent2_id" IN (... SELECT "job_posts".* FROM "job_posts" WHERE "job_posts"."id" IN (... SELECT "entry_channels".* FROM "entry_channels" WHERE "entry_channels"."id" IN (... SELECT "entry_channel_details".* FROM "entry_channel_details" WHERE "entry_channel_details"."id" IN (...
余談ですが、サンプルのコードはincludes
を使っていること以外にも無駄な部分があり、実際リリースされたコードは以下のようになりました。
このコードですと、eager_load
が発生した場合でもJOINの実行回数が減るので、取得されるデータセットの大きさは削減されています。
@similar_entries = @entry.entries_by_similar_talents.preload( { applicant: { talent: %i[talent_similarity_suggestions_as_talent1 talent_similarity_suggestions_as_talent2] } }, :job_post, :entry_channel, :entry_channel_detail )
まとめ
Ruby on Railsにおけるpreload
、includes
、eager_load
は、一見似たような機能に見えますが、その内部動作とパフォーマンス特性には大きな違いがあります。
特に大規模なデータセットや多数の関連を持つデータを扱う場合は、preload
を使用してJOINを避けることで、レスポンス時間を劇的に短縮できる可能性があります。
これらのメソッドの特性を理解し、適切に使い分けることが重要です。パフォーマンスを意識したアプリケーション開発においては、発行されるSQLクエリを意識して、どのメソッドを使うべきかを判断することをお勧めします。
We Are Hiring!
SmartHR では一緒にプロダクトの課題を解決していってくれるメンバーを探しています!
少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!