0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails】each, find_each, find_in_batches の違い

Last updated at Posted at 2024-07-21

前提

下記コードで取得されるcompanyの件数が50件である前提で進めます

Company.joins(:shops).where(shops: { prefecture: '東京都' }).distinct

each

メインの確認コード
companies.each.with_index(1) do |company, i|
  puts "loop_count: #{i}, company_id: #{company.id}"
end

※何回目のループでどんなレコードが渡っているか、視覚的に分かりやすくするために#with_indexを使用します。

rails c
[1] pry(main)> companies = Company.joins(:shops).where(shops: { prefecture: '東京都' }).distinct
  Company Load (5.3ms)  SELECT DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1  [["prefecture", "東京都"]]
=> [#<Company:0x00007f98ebc5b468
  id: 18,
  ### 省略 ###
[2] pry(main)> companies.size
=> 50
[3] pry(main)> companies.each.with_index(1) do |company, i|
[3] pry(main)*   puts "loop_count: #{i}, company_id: #{company.id}"
[3] pry(main)* end
loop_count: 1, company_id: 18
loop_count: 2, company_id: 40
loop_count: 3, company_id: 10
loop_count: 4, company_id: 127
loop_count: 5, company_id: 21
loop_count: 6, company_id: 125
loop_count: 7, company_id: 48
loop_count: 8, company_id: 22
loop_count: 9, company_id: 2
loop_count: 10, company_id: 64
loop_count: 11, company_id: 36
loop_count: 12, company_id: 25
loop_count: 13, company_id: 47
loop_count: 14, company_id: 9
loop_count: 15, company_id: 102
loop_count: 16, company_id: 77
loop_count: 17, company_id: 107
loop_count: 18, company_id: 71
loop_count: 19, company_id: 43
loop_count: 20, company_id: 3
loop_count: 21, company_id: 79
loop_count: 22, company_id: 81
loop_count: 23, company_id: 14
loop_count: 24, company_id: 114
loop_count: 25, company_id: 34
loop_count: 26, company_id: 19
loop_count: 27, company_id: 1
loop_count: 28, company_id: 17
loop_count: 29, company_id: 80
loop_count: 30, company_id: 33
loop_count: 31, company_id: 11
loop_count: 32, company_id: 55
loop_count: 33, company_id: 13
loop_count: 34, company_id: 53
loop_count: 35, company_id: 118
loop_count: 36, company_id: 76
loop_count: 37, company_id: 8
loop_count: 38, company_id: 128
loop_count: 39, company_id: 7
loop_count: 40, company_id: 84
loop_count: 41, company_id: 20
loop_count: 42, company_id: 16
loop_count: 43, company_id: 29
loop_count: 44, company_id: 101
loop_count: 45, company_id: 65
loop_count: 46, company_id: 50
loop_count: 47, company_id: 95
loop_count: 48, company_id: 91
loop_count: 49, company_id: 86
loop_count: 50, company_id: 54
=> [#<Company:0x00007f98ebc5b468
  id: 18,
  省略>,
 #<Company:0x00007f989b8bf6d8
  id: 40,
  省略>,
  ### 以降省略 ###

全てのレコードをメモリに読み込んでから実行している為、メモリ使用量には要注意!(特に大規模なデータの場合)

※実際にデータがメモリに読み込まれるのは#each#map#to_a等のメソッドを呼び出した時点です。(クエリは遅延評価されます)

find_each

分割してレコードを取得して1件ずつ処理
デフォルトでは1000件ずつ処理 大きなデータをもつモデルなどを処理する時に使う

  • find_in_batchesとの違いは1件ずつ処理すること
オプション 説明 デフォルト値
:batch_size 同時処理数 1000
:start 処理開始位置
:finish 終了するときのキー
:error_on_ignore 例外を発生させる
:order 主キーの順序を指定 :asc

実際に走っているSQLを、コンソールで確認します。

メインの確認コード
companies.find_each(batch_size: 20).with_index(1) do |company, i|
  puts "loop_count: #{i}, company_id: #{company.id}"
end
rails c
[1] pry(main)> companies = Company.joins(:shops).where(shops: { prefecture: '東京都' }).distinct
  Company Load (5.3ms)  SELECT DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1  [["prefecture", "東京都"]]
=> [#<Company:0x00007f989b847160
  id: 18,
  ### 省略 ###
[2] pry(main)> companies.size
=> 50
[15] pry(main)> companies.find_each(batch_size: 20).with_index(1) do |company, i|
[15] pry(main)*   puts "loop_count: #{i}, company_id: #{company.id}"
[15] pry(main)* end
  Company Load (1.9ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 1, company_id: 1
loop_count: 2, company_id: 2
loop_count: 3, company_id: 3
loop_count: 4, company_id: 7
loop_count: 5, company_id: 8
loop_count: 6, company_id: 9
loop_count: 7, company_id: 10
loop_count: 8, company_id: 11
loop_count: 9, company_id: 13
loop_count: 10, company_id: 14
loop_count: 11, company_id: 16
loop_count: 12, company_id: 17
loop_count: 13, company_id: 18
loop_count: 14, company_id: 19
loop_count: 15, company_id: 20
loop_count: 16, company_id: 21
loop_count: 17, company_id: 22
loop_count: 18, company_id: 25
loop_count: 19, company_id: 29
loop_count: 20, company_id: 33
  Company Load (1.2ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1 AND ("companies"."id" > 33)  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 21, company_id: 34
loop_count: 22, company_id: 36
loop_count: 23, company_id: 40
loop_count: 24, company_id: 43
loop_count: 25, company_id: 47
loop_count: 26, company_id: 48
loop_count: 27, company_id: 50
loop_count: 28, company_id: 53
loop_count: 29, company_id: 54
loop_count: 30, company_id: 55
loop_count: 31, company_id: 64
loop_count: 32, company_id: 65
loop_count: 33, company_id: 71
loop_count: 34, company_id: 76
loop_count: 35, company_id: 77
loop_count: 36, company_id: 79
loop_count: 37, company_id: 80
loop_count: 38, company_id: 81
loop_count: 39, company_id: 84
loop_count: 40, company_id: 86
  Company Load (0.9ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1 AND ("companies"."id" > 86)  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 41, company_id: 91
loop_count: 42, company_id: 95
loop_count: 43, company_id: 101
loop_count: 44, company_id: 102
loop_count: 45, company_id: 107
loop_count: 46, company_id: 114
loop_count: 47, company_id: 118
loop_count: 48, company_id: 125
loop_count: 49, company_id: 127
loop_count: 50, company_id: 128
=> nil

一度に全てのレコードをメモリに読み込まず、指定したbatch_size毎にSQLを実行してデータを読み込み、ブロック処理を行っていることが分かります

デフォルトでは、ID順(昇順)ORDERしている

GitHub(find_each)

eachとfind_eachのメモリ使用量の違い

メモリ使用量について検証している興味深い記事があったので、リンクを記載します。

find_each未使用
3回実行し、MAXで72.43%ほどを使用していることが分かりました。

find_each使用
3回実行し、MAXで22.48%ほどを使用していることが分かりました。

結論
find_eachを使用することで、メモリの消費が大幅に抑えられることが分かりました。
バッチなどの大量データを使用する場合にはfind_eachを使用し、メモリの消費を抑えることを意識した方が良いと思います。

メモリ削減において、力を発揮してくれる(特に大規模なデータを扱う場合)

find_eachの注意点

使い方を間違えると、無限ループを起こして逆にメモリを食い潰すらしいです。
実装する際にこちらの注意点を把握しておくと良さそうなので、リンクを載せておきます。

[Rails]find_eachが無限ループして本番環境のメモリを食いつぶした話

SELECT句で結合先テーブルのIDを指定する際は、結合元テーブルのIDにも(双方)明示的に別名(AS)をつけておくのが良さそう

find_in_batches

分割してレコードを取得して処理
デフォルトで1000件ずつ処理

処理する順序は指定できない
find_eachとの違いは配列で値を受けとること
順番に処理したい場合は、find_eachを使用

オプション 説明 デフォルト値
:batch_size 同時処理数 1000
:start 処理開始位置
:finish 終了するときのキー
:error_on_ignore 例外を発生させる

こちらについても、実際に走っているSQLをコンソールで確認してみます。

メインの確認コード
companies.find_in_batches(batch_size: 20).with_index(1) do |company_group, i|
  puts "loop_count: #{i}, company_group.size: #{company_group.size}, class: #{company_group.class}"
  puts "company_ids: #{company_group.map(&:id)}"
  puts ''
end
rails c
[1] pry(main)> companies = Company.joins(:shops).where(shops: { prefecture: '東京都' }).distinct
  Company Load (5.3ms)  SELECT DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1  [["prefecture", "東京都"]]
=> [#<Company:0x00007f989b81e440
  id: 18,
  ### 省略 ###
[2] pry(main)> companies.size
=> 50
[3] pry(main)> companies.find_in_batches(batch_size: 20).with_index(1) do |company_group, i|
[3] pry(main)*   puts "loop_count: #{i}, company_group.size: #{company_group.size}, class: #{company_group.class}"
[3] pry(main)*   puts "company_ids: #{company_group.map(&:id)}"
[3] pry(main)*   puts ''
[3] pry(main)* end
  Company Load (3.2ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 1, company_group.size: 20, class: Array
company_ids: [1, 2, 3, 7, 8, 9, 10, 11, 13, 14, 16, 17, 18, 19, 20, 21, 22, 25, 29, 33]

  Company Load (1.2ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1 AND ("companies"."id" > 33)  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 2, company_group.size: 20, class: Array
company_ids: [34, 36, 40, 43, 47, 48, 50, 53, 54, 55, 64, 65, 71, 76, 77, 79, 80, 81, 84, 86]

  Company Load (1.3ms)  SELECT  DISTINCT "companies".* FROM "companies" INNER JOIN "shops" ON "shops"."company_id" = "companies"."id" WHERE "shops"."prefecture" = $1 AND ("companies"."id" > 86)  ORDER BY "companies"."id" ASC LIMIT 20  [["prefecture", "東京都"]]
loop_count: 3, company_group.size: 10, class: Array
company_ids: [91, 95, 101, 102, 107, 114, 118, 125, 127, 128]

=> nil

こちらも、指定したbatch_size毎にSQLを実行し、Loadしていることが分かります

一度に大量のレコードをメモリに読み込むことを避け、メモリ使用量を抑えている

ActiveRecord::Relationを配列に直しても問題ない ∧ バルクアップデートやバルクインサート等、batch_size毎にまとめて処理を行うケースで有効そう

GitHub(find_in_batches)

in_batches(Rails5以降)

Rails5以降では、#in_batches というメソッドも使うことが出来ます。

find_in_batchesとの違いはActiveRecord::Relationで値を返す

オプション 説明 デフォルト値
:of 同時処理数 1000
:load ロードするか false
:start 処理開始位置
:finish 終了するときのキー
:error_on_ignore 例外を発生させる
:order 主キーの順序を指定 :asc

GitHub(in_batches)

配列ではなくActiveRecord::Relationを返すのは扱い易そうです。クエリをメソッドチェーンで追加することもできそうです

オプションでloadするかを決められる点も、find_eachfind_in_batchesとは違いますね

ActiveRecord::Relationを配列に直すとデータ規模によってはメモリ使用量が増加しそうですが、こちらのメソッドはよりメモリ使用量の削減において力を発揮してくれそうです

但し、パフォーマンス面においては処理速度を上げる工夫が必要なようです。

良い記事がありましたので、リンクを載せておきます。

Railsでin_batches使うととても遅い

そもそもin_batchesを使わずに、
ID配列.each_slice(1_000) { |ids| User.where(id: ids).each { |user| ... } }
の方が処理パフォーマンスは良さげといった結論のようですw

ただ、私自身で確かめた訳ではない ∧ ケースによっては#in_batchesにも他のメリットもある筈なので、あくまでも参考に留めます。

参考リンク

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?