ActiveRecord::Batches#find_eachについて
ActiveRecordのfind_each
は、メモリを節約しながら大量のデータを処理するためのメソッドです。
ActiveRecordのリファレンスには以下のようなことが書かれています。
find_each(start: nil, finish: nil, batch_size: 1000, error_on_ignore: nil)
find_eachはデータベースのレコードをループ処理します。
大量のレコードが存在するとき、allを用いては、すべてのオブジェクトを一度にインスタンス化するため、メモリ効率がとても悪いです。
一方、find_eachのようなバッチ処理メソッドを使用すると、レコードをバッチで処理するため、メモリ消費が大幅に節約できます。
find_eachは、デフォルトで:batch_sizeが1000なので、1000件ずつのレコードを分割してループします。
https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_each
つまり、10,000件のレコードを取得してcsvに加工したりといった処理を行いたいとき、find_eachを用いれば10,000件のレコードがメモリにのらずに済むということです。
find_eachを用いてActiveRecordが生成するクエリは、以下のようになります。
User.find_each do |user|
# do_something
end
SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1000
SELECT `users`.* FROM `users` WHERE `users`.`id` > 1000 ORDER BY `users`.`id` ASC LIMIT 1000
SELECT `users`.* FROM `users` WHERE `users`.`id` > 2000 ORDER BY `users`.`id` ASC LIMIT 1000
SELECT `users`.* FROM `users` WHERE `users`.`id` > 3000 ORDER BY `users`.`id` ASC LIMIT 1000
...
(usersが全件取得できるまで繰り返す。10,000件存在していれば10回)
LIMIT 1000
とレコードを限定して取得し、WHERE users.id > 1000
のように条件指定することで、漏れ・ダブリなくレコードを取得するようになっています。
LIMITを1000以外にしたい場合は、リファレンスにあるように、find_eah(batch_size: 2000)
のように呼び出せば良いです。
しかし、上記を見ると、ORDER BY users.id ASC
という意図しないソートがクエリに含まれています。
リファレンスを確認すると、次のような注意書きがあります。
NOTE: It's not possible to set the order. That is automatically set to ascending on the primary key (“id ASC”) to make the batch ordering work. This also means that this method only works when the primary key is orderable (e.g. an integer or string).
どうやら、find_eachは主キーでソートしてからLIMITをかけて分割取得することで、漏れ・ダブリなくデータを処理するといった実装になっているようです。
よって、select(users.id AS user_id)
などと一緒に使うとエラーが出ます(id
というカラム名である必要がある)。
また、他のカラムでORDERしていると、それは無視されます。
indexによっては、ORDERが入ることでクエリが遅くなってしまうことがあると思います。
ORDERは入れたくないが、分割して処理することでメモリ消費を削減したいとき、どう実現するかを考えてみました。
実現方法
単純ですが、id
をすべて取ってきて、each_slice
で任意の数ずつwhere
で取ってくることで、ORDERなしでfind_eachっぽく処理するようにしてみました。
ids = User.pluck(:id)
ids.each_slice(1000) do |id|
User.where(id: id).each do |user|
# do_somethins
end
end
これで発行されるクエリは以下のとおりです。
SELECT `users`.`id` FROM `users`
SELECT `users`.* FROM `users` WHERE `users`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, ..., 1000)
...
(全件取得するまで)
これでORDERなしで、メモリを節約できました。
問題点
find_eachは安全のためにORDER BY users.id ASC
しているわけで、例えばクエリ実行のたびに取得順序が変わるような場合は当然これは使用できません。
また、ids = User.pluck(:id)
でidを全件取ってくるため、ここでパフォーマンスが落ちることもあります。
結局、find_eachでORDERが入ってもいいように、indexを適切にできるならそうしたほうが良さそうです。