バックエンドエンジニアの横塚です。
Railsで中規模以上のサービスを運用していると、大量のレコードやcsvをバッチで処理したい場面などが出てくると思います。
当たり前のように意識できている人も多いかと思いますが、今回はおさらいの意味も込めてバッチで大量データを扱うときに気をつけていることをまとめていこうと思います!
大量レコードに対して処理をするときはfind_each
やfind_in_batches
を使う
DBからデータを取得してきて処理をしたい場合、each
で処理しようとすると対象データがすべてメモリに展開されてしまいますが、find_each
は1行ずつメモリに展開するため、レコード数を気にせず処理をすることができます。
User.each do |user| # なんか処理 end
↓
User.find_each do |user| # なんか処理 end
また、find_in_batches
は取得したデータを配列でまとめて処理されます。
デフォルトで1000件ずつ処理されますが、batch_size
オプションで何件ずつ処理するか指定することもできます。
User.find_in_batches(batch_size: 500) do |users| # なんか処理 end
Rails5以降はin_batches
メソッドが使えるようになりました。find_in_batches
はブロックに配列を渡していたのに対して、こちらはブロックにActiveRecord::Relation
を渡せるようになりました。
of
オプションで何件ずつ処理するか指定することができます。
大容量のcsvファイルの処理はforeach
を使う
数千行、数万行のcsvファイルを一度に展開してしまうと大量にメモリを消費してしまいます。
foreach
を使って1行ずつ展開してメモリを節約します。
users = CSV.read('tmp/user_data.csv') # 一度に展開される users.each do |row| # なんか処理 end
↓
CSV.foreach('tmp/user_data.csv') do |row| # 1行ずつ展開される # なんか処理 end
大量のレコードをinsertしたいときはバルクインサートする
例えば1万行のcsvファイルからDBにインサートしたいとき、1行ずつsaveしていくと1万回のINSERTがDBに走り、効率が悪いです。
gem activerecord-import
を使うと簡単にバルクインサートが実装できます。
以下はcsvから読み取ったデータを1000件ずつバルクインサートする例です。
columns = %i(first_name, last_name, hoge_status) CSV.foreach('tmp/user_data.csv').each_slice(1000) do |row| values = [] values << [row[0], row[1], row[2]] User.import columns, values end
以下のようにモデルのインスタンスの配列を引数に指定することもできますが、colum, valueの配列指定の方が高速です。さらにvaludate: false
オプションをつけることでバリデーションをスキップすることができるため、高速化が望めますが使用時には注意が必要です。
CSV.foreach('tmp/user_data.csv').each_slice(1000) do |row| users << User.new(first_name: row[0], last_name: row[1], hoge_status: row[2] User.import users end
また、DBがPostgreSQLの場合はrecursive: true
オプションで関連レコードもまとめてバルクインサートすることができます。(MySQLではできないようです)
CSV.foreach('tmp/user_data.csv').each_slice(1000) do |row| user = User.new(first_name: row[0], last_name: row[1], hoge_status: row[2] user.user_items = [user_item1, user_item2, ...] # 関連モデルのインスタンス配列 users << user User.import users, recursive: true end
大量のレコードをまとめて更新、削除するときはupdate_all
, delete_all
を使う
レコードをまとめて更新、まとめて削除したいときに、レコードの数だけUPDATEやDELETEが走らないようにすることができますが、バリデーションもコールバックも呼ばれないため使用時は注意が必要です。
# 特定のpostsレコードまとめて下書きにする Post.where(author_id: 123).update_all(published: false) # 特定のpostsレコードをまとめて削除する Post.where(author_id: 222).delete_all
map
よりpluck
を使ってメモリを節約する
あるテーブルの特定要素を配列で取り出したいときは、ActiveRecordオブジェクトを生成しないpluck
を使ってメモリを節約します。必要なフィールドのみをDBから読み込むためmap
より高速です。
> Pattern.limit(3).map(&:price_rank) [2019-07-26T06:22:25.754772 #29] DEBUG -- : Pattern Load (63.8ms) SELECT "patterns".* FROM "patterns" LIMIT $1 [["LIMIT", 3]] => ["A", "A", "B"]
↓
> Pattern.limit(3).pluck(:price_rank) [2019-07-26T06:22:35.921301 #29] DEBUG -- : (1.8ms) SELECT "patterns"."price_rank" FROM "patterns" LIMIT $1 [["LIMIT", 3]] => ["A", "A", "B"]
ただし、すでにインスタンス化されたオブジェクトから値を取り出したいときは、pluck
を使うと毎回SQLが発行されてしまうため逆に効率が悪くなってしまうので注意です。
参考:
pluckよりもmapのほうが高速なケース - Qiita
まとめ
サービスを数年運用していると、昔つくったバッチがめっちゃメモリ無駄遣いしてる・・!といったことも発生しがちだと思います。リリース当時は問題なくても、データが増えていくにつれてある日限界を超えて爆発する・・・なんていうことが起こらないように、日頃からこういったデータの扱いには気を付けていきたいです。
LCLでは、日々増え続けるデータをスマートに処理してくれる仲間を絶賛募集中です!