Railsのループ処理で、N+1の次に気にしたいこと
パフォーマンス改善を考えると、まず思い浮かぶのは N+1 問題です。
これは本当に大事です。
ただ、N+1 を避けたあとにも、まだ気にしたいことがあります。
それが メモリ使用量 です。
大量データを扱う処理では、クエリ回数を減らしても、メモリに載せすぎると別の問題が起きます。
今回は、Railsでループ処理を書くときに、N+1の次に見るべきメモリまわりの考え方を整理します。
all.each は分かりやすいけど、大量データでは危ない
例えば、全ユーザーに対して何か処理したいとします。
User.all.each do |user|
WeeklyMailer.report(user).deliver_later
end
少ない件数なら問題ないかもしれません。
ただ、件数が多い場合、User.all で大量のレコードを一気にメモリへ載せることになります。
Rails Guides でも、大きなテーブルに対して all.each を使うと、全件を一度に取得してメモリを消費しすぎる可能性があるため、find_each や find_in_batches が紹介されています。
Active Record Query Interface - Ruby on Rails Guides
大量データなら find_each を使う
大量レコードを1件ずつ処理するなら、まず find_each を検討します。
User.where(active: true).find_each(batch_size: 1000) do |user|
WeeklyMailer.report(user).deliver_later
end
find_each は、指定した件数ごとに分割して取得しながら処理してくれます。
全件を一度にメモリへ載せないので、大量データ向きです。
ただし、何でも find_each にすればよいわけではありません。
例えば、ユーザーごとに関連データを見ながら集計したい場合、毎回クエリが発生してしまうことがあります。
その場合は、N+1とメモリの両方を見ながら設計する必要があります。
事前取得して、メモリ内で引く
件数が現実的な範囲なら、関連データを事前に取得しておき、メモリ内で引く方法が使えます。
例えば、投稿をユーザーごとに表示したい場合です。
users = User.where(active: true).to_a
posts = Post.where(user_id: users.map(&:id)).to_a
posts_by_user_id = posts.group_by(&:user_id)
users.each do |user|
user_posts = posts_by_user_id[user.id] || []
puts "#{user.name}: #{user_posts.size}件"
end
ポイントは、ループの中で毎回 Post.where(user_id: user.id) を呼ばないことです。
先に必要な投稿をまとめて取得し、group_by で引きやすい形にしておきます。
これで、DBへの問い合わせ回数を減らせます。
group_by と index_by の使い分け
よく使うのは group_by と index_by です。
group_by
1つのキーに対して複数件ある場合に使います。
posts_by_user_id = posts.group_by(&:user_id)
ユーザー1人に複数投稿があるようなケースです。
index_by
1つのキーに対して1件だけ引きたい場合に使います。
users_by_id = users.index_by(&:id)
id からユーザーを引きたい、のようなケースです。
index_by は Active Support の便利メソッドです。
Enumerable#index_by - Ruby on Rails API
メモリに載せる前に、件数を確認する
事前取得は便利ですが、何でもメモリに載せればよいわけではありません。
例えば、数万件、数十万件のデータを一気に to_a するのは危険です。
自分なら、次を見ます。
- 対象件数はどのくらいか
- 1レコードあたりのサイズは大きいか
- 関連データも一緒に持つ必要があるか
- 処理は同期でやる必要があるか
- バッチ処理に分けられないか
- DB側で集計したほうがよくないか
特に集計処理は、Ruby側で全部回すより、SQLで集計したほうがよい場合があります。
メモリ内処理は便利ですが、DBが得意なことまでRubyで抱え込まないほうがよいです。
自分が助けられた場面
以前、ユーザーごとの関連データを表示する機能で、ループ内クエリが多くなっていたことがありました。
最初は、ユーザーごとに関連データを取りに行っていました。
users.each do |user|
posts = Post.where(user_id: user.id)
# postsを使った処理
end
これだと、ユーザー数が増えるほどクエリが増えます。
そこで、先に対象投稿をまとめて取得し、group_by でユーザーごとに分類しました。
posts = Post.where(user_id: users.map(&:id)).to_a
posts_by_user_id = posts.group_by(&:user_id)
users.each do |user|
posts = posts_by_user_id[user.id] || []
# postsを使った処理
end
これだけで、処理時間がかなり改善しました。
ただし、対象件数が大きすぎる場合は別です。
その場合は、find_each や find_in_batches、SQL集計、非同期処理なども含めて考えます。
【Rails】N+1問題を見つけて、動作遅延を改善する。 と合わせて、N+1とメモリ使用量はセットで見るとよさそうです。
まとめ
Railsのループ処理では、N+1だけでなくメモリ使用量も見たほうがよいです。
- 大量データは
find_eachやfind_in_batches - 件数が現実的なら事前取得して
group_by - 1件参照なら
index_by - 集計はDBに任せることも考える
-
to_aする前に件数を見る
パフォーマンス改善は、クエリ回数だけを減らせば終わりではありません。
DBに任せるところ、Rubyで扱うところ、バッチで分けるところ。
この分担を考えられると、Railsのループ処理はかなり扱いやすくなると思います。