ユニファ開発者ブログ

ユニファ株式会社プロダクトデベロップメント本部メンバーによるブログです。

バッチ処理書くときに使うアレ

この記事はユニファアドベントカレンダーの24日目の記事です!
adventar.org

いつか大きめのバッチ処理を書く必要があるかもしれないので以前から興味があった ActiveRecord::Batches#find_each を使わないで Array#each を使ってしまったら実際にどれだけメモリを食うのか手元のマシンで検証してみた。

実験

今回は取り敢えず 適当に作った127万ユーザーにメールを一斉送信するような処理を作って比較してみた。

Array#each

require 'objspace'

namespace :memory_profile do
  desc 'Array#each'
  task each: :environment do
    puts '---------- Array#each ----------'
    puts "#{User.count} users"
    puts "Before: #{memory_usage} MB"

    User.all.each do |user|
      NewsMailer.deliver(user.email)
    end

    puts "After: #{memory_usage} MB"
    puts "User: #{memory_usage(User)} MB"
  end
end

class NewsMailer
  def self.deliver(_email) = nil
end

def memory_usage(klass = nil)
  ObjectSpace.garbage_collect

  bytes = if klass
    ObjectSpace.memsize_of_all(klass)
  else
    ObjectSpace.memsize_of_all
  end

  (bytes / 1024.0 / 1024.0).round(2)
end
 ± bin/rake memory_profile:each
---------- Array#each ----------
1278681 users
Before: 14.86 MB
After: 1662.31 MB
User: 0.0 MB

Array#each を利用すると10,000レコード当たり13MB程度のメモリ使用量ということがわかった。想像していたよりメモリ使わないのね。

ActiveRecord::Batches#find_each

namespace :memory_profile do
  desc 'ActiveRecord::Batches#find_each'
  task find_each: :environment do
    puts '---------- ActiveRecord::Batches#find_each ----------'
    puts "#{User.count} users"
    puts "Before: #{memory_usage} MB"

    User.find_each do |user|
      NewsMailer.deliver(user.email)
    end

    puts "After: #{memory_usage} MB"
    puts "User: #{memory_usage(User)} MB"
  end
end
 ± bin/rake memory_profile:find_each
---------- ActiveRecord::Batches#find_each ----------
1278681 users
Before: 14.86 MB
After: 15.85 MB
User: 0.21 MB

ActiveRecord::Batches#find_each は1,000件ずつDBからレコードを取得し各レコードを1つのブロックに yield してるので全然メモリ使わないのがよく分かる。(当然DBには1,279回のクエリを発行しています)

オマケ

desc 'Array#each with includes'
task each_with_includes: :environment do
  puts '---------- Array#each (includes)----------'
  puts "#{User.count} users"
  puts "Before: #{memory_usage} MB"

  User.joins({ blogs: { posts: :tags } })
      .includes({ blogs: { posts: :tags } })
      .where(tags: { name: %w(Ruby Glisp) })
      .each do |user|
    NewsMailer.deliver(user.email)
  end

  puts "After: #{memory_usage} MB"
  puts "User: #{memory_usage(User)} MB"
  puts "Blog: #{memory_usage(Blog)} MB"
  puts "Post: #{memory_usage(Post)} MB"
end
 ± bin/rake memory_profile:each_with_includes
---------- Array#each (includes)----------
1278681 users
Before: 14.87 MB
After: 2760.97 MB
User: 77.51 MB
Blog: 86.42 MB
Post: 97.66 MB

Tag で Ruby と Glisp についてのブログ記事を書いている32万ユーザーに絞り込んでみた。無駄に includes を利用して N+1 問題に気を使ったつもりだが不要な Eager Loading は複数の巨大な関連付けモデルの配列を作ってしまい大量のメモリを消費してしまうのが見て取れる。

まとめ

潤沢にメモリが乗っている Worker インスタンスが使える場合やコレクションの大きさが数万程度だったりどうしてもDBへのリクエスト数を制限したいとかじゃない限りは普通に ActiveRecord::Batches#find_each を使いましょう。


ユニファでは、一緒に働く仲間を募集中です!

jobs.unifa-e.com