あゆの塩焼きブログ

【Rails】N+1問題を見つけて、動作遅延改善しよう

Agenda目次
  • ・⚠️ N+1問題、それは開発者の天敵
  • ・🔍 こんなところにN+1!?ありがちな発生パターン
  • ・✅ 解決策① eager_load で N+1 を撃退
  • ・✅ 解決策② preload でスマートに処理
  • ・⚖️ eager_load vs preload どっちを使うべき?
  • ・🤔 includesはどうなの?おすすめしない理由
  • ・⚠️ 罠:解決したと思ったら、まだN+1問題が潜んでいた!
  • ・⚡【実例】N+1改善で100倍速くなった話
  • ・🎯 まとめ

⚠️ 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_loadpreloadのどちらかを選ぶ仕様になっています。

この挙動が予測しづらいため、明示的にeager_loadpreloadを使うのがおすすめです。

⚠️ 罠:解決したと思ったら、まだN+1問題が潜んでいた!

eager_loadpreloadを使っても、ループ内で再度条件をつけて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倍の速度改善)されました!

これはユーザー体験を劇的に向上させることにつながりますし、直した側としても最高に気持ちいいものです。

🎯 まとめ

  1. N+1問題はRailsアプリの天敵!
  2. eager_loadpreloadの使い分けがカギ
    • 条件付き検索をするならeager_load
    • データ量が多い場合はpreload
  3. ループ内のwhereに要注意!N+1が再発する罠
  4. 適切に最適化すれば100倍以上の速度改善も可能!

あなたのRailsアプリにもN+1問題、潜んでいませんか?

今すぐチェックして、救い出してあげましょう!

Loading...