Rails: Active Record関連付けのループではeachよりfind_eachを検討しよう(翻訳)
オブジェクトのグループを列挙する標準的なメソッドといえば、Rubyの配列でもRailsのActive Recordモデルでも、each
です。
しかし、大量のデータをループで処理すると(あるモデルの全レコードでデータを入れ直すなど)、多数のレコードを読み込むときにも処理するときにも重大なメモリ問題やスピード低下問題が発生する可能性があります。
そういうときは、ActiveRecord::Batches
が提供する機能を検討すべきです。ここではその中からfind_each
を使ってデモを行います。
以下のように書くのではなく
多数のActive Recordオブジェクトを.each
で回す。
post.comments.each do |comment|
# 各`comment:`に対して何かするジョブをエンキューする
end
以下のように書く
.find_each
でデータベースからレコードを効率よく読み込む。
post.comments.find_each do |comment|
# 各`comment:`に対して何かするジョブをエンキューする
end
そうする理由
each
を使うと、「1個の」SQL呼び出しをデータベースに送信してオブジェクトのセット全体をメモリに読み込み、それからループ処理を行います。これは、最初の例で post.comments.all.each
を呼び出すのと同じです。
これは2つの点で問題になります。
第1に、データベースクエリの実行に長時間を要する可能性があり、タイムアウトの可能性もあります。
第2に、ループ処理のために全レコードをメモリに読み込むので、データを返すときに(返せたとしても)メモリを大量に消費する可能性があります。
.find_each
なら、(多くの賢いデフォルト設定を持つ)効率の良い複数のSQLクエリを作成することでデータベースからレコードを取得します。これは多くの場合、一度に全レコードをメモリに読み込むよりもずっと効率が高まります。
そうしない理由があるとすれば
.find_each
はループ中のソートに主キーしか使わないので、レコードを特定のソート順で表示する必要がある場合についてはサポートされません。モデルでUUID主キーを使っている場合は、この理由によってfind_each
が期待通りに動かなくなります(理由: UUIDはシーケンシャルではありません)。つまり、ループ中に新しいデータが追加されると、レコードがスキップされる可能性があります(lainの指摘に感謝します)。
レコードをその場で変更する必要がある場合は、このようなループ処理は理想的ではありません。各レコードに対して#update
を実行すると「大量の」クエリを実行することになります。代わりに、#update_all
などのより適切なバルク更新メソッドを検討しましょう。
ビューのコードを生成する場合は、each
もfind_each
も使わないでください。レコードが何件あるかもわからない状態で、巨大なコレクションをビューのコードでループ処理すると、ページが遅くなってユーザーエクスペリエンスを損なう原因になります。ページネーションを検討しましょう。
Railsガイドによると、find_each
が必要になるのは一度にメモリに乗せきれないような大量のレコードを処理する場合だけだそうです。レコード数が1000件に満たない場合のループ処理であれば通常のメソッドで問題ありませんし、実際推奨されています。
規模がさらに拡大すると、長時間のループを克服するためにもっと高度な手法が必要になります。おそらく、データベースへの大量の読み書きを削減する、あるいは数時間かかる実行を分単位に減らすなどの対応を取りたくなるでしょう。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。
参考: 週刊Railsウォッチ20230314 Active Record関連付けのループは
find_each
でなお、Rails 6.1.0から
find_each
,find_in_batches
、in_batches
メソッドでorder: :desc
オプションを指定可能になっています(#30590)。参考: §2.2.1.1
find_each
のオプション -- Active Record クエリインターフェイス - Railsガイド参考: PR Support order DESC for find_each, find_in_batches and in_batches by le0pard · Pull Request #30590 · rails/rails