SmartHR Tech Blog

SmartHR 開発者ブログ

多数の関連を持つRailsモデルにおけるJOIN問題 —— includes(eager_load)の注意点とpreloadを使った改善策

(2025/6/4追記: 記事の主旨がより正確に伝わるタイトルに変更しました。ご意見をくださった皆様に感謝いたします。)

こんにちは、SmartHRのプロダクトエンジニアの@masaruです。

Ruby on Railsでの関連データ取得にはpreloadeager_loadincludes、という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_talentjob_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 は以下のロジックで発行するクエリを決定します。

  1. 関連テーブルへの参照(whereやjoinなど)があるかどうかを判定する
  2. 関連テーブルへの参照がある場合、すべての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が関連クエリを取得するために、別のクエリを生成・実行するのは「メインのクエリ」を実行した後の結果に限ります。すなわちentryapplicantsを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におけるpreloadincludeseager_loadは、一見似たような機能に見えますが、その内部動作とパフォーマンス特性には大きな違いがあります。

特に大規模なデータセットや多数の関連を持つデータを扱う場合は、preloadを使用してJOINを避けることで、レスポンス時間を劇的に短縮できる可能性があります。

これらのメソッドの特性を理解し、適切に使い分けることが重要です。パフォーマンスを意識したアプリケーション開発においては、発行されるSQLクエリを意識して、どのメソッドを使うべきかを判断することをお勧めします。

We Are Hiring!

SmartHR では一緒にプロダクトの課題を解決していってくれるメンバーを探しています!

少しでも興味を持っていただけたら、カジュアル面談でざっくばらんにお話ししましょう!

hello-world.smarthr.co.jp