【Rails】大量のレコードをループ処理する際の注意点

Ruby

分割してデータを読み込むことでメモリの消費を抑える

eachはデータをすべてメモリにのせてからループ処理を実行するため、大量のデータを扱う場合はメモリを圧迫させる可能性があります。

たとえば以下の例では、booksテーブルの全レコードがActive Recordオブジェクトに変換され、全オブジェクトをまとめた配列がメモリへ配置されることになります。

### データが多いとメモリを大量に消費するダメな実装
Book.all.each do |book|
  # 処理
end

Railsで大量のデータをループ処理する場合、データを割してメモリにのせるin_batches・find_in_batches・find_eachを利用することでメモリ消費が抑えられます。

in_batchesfind_in_batchesfind_eachはメソッドによってブロックへ渡すオブジェクトが異なります。
各メソッドとブロックへ渡すオブジェクトの対応は以下の通りです。

メソッド ブロックへ渡すオブジェクト
in_batches ActiveRecord::Relation
find_in_batches Array
find_each Active Record

in_batchesfind_in_batchesfind_eachの違いの詳細解説は【Rails】in_batches、find_in_batches、find_eachの違いと使いどころで紹介しています。

大量のデータをループ処理する場合はeachの代わりにin_batchesfind_in_batchesfind_eachを利用するとよいでしょう。
特にfind_eacheachと同様に1件ずつActive Recordをブロックに渡すので、eachのループ処理を省メモリで行いたいときに向いているメソッドです。

ただし、in_batchesfind_in_batchesfind_eachではデータのソートは無視される注意が必要です。
以下の例をみてわかるように、find_eachの処理順にBook.order("price DESC")は適用されていません。

ソースコード

### orderは適用されない
Book.order("price DESC").find_each(batch_size: 10) do |book|
  print "#{book.price}, "
end

実行ログ

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,

一括更新を活用して処理時間を短縮する

大量のデータを更新する場合、1件ずつ処理をすると頻繁にデータベースへアクセスすることになるため時間がかかります。

たとえば以下のようにすると『booksテーブルにある全レコードのpriceを100増やす』という処理が1件ずつ処理されます。

### 1件ずつsaveが実行されるので時間がかかる
Book.find_each do |book|
  book.price += 100
  book.save
end

update_allを利用してデータを一括更新することでデータベースへのアクセスが減り、処理時間を短縮できます。

in_batchesupdate_allを利用してデータの一括更新を実装した例が以下になります。

### 一括更新によって処理時間が短縮される
Book.in_batches do |relation|
  relation.update_all("price = price + 100")
end

ただし、update_allはバリデーションとコールバックが実行されずレコードのupdate_atも更新されません。1 2

そのため、『each + save』と『in_batches + update_all』の挙動は不等価なので注意が必要です。
バリデーションをスキップしても問題ないシチュエーションであれば、処理時間の観点から一括更新を採用するとよいでしょう。

まとめ

大量のデータをループ処理する際のチェックポイント
  • メモリの消費を抑えるためにデータは分割して読み込む
  • eachではなくin_batches・find_in_batches・find_eachを利用してデータを読み込む
  • 処理時間を短縮させるためにレコードは一括更新する
  • データ更新はsaveではなくupdate_allを活用する
  • update_allはバリデーションやコールバックが実行されないので使用する際は注意する

Twitter(@nishina555)やってます。フォローしてもらえるとうれしいです!

参考