見出し画像

Railsでin_batches使うととても遅い


メモリ消費しすぎ問題

ActiveRecordはインスタンスを生成すると結構メモリを食う。それが何万件という規模になるとメモリが足りなくなってバッチ処理の環境が落ちるみたいなことがあるので、よくある解決策としてはin_batchesを使って一度に生成されるインスタンスの量を抑えたりすると思う。

user_ids = [2, 1, 4, ...] # ソートされていない何万件のユーザーのID

# これはメモリを食いすぎる
User.where(id: user_ids).each do |user|
  ...
end

# こっちならインスタンスの量が1000件ごとになる
User.where(id: user_ids).in_batches(of: 1000) do |users|
  users.each |user|
    ...
  end
end

find_each, find_in_batchesなども内部ではin_batchesを使っている。

メモリ問題は解決するけど、速度がでない

in_batchesを使えばメモリの消費はある程度抑えられるけどこれだとin_batchesが作るSQLがとにかく遅い。
なぜかというとin_batchesは常にwhere句に同じ条件をつけたままbatch毎の条件を追加してデータを取得している。

実際に発行されるSQLを見るとわかりやすい

SELECT `users`.*
FROM `users`
WHERE `users`.`id` IN (2, 1, 4, ...)
AND `users.id` IN (2971, 2972, 2973, ...)

つまり、user_idsで指定したレコード全件に絞ってからさらにbatchに必要な件数に絞るということをやっている。

速度も解決する方法

なんてことはない、先に配列を分割しておくのが効く。
この例だとuser_idsを1000件ごとに分割しておけばかなり速くなる。

user_ids.each_slice(1000) do |chunked_user_ids|
  User.where(id: chunked_user_ids).then do |users|
    users.each do |user|
      ...
    end
  end
end

rubyで配列を複数の配列に分割する時はeach_sliceを使うと便利。

Integerの配列だからsortしておいたらSQLがいい感じになるかなと思ったけどそんなこともなかったので、やっぱり素朴に先に分割するのが良さそう。

Benchmark

測ってみたので載せておく。レコードは200件でソートされたid配列を条件に使っている。

in_batchesを使った時
      user     system      total        real
  0.059541   0.007739   0.067280 (  0.092914)

先にeach_sliceした時
      user     system      total        real
  0.019721   0.001785   0.021506 (  0.027825)