each
はデータをすべてメモリにのせてからループ処理を実行するため、大量のデータを扱う場合はメモリを圧迫させる可能性があります。
たとえば以下の例では、booksテーブルの全レコードがActive Recordオブジェクトに変換され、全オブジェクトをまとめた配列がメモリへ配置されることになります。
### データが多いとメモリを大量に消費するダメな実装
Book.all.each do |book|
# 処理
end
Railsで大量のデータをループ処理する場合、データを割してメモリにのせるin_batches・find_in_batches・find_eachを利用することでメモリ消費が抑えられます。
今回はin_batches
・find_in_batches
・find_each
の違いについて紹介します。
目次
メソッドの概要と挙動について
in_batches
、find_in_batches
、find_each
について紹介します。
in_batchesについて
in_batchesはデータを分割して取得し、ActiveRecord::Relationオブジェクトの形でブロックへ渡します。デフォルトの分割単位は1,000件です。Rails 5.0.0.1から利用可能です。
in_batches
のオプションは以下の通りです。1
オプション | 内容 |
---|---|
:of | バッチ数。デフォルトは1,000 |
:load | ロードの有無。デフォルトはfalse |
:start | 開始位置 |
:finish | 終了キー |
:error_on_ignore | 例外を発生させる |
in_batches
はActiveRecord::Relationをブロックに渡すので、ブロック内でwhereによる絞り込みやupdate_allによる一括更新を行いたい時に向いているメソッドです。
たとえば『booksテーブルにある全レコードのpriceを100増やす』という処理をin_batches
で実装すると以下のようになります。
ソースコード
Book.in_batches do |relation|
relation.update_all("price = price + 100")
end
update_allとupdateの挙動は等価ではないので、『each + update』の代わりに『in_batches + update_all』を利用する際は注意が必要です。
ログは以下の通りです。
発行されたSQLを見てわかる通り、in_batches
を利用すると1,000件ずつデータが一括更新されます。
実行ログ
(1.0ms) SELECT `books`.`id` FROM `books` ORDER BY `books`.`id` ASC LIMIT 1000
Book Update All (14.9ms) UPDATE `books` SET price = price + 100 WHERE `books`.`id` IN (1, 2, 3, 4, 5,...
(0.8ms) SELECT `books`.`id` FROM `books` WHERE `books`.`id` > 1000 ORDER BY `books`.`id` ASC LIMIT 1000
Book Update All (14.6ms) UPDATE `books` SET price = price + 100 WHERE `books`.`id` IN (1001, 1002, 1003, 1004, 1005,...
(0.8ms) SELECT `books`.`id` FROM `books` WHERE `books`.`id` > 2000 ORDER BY `books`.`id` ASC LIMIT 1000
Book Update All (14.6ms) UPDATE `books` SET price = price + 100 WHERE `books`.`id` IN (2001, 2002, 2003, 2004, 2005,...
(0.6ms) SELECT `books`.`id` FROM `books` WHERE `books`.`id` > 3000 ORDER BY `books`.`id` ASC LIMIT 1000
Book Update All (14.6ms) UPDATE `books` SET price = price + 100 WHERE `books`.`id` IN (3001, 3002, 3003, 3004, 3005,...
...
...
find_in_batchesについて
find_in_batchesはデータを分割して取得し、Arrayの形でブロックへ渡します。デフォルトの分割単位は1,000件です。
find_in_batchesの実装を確認するとメソッドの中でin_batches
を呼んでいることがわかります。
つまり、in_batchesで渡されたオブジェクトをArrayに変換するメソッドがfind_in_batchesの正体です。
find_in_batches
のオプションは以下の通りです。2
オプション | 内容 |
---|---|
:batch_size | バッチ数。デフォルトは1,000 |
:start | 開始位置 |
:finish | 終了キー |
:error_on_ignore | 例外を発生させる |
たとえば『booksテーブルにある全レコードのpriceを100増やす』という処理をfind_in_batches
で実装すると以下のようになります。
ソースコード
Book.find_in_batches do |books|
books.each do |book|
book.price += 100
book.save
end
end
ログは以下の通りです。
発行されたSQLを見てわかる通り、find_in_batches
では1,000件ずつデータを取得しています。
実行ログ
Book Load (2.6ms) SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1000
(0.9ms) BEGIN
Book Update (10.9ms) UPDATE `books` SET `books`.`price` = 880, `books`.`updated_at` = '2021-06-25 08:10:21.970912' WHERE `books`.`id` = 1
(10.0ms) COMMIT
(0.3ms) BEGIN
Book Update (0.7ms) UPDATE `books` SET `books`.`price` = 880, `books`.`updated_at` = '2021-06-25 08:10:22.001425' WHERE `books`.`id` = 2
(3.0ms) COMMIT
(0.4ms) BEGIN
Book Update (0.6ms) UPDATE `books` SET `books`.`price` = 740, `books`.`updated_at` = '2021-06-25 08:10:22.008483' WHERE `books`.`id` = 3
(2.4ms) COMMIT
(0.3ms) BEGIN
...
...
...
find_eachについて
find_eachはデータを分割して取得し、1件ずつActive Recordの形でブロックへ渡します。
find_eachの実装を確認するとメソッドの中でfind_in_batches
を呼び、find_in_batches
によってブロックに渡されたArrayをeach
でループしていることがわかります。
つまり、find_in_batchesで渡されたArrayをeachでループするメソッドがfind_eachの正体です。
find_each
はeach
と同様に1件ずつActive Recordをブロックに渡すので、eachのループ処理を省メモリで行いたいときに向いているメソッドです。
find_each
のオプションは以下の通りです。3
オプション | 内容 |
---|---|
:batch_size | バッチ数。デフォルトは1,000 |
:start | 開始位置 |
:finish | 終了キー |
:error_on_ignore | 例外を発生させる |
たとえば『booksテーブルにある全レコードのpriceを100増やす』という処理をfind_each
で実装すると以下のようになります。
ソースコード
Book.find_each do |book|
book.price += 100
book.save
end
ログは以下の通りです。
発行されたSQLを見てわかる通り、find_each
では1,000件単位でデータを取得したあと1件ずつブロックに渡しています。
実行ログ
Book Load (1.6ms) SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 1000
(0.5ms) BEGIN
Book Update (0.6ms) UPDATE `books` SET `books`.`price` = 880, `books`.`updated_at` = '2021-06-25 08:14:01.058354' WHERE `books`.`id` = 1
(2.9ms) COMMIT
(0.3ms) BEGIN
Book Update (0.6ms) UPDATE `books` SET `books`.`price` = 880, `books`.`updated_at` = '2021-06-25 08:14:01.066300' WHERE `books`.`id` = 2
(2.4ms) COMMIT
(0.2ms) BEGIN
...
...
...
データのソート順はバッチ処理に適用されないので注意
in_batchesの実装を確認するとprimary_key
を利用してデータを取得していることがわかります。
そのため、ソート処理をしたデータに対してin_batchesを実行しても事前に行ったソート順は適用されません。
ソースコード
### orderは適用されない
Book.order("price DESC").in_batches(of: 10) do | relation |
p relation.map(&:price)
end
ログは以下の通りです。
バッチ単位でみるとprice順にソートされていますが、ループ処理はid順になっています。
また、Scoped order is ignored, it's forced to be batch order.
というログからもソートが適用されていないことがわかります。
実行ログ
Scoped order is ignored, it's forced to be batch order.
(0.7ms) SELECT `books`.`id` FROM `books` ORDER BY `books`.`id` ASC LIMIT 10
Book Load (0.6ms) SELECT `books`.* FROM `books` WHERE `books`.`id` IN (1, 2, 3, 4, 5, 6, 7, 8, 9, 10) ORDER BY price DESC
[1080, 1080, 1040, 1020, 1020, 800, 780, 780, 640, 640]
(0.5ms) SELECT `books`.`id` FROM `books` WHERE `books`.`id` > 10 ORDER BY `books`.`id` ASC LIMIT 10
Book Load (0.5ms) SELECT `books`.* FROM `books` WHERE `books`.`id` IN (11, 12, 13, 14, 15, 16, 17, 18, 19, 20) ORDER BY price DESC
[940, 940, 900, 870, 830, 800, 740, 660, 620, 600]
(0.5ms) SELECT `books`.`id` FROM `books` WHERE `books`.`id` > 20 ORDER BY `books`.`id` ASC LIMIT 10
Book Load (0.5ms) SELECT `books`.* FROM `books` WHERE `books`.`id` IN (21, 22, 23, 24, 25, 26, 27, 28, 29, 30) ORDER BY price DESC
[1050, 1000, 940, 910, 850, 760, 730, 720, 680, 600]
find_in_batchesとfind_eachもin_batches`同様、事前に行ったソート順は適用されません。
参考として以下にfind_in_batches
とfind_each
を利用したケースも掲載しておきます。
ソースコード(find_in_batches)
### orderは適用されない
Book.order("price DESC").find_in_batches(batch_size: 10) do |books|
p books.map(&:price)
end
実行ログ(find_in_batches)
Scoped order is ignored, it's forced to be batch order.
Book Load (0.8ms) SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 10
[780, 780, 640, 1080, 1080, 1020, 1040, 800, 1020, 640]
Book Load (0.6ms) SELECT `books`.* FROM `books` WHERE `books`.`id` > 10 ORDER BY `books`.`id` ASC LIMIT 10
[660, 940, 800, 600, 830, 940, 740, 900, 620, 870]
Book Load (0.6ms) SELECT `books`.* FROM `books` WHERE `books`.`id` > 20 ORDER BY `books`.`id` ASC LIMIT 10
[940, 600, 1000, 730, 850, 1050, 680, 720, 760, 910]
ソースコード(find_each)
### orderは適用されない
Book.order("price DESC").find_each(batch_size: 10) do |book|
print "#{book.price}, "
end
実行ログ(find_each)
Scoped order is ignored, it's forced to be batch order.
Book Load (0.7ms) SELECT `books`.* FROM `books` ORDER BY `books`.`id` ASC LIMIT 10
780, 780, 640, 1080, 1080, 1020, 1040, 800, 1020, 640,
Book Load (0.7ms) SELECT `books`.* FROM `books` WHERE `books`.`id` > 10 ORDER BY `books`.`id` ASC LIMIT 10
660, 940, 800, 600, 830, 940, 740, 900, 620, 870,
Book Load (0.7ms) SELECT `books`.* FROM `books` WHERE `books`.`id` > 20 ORDER BY `books`.`id` ASC LIMIT 10
940, 600, 1000, 730, 850, 1050, 680, 720, 760, 910,
実行順に意味のあるループ処理ではin_batches、find_in_batches、find_eachを利用しないようにしましょう。
ループ処理の対象が数千程度であればeachメソッドで問題ない
バッチ数のデフォルトが1,000であることからわかる通り、ループ処理の対象が数千程度であればeach
で問題ありません。4
まとめ
- in_batchesはActiveRecord::Relationを渡す
- find_in_batchesはArrayを渡す
- find_eachはActive Recordを渡す
- in_batches・find_in_batches・find_eachではデータのソートは無視される注意
- 数千程度のデータ数であればeachでループ処理をしても問題ない
Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!