Railsのループ処理でメモリ使用量を抑える。N+1の次に見ること

Railsのループ処理でメモリ使用量を抑える。N+1の次に見ることの説明画像

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_eachfind_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_byindex_by の使い分け

よく使うのは group_byindex_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_eachfind_in_batches、SQL集計、非同期処理なども含めて考えます。

【Rails】N+1問題を見つけて、動作遅延を改善する。 と合わせて、N+1とメモリ使用量はセットで見るとよさそうです。

まとめ

Railsのループ処理では、N+1だけでなくメモリ使用量も見たほうがよいです。

  • 大量データは find_eachfind_in_batches
  • 件数が現実的なら事前取得して group_by
  • 1件参照なら index_by
  • 集計はDBに任せることも考える
  • to_a する前に件数を見る

パフォーマンス改善は、クエリ回数だけを減らせば終わりではありません。

DBに任せるところ、Rubyで扱うところ、バッチで分けるところ。

この分担を考えられると、Railsのループ処理はかなり扱いやすくなると思います。

この記事をシェア