Rails: ジョブの中で関連付けをループしないこと(翻訳)
SQLを直接使わずにRubyでデータベースとやりとりする方法は、ともすると、ほとんど同じようなクエリをうっかり大量に実行してパフォーマンス低下で頭を抱えがちですが、それでもActive Record(または同等のもの)を利用するメリットには一般にそれだけの価値があります。
これはN+1クエリ問題と呼ばれるのが普通です。ビューでActive Recordオブジェクトを表示し、belong_to
でそれに関連付けられているレコードをすべて表示しようとするときに最もよく見かけます。どうしてもデータベースから多数のレコードを読み込んだり保存したりする必要がある場合は、この重たいデータベース処理を非同期ジョブに逃がす方法が使えます。
しかし、production環境のデータがローカル環境よりどれだけ多いかということを軽く考えていると、ジョブの「内部で」ループが長時間実行されてしまうということになりがちです。
以下のように書くのではなく
ジョブの中でも多数のActive Recordオブジェクトをループで回して更新する。
class UpdateManyCommentsJob < ApplicationJob
def perform(post)
post.comments.each do |comment|
comment.update(name: comment.name.downcase)
end
end
end
以下のように書くこと
更新ごとにジョブを分ける。
class UpdateManyCommentsJob < ApplicationJob
def perform(post)
post.comments.each do |comment|
UpdateSingleCommentJob.perform_later(comment)
end
end
end
class UpdateSingleCommentJob < ApplicationJob
def perform(comment)
comment.update(name: comment.name.downcase)
end
end
そうする理由
ここでやっているのは、作業をできるだけ並行化して個別の作業要素を小さく保つことです。
sidekiqの作者Mike Perhamはジョブについての知見が深く、sidekiqのベストプラクティスで「ジョブは、多数のジョブを並行実行できるように設計せよ」と述べています。提案されているソリューションでは、1個のジョブを長時間回すのではなく、1個の親ジョブが多数のサブジョブをキューに登録するようにしています。これにより多くのメリットが得られます。
バックグラウンドのキューで多数のワーカーを実行する形にすれば、ループで処理を1個ずつ回すのに比べて更新に要する時間が大幅に短縮されます。
1件の投稿(post)に「大量の」コメントが付いているような場合でも、リトライが難しくメモリ肥大化問題を引き起こす可能性のある1個のジョブを長時間動かすことを回避できます。
ジョブ内で回すループはproduction環境でバグの温床になる可能性があります。どうかご用心ください。
そうしない理由があるとすれば
1個のジョブでhas_many
関連付けを小さなループで更新するのは問題ありませんが、production環境ではそうはいきません。
ジョブの内容によってはRailsの他の機能が使えるかもしれません。update_all
で関連付けをSQLクエリ1件でまとめて更新する手もありますが、update_all
による更新はシンプルにしなければならず、関連付けられたレコードでコールバックが実行されない点に注意が必要です。
概要
元サイトの許諾を得て翻訳・公開いたします。