⚠️ N+1問題、それは開発者の天敵
N+1問題は、Rails開発においてパフォーマンスを著しく低下させる原因の一つです。
「ちょっと遅いな?」と思っていたら、実はN+1問題だった……なんてこともザラにあります。
今回は、実際に遭遇したN+1のケースとその解決策をわかりやすく解説します!
🔍 こんなところにN+1!?ありがちな発生パターン
N+1問題は、特にアソシエーションを使ったループ処理で発生しやすいです。
❌ ダメな例(N+1問題発生)
class Customer < ApplicationRecord
has_many :orders
end
class Order < ApplicationRecord
belongs_to :customer
end
customers = Customer.all
customers.each do |customer|
puts "#{customer.name} の注文数: #{customer.orders.count}件" # N+1問題発生!
end
このコードでは、Customer.all
で全顧客を取得した後、ループ内で customer.orders.count
を実行しているため、顧客の数だけSQLが発行されます(N+1問題)。
✅ 解決策① eager_load
で N+1 を撃退
🚀 eager_load
を使った改善例
customers = Customer.eager_load(:orders)
customers.each do |customer|
puts "#{customer.name} の注文数: #{customer.orders.count}件" # SQLの追加発行なし!
end
eager_load
を使うと、内部的にLEFT OUTER JOINを行い、データを一括取得します。
そのため、ループ内でcustomer.orders.count
を呼んでも追加のSQLは発行されません。
eager_load
の特徴
- LEFT OUTER JOIN
を使って関連データを一括取得する
- 条件付き検索(例: where(orders: { is_deleted: false })
)と相性が良い
- データ量が多いとJOINの影響でクエリが遅くなることがある
✅ 解決策② preload
でスマートに処理
🚀 preload
を使った改善例
customers = Customer.preload(:orders)
customers.each do |customer|
puts "#{customer.name} の注文数: #{customer.orders.count}件"
end
preload
は、最初にposts
を個別のSQLで取得し、メモリにキャッシュします。
その後、ループ内でcustomer.orders
を参照してもSQLが発行されません。
preload
の特徴
- JOINを使わず、関連データを個別のクエリで取得する
- データ量が多くてもパフォーマンスが安定しやすい
- 条件付きの検索(where
など)をするとN+1が再発するリスクがある
⚖️ eager_load
vs preload
どっちを使うべき?
どちらを使うかはケースによります。
- 条件付きの検索をするなら
eager_load
(例:where(posts: { published: true })
をしたい場合) - データ量が多く、単純な関連データ取得なら
preload
(個別のクエリで取得するため、大量データでも安定しやすい)
この違いを理解して、適切に使い分けましょう。
🤔 includes
はどうなの?おすすめしない理由
includes
も似たような機能を持っていますが、Railsが自動でeager_load
かpreload
のどちらかを選ぶ仕様になっています。
この挙動が予測しづらいため、明示的にeager_load
かpreload
を使うのがおすすめです。
⚠️ 罠:解決したと思ったら、まだN+1問題が潜んでいた!
eager_load
やpreload
を使っても、ループ内で再度条件をつけてSQLを発行するとN+1が復活します。
❌ これ、またN+1問題です!
customers = Customer.eager_load(:orders)
customers.each do |customer|
confirmed_orders = customer.orders.where(status:'confirmed')
puts "#{customer.name} の確定済み注文数: #{confirmed_orders.count}件"
# ここで再びSQLが発行される!(N+1問題)
end
✅ select
でメモリ上で処理
customers = Customer.eager_load(:orders)
customers.each do |customer|
confirmed_orders = customer.orders.select { |order| order.status == 'confirmed' }
puts "#{customer.name} の確定済み注文数: #{confirmed_orders.count}件"
# メモリ上で処理されるのでN+1問題なし!
end
これは、Railsがwhere
を使うと毎回SQLを発行する仕様だからです。
解決策としては、メモリ上で処理するselect
を活用するのがひとつの手です。
⚡【実例】N+1改善で100倍速くなった話
実際にN+1問題が発生し、パフォーマンスが致命的に低下していた画面がありました。
ページのロード時間が30秒以上かかるレベルでしたが、preload
を適用することで、ロード時間が0.2秒に短縮(約150倍の速度改善)されました!
これはユーザー体験を劇的に向上させることにつながりますし、直した側としても最高に気持ちいいものです。
🎯 まとめ
- N+1問題はRailsアプリの天敵!
eager_load
とpreload
の使い分けがカギ- 条件付き検索をするなら
eager_load
- データ量が多い場合は
preload
- 条件付き検索をするなら
- ループ内の
where
に要注意!N+1が再発する罠 - 適切に最適化すれば100倍以上の速度改善も可能!
あなたのRailsアプリにもN+1問題、潜んでいませんか?
今すぐチェックして、救い出してあげましょう!