YASD-TECH
YASD TECH
# Rails

preloadとindex_by(N+1回避とHash化)

投稿日:2026/7/27

更新日:2026/7/27

ttitleImage

概要

「関連データを効率よく取ってくる」ための2つの道具。

メソッド やること
preload 関連を先にまとめて取っておく(N+1回避)
index_by 配列を {キー => 要素} の Hash に変換する

preload で取得回数を減らし、index_by で引きやすい形にする。この2つはセットで使われることが多い。

以下、ユーザー3人と注文3件のテーブルで見ていく。

ユーザー 注文
あおい コーヒー、ケーキ
ひかる 紅茶
つばさ (なし)

N+1 問題

「全ユーザーと、その注文内容を一覧表示したい」。素直に書くとこうなる。

ruby
User.all.each do |user|
  puts user.name
  puts user.orders.map(&:item)   # ← ここ
end

動くが、飛ぶSQLを見ると問題がわかる。

sql
SELECT * FROM users
SELECT * FROM orders WHERE user_id = 1  -- あおい
SELECT * FROM orders WHERE user_id = 2  -- ひかる
SELECT * FROM orders WHERE user_id = 3  -- つばさ

ユーザーを取る1本 + 1人ごとに1本。名前のとおり「1 + N本」飛ぶので N+1問題と呼ばれる。

3人なら4本で済むが、ユーザーが1,000人なら 1,001本になる。

原因は user.orders が「必要になった瞬間にDBへ問い合わせる」遅延読み込みだから。ループの中で書くと、回るたびに問い合わせが発生する。


preload — まとめて先読みする

解決策は「ループに入る前に、必要な注文を全部まとめて取っておく」。

ruby
User.preload(:orders).each do |user|
  puts user.name
  puts user.orders.map(&:item)   # ← SQLは飛ばない
end
sql
SELECT * FROM users
SELECT * FROM orders WHERE user_id IN (1, 2, 3)

4本が2本になった。ポイントは IN (1, 2, 3) の部分で、1人ずつ聞くのではなく必要なIDをまとめて1回で聞いている

ユーザー3人 ユーザー1,000人
preload なし 4本 1,001本
preload あり 2本 2本

クエリ本数は「対象件数」ではなく「関連の数」で決まる。対象が何件に増えても本数は変わらない。ここが N+1 との決定的な差。


joins では代用できない

「JOIN すれば1本で済むのでは?」と思うところだが、joins しても関連データはメモリに載らない

ruby
user = User.joins(:orders).first
user.orders   # ← ここで改めてSQLが飛ぶ

理由は生成されるSQLの SELECT 句にある。

sql
SELECT users.*        -- usersの列しか取っていない
FROM users
INNER JOIN orders ON orders.user_id = users.id

JOIN はしているのに取ってきているのは users の列だけ。JOIN が「絞り込みの条件」としてしか使われていない

メソッド 発行されるSQL 用途
joins INNER JOIN(1本) 絞り込み・集計
preload 関連ごとに別クエリ N+1回避

使い分けはこう考える。

  • 「注文がある人だけ欲しい」→ joins
  • 「注文の中身を表示したい」→ preload
  • 両方必要なら両方書くjoins(:orders).preload(:orders)

ネストした preload

「注文 → 商品 → メーカー」のように関連の先の関連まで辿るときの書き方は2種類だけ。

書き方 意味
:関連名 その関連を1階層だけ
{ 親: 子 } その先まで辿る
ruby
User.preload(:orders)                        # 注文まで
User.preload(orders: :product)               # 商品まで
User.preload(orders: { product: :maker })    # メーカーまで

ネストの形はメソッドチェーンと1対1で対応している。

ruby
user.orders .product .maker
#     ↓        ↓       ↓
{ orders: { product: :maker } }

途中はハッシュ、最後だけシンボル。これがルール。複数の関連を同時に指定するときはカンマで並べる。

ruby
User.preload(:profile, orders: :product)

実例:多段ネストの preload

通知メッセージを組み立てるジョブなど、実務では多段のネストが並ぶ。

ruby
Reservation.where(id: ids).preload(
  :customer,
  { staff: { team: { manager: :notification_setting } } },
  { line_items: { product: :maker } },
)

3つの引数がそれぞれ、通知メッセージの部品に対応している。

preload の指定 使いみち
:customer 顧客名
staff → team → manager → notification_setting 上長へのメンション
line_items → product → maker 商品名とメーカー名

2つ目の4段ネストは、コード側のこのチェーンと対応している。

ruby
reservation.staff.team.manager.notification_setting.destination_id

これで飛ぶSQLは関連の数だけ、つまり 9本

sql
Reservation         WHERE id IN (...)
Customer            WHERE id IN (...)
Staff               WHERE id IN (...)
Team                WHERE id IN (...)
Manager             WHERE id IN (...)
NotificationSetting WHERE manager_id IN (...)
LineItem            WHERE reservation_id IN (...)
Product             WHERE id IN (...)
Maker               WHERE id IN (...)

preload がなければ「対象件数 × 関連8」本まで膨らむ。

確認したいのは2点。すべて IN (...) になっていることと、JOIN が1つも出てこないことpreload の性質がそのまま現れている。


index_by — 配列を Hash にする

preload で取ったレコードは、そのままだと配列。あとで ID から引きたい場合は index_by で Hash にしておく。

ruby
reservations = Reservation.where(id: ids).preload(...).index_by(&:id)

やっているのは変換だけ。

ruby
# 変換前(配列)
[#<Reservation id: "A">, #<Reservation id: "B">]

# 変換後(Hash)
{ "A" => #<Reservation id: "A">,
  "B" => #<Reservation id: "B"> }

&:id は「各要素に .id を呼んで、それをキーにする」という意味。index_by(&:email) と書けばメールアドレスがキーになる。

なぜ Hash にするのか

あとでキーから引きたいから。配列のままだと毎回先頭から探すことになる。

ruby
# 配列(毎回、先頭から順に探す)
users.find { |u| u.id == target_id }

# Hash(一発で引ける)
users[target_id]

件数が増えるほど差が開き、コードも読みにくくなる。


「順序」と「データ」を分ける

index_by が効くのは、別の Hash と ID で繋ぐ設計にしたとき。

Hash 中身 役割
1つめ {ID => 初回受付日時} 誰に、どの順で送るか
2つめ {ID => レコード} 何を表示するか

1つめは group + 集計関数で作った Hash。日時を持っているので並び順を決められる。2つめが index_by で作った Hash。

ruby
# 日時が早い順に並べてループ
first_received_at.sort_by { |_, at| at }.each do |id, _|
  reservation = reservations[id]     # ← IDで2つめから引く
  # reservation.customer.name などで通知を組み立てる
end

「順序」と「データ」を別々の Hash に持たせ、ID で繋ぐ。集計結果と実レコードを混ぜずに扱えるので、並び替えの条件を変えても表示側に影響しない。


N+1 に気づくには

N+1 は「動くけれど遅い」タイプの問題なので、テストでは気づきにくい。ループの中で関連を触っていたら、SQLログを開いて同じクエリが並んでいないか確かめる。

ruby
# コンソールでSQLログを出す
ActiveRecord::Base.logger = Logger.new($stdout)

まとめ

項目 内容
N+1 ループの中で関連を触ると、1回ごとにSQLが飛ぶ
preload 先にまとめて取る。IN (...) で1回にする
クエリ本数 関連の数で決まる。対象が何件でも変わらない
joins では代用不可 JOIN しても関連データはメモリに載らない
ネスト { 親: 子 }。最後だけシンボル
index_by 配列を {キー => 要素} の Hash に変える
Hash にする理由 あとでキーから一発で引くため

Index

  • 概要
  • N+1 問題
  • preload — まとめて先読みする
  • joins では代用できない
  • ネストした preload
  • 実例:多段ネストの preload
  • index_by — 配列を Hash にする
  • なぜ Hash にするのか
  • 「順序」と「データ」を分ける
  • N+1 に気づくには
  • まとめ