投稿日:2026/7/27
更新日:2026/7/27

「関連データを効率よく取ってくる」ための2つの道具。
| メソッド | やること |
|---|---|
preload |
関連を先にまとめて取っておく(N+1回避) |
index_by |
配列を {キー => 要素} の Hash に変換する |
preload で取得回数を減らし、index_by で引きやすい形にする。この2つはセットで使われることが多い。
以下、ユーザー3人と注文3件のテーブルで見ていく。
| ユーザー | 注文 |
|---|---|
| あおい | コーヒー、ケーキ |
| ひかる | 紅茶 |
| つばさ | (なし) |
「全ユーザーと、その注文内容を一覧表示したい」。素直に書くとこうなる。
User.all.each do |user|
puts user.name
puts user.orders.map(&:item) # ← ここ
end
動くが、飛ぶ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へ問い合わせる」遅延読み込みだから。ループの中で書くと、回るたびに問い合わせが発生する。
解決策は「ループに入る前に、必要な注文を全部まとめて取っておく」。
User.preload(:orders).each do |user|
puts user.name
puts user.orders.map(&:item) # ← SQLは飛ばない
end
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 との決定的な差。
「JOIN すれば1本で済むのでは?」と思うところだが、joins しても関連データはメモリに載らない。
user = User.joins(:orders).first
user.orders # ← ここで改めてSQLが飛ぶ
理由は生成されるSQLの SELECT 句にある。
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回避 |
使い分けはこう考える。
joinspreloadjoins(:orders).preload(:orders)「注文 → 商品 → メーカー」のように関連の先の関連まで辿るときの書き方は2種類だけ。
| 書き方 | 意味 |
|---|---|
:関連名 |
その関連を1階層だけ |
{ 親: 子 } |
その先まで辿る |
User.preload(:orders) # 注文まで
User.preload(orders: :product) # 商品まで
User.preload(orders: { product: :maker }) # メーカーまで
ネストの形はメソッドチェーンと1対1で対応している。
user.orders .product .maker
# ↓ ↓ ↓
{ orders: { product: :maker } }
途中はハッシュ、最後だけシンボル。これがルール。複数の関連を同時に指定するときはカンマで並べる。
User.preload(:profile, orders: :product)
通知メッセージを組み立てるジョブなど、実務では多段のネストが並ぶ。
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段ネストは、コード側のこのチェーンと対応している。
reservation.staff.team.manager.notification_setting.destination_id
これで飛ぶSQLは関連の数だけ、つまり 9本。
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 の性質がそのまま現れている。
preload で取ったレコードは、そのままだと配列。あとで ID から引きたい場合は index_by で Hash にしておく。
reservations = Reservation.where(id: ids).preload(...).index_by(&:id)
やっているのは変換だけ。
# 変換前(配列)
[#<Reservation id: "A">, #<Reservation id: "B">]
# 変換後(Hash)
{ "A" => #<Reservation id: "A">,
"B" => #<Reservation id: "B"> }
&:id は「各要素に .id を呼んで、それをキーにする」という意味。index_by(&:email) と書けばメールアドレスがキーになる。
あとでキーから引きたいから。配列のままだと毎回先頭から探すことになる。
# 配列(毎回、先頭から順に探す)
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。
# 日時が早い順に並べてループ
first_received_at.sort_by { |_, at| at }.each do |id, _|
reservation = reservations[id] # ← IDで2つめから引く
# reservation.customer.name などで通知を組み立てる
end
「順序」と「データ」を別々の Hash に持たせ、ID で繋ぐ。集計結果と実レコードを混ぜずに扱えるので、並び替えの条件を変えても表示側に影響しない。
N+1 は「動くけれど遅い」タイプの問題なので、テストでは気づきにくい。ループの中で関連を触っていたら、SQLログを開いて同じクエリが並んでいないか確かめる。
# コンソールでSQLログを出す
ActiveRecord::Base.logger = Logger.new($stdout)
| 項目 | 内容 |
|---|---|
| N+1 | ループの中で関連を触ると、1回ごとにSQLが飛ぶ |
preload |
先にまとめて取る。IN (...) で1回にする |
| クエリ本数 | 関連の数で決まる。対象が何件でも変わらない |
joins では代用不可 |
JOIN しても関連データはメモリに載らない |
| ネスト | { 親: 子 }。最後だけシンボル |
index_by |
配列を {キー => 要素} の Hash に変える |
| Hash にする理由 | あとでキーから一発で引くため |