【Rails】N+1問題を見つけて、動作遅延を改善する。

【Rails】N+1問題を見つけて、動作遅延を改善する。の説明画像
目次

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(顧客)の数だけSQLが発行されます(N+1問題)。
つまり、顧客が100人いれば100回、1000人いれば1000回SQLが発行されるわけです...そりゃ遅い!

解決策① 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の特徴
とっても便利な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は、最初にordersを個別のSQLで取得し、メモリにキャッシュします。

その後、ループ内でcustomer.ordersを参照してもSQLが発行されません。

preloadの特徴
こちらに関してもメリット・デメリットあるので以下にまとめます。

  • JOINを使わず、関連データを個別のクエリで取得する
  • →データ量が多くてもパフォーマンスが安定しやすい
  • 関連に関する条件が付いている検索((例: where(orders: {status: "confirmed"}))をすると、個別でSQLを発行している関係でエラーが発生することがある。

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問題、潜んでいませんか?

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