LoginSignup
3
0

More than 3 years have passed since last update.

ORDER BYを使わずにfind_eachっぽくデータを処理してメモリを節約する方法

Last updated at Posted at 2019-10-11

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を適切にできるならそうしたほうが良さそうです。

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0