LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

Railsで大量データを扱うときに気をつけていること

f:id:ytkr0813:20190731101326p:plain

バックエンドエンジニアの横塚です。

Railsで中規模以上のサービスを運用していると、大量のレコードやcsvをバッチで処理したい場面などが出てくると思います。
当たり前のように意識できている人も多いかと思いますが、今回はおさらいの意味も込めてバッチで大量データを扱うときに気をつけていることをまとめていこうと思います!

大量レコードに対して処理をするときはfind_eachfind_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では、日々増え続けるデータをスマートに処理してくれる仲間を絶賛募集中です!

www.lclco.com