Railsでバッチを書いたら
初めてRailsでバッチを書いて
ベストプラクティスかわからないけど「バッチの書き方」と「バッチの高速化」の知見が得られたので
今回は「バッチの高速化」について書いていきたいと思います
高速化のためやったこと
-
- activerecord-importでバルクインサートする
-
-
each
の代わりにin_batches
を使う
-
-
- N+1を潰す
結果
およそ18万件のレコードの処理にかかる時間を1時間から5分まで短縮できた
バッチ処理の概要と前提
実際はもっと複雑だけど簡略化して
Target
: TargetOneChild
: TargetManyChild
= 1 : 1 : N
というモデルから
Summary
: SummaryOneChild
: SummaryManyChild
= 1 : 1 : N
というモデルを作る処理を例にする
修正前のソースはざっくりとこんな感じ
before
def run!
ActiveRecord::Base.transaction do
Target.each do |target|
summary = Summary.create!(name: target.name, 省略)
SummaryOneChild.create!(summary: summary, name: target.target_one_child.name, 省略)
target.target_many_childs.each do |target_many_child|
SummaryManyChild.create!(summary: summary, name: target_many_child.name, 省略)
end
end
end
end
高速化していく
1. activerecord-importでバルクインサートする
after
def run!
ActiveRecord::Base.transaction do
summaries = []
Target.each do |target|
# Summaryと関連モデルを保存せずに生成する
summary = Summary.new(name: target.name, 省略)
summary.build_summary_one_child(name: target.target_one_child.name, 省略) # ※1
target.target_many_childs.each do |target_many_child|
summary.summary_many_childs.build(
name: target_many_child.name,
master_target: target_many_child.master_target,
省略
) # ※2
end
# 生成したモデルの配列を作る
summaries << summary
end
# バルクインサート 関連モデルも一緒に保存できるように recursive: true
Summary.import! summaries, recursive: true
end
end
解説
- コツコツと馬鹿正直に
create
するとめちゃくちゃ遅いのでバルクインサートする -
recursive: true
にすると関連モデルも一緒にインサートできる(※PostgreSQLのみ) - ※1 has_oneのモデルの生成についてはここを参照
- ※2 has_manyのモデルの生成についてはここを参照
.new
でもいいみたい
ちなみに、モデル生成時に関連モデルを
master_target_id: target_many_child.master_target.id,
と書くとimport!
の中でmaster_targets
へのSELECT
が走ってN+1みたいなことが起こるので
master_target: target_many_child.master_target,
と書く
2. each
の代わりにin_batches
を使う
after
def run!
ActiveRecord::Base.transaction do
Target.in_batches(of: 5000) do |targets|
summaries = []
targets.each do |target|
# Summaryと関連モデルを保存せずに生成する
summary = Summary.new(name: target.name, 以降省略)
summary.build_summary_one_child(name: target.target_one_child.name, 省略)
target.target_many_childs.each do |target_many_child|
summary.summary_many_childs.build(name: target_many_child.name, 省略)
end
# 生成したモデルの配列を作る
summaries << summary
end
# バルクインサート 関連モデルも一緒に保存できるように recursive: true
Summary.import! summaries, recursive: true
# トランザクションのコミットも一緒に少しずつ
ActiveRecord::Base.connection.execute('COMMIT;')
ActiveRecord::Base.connection.execute('BEGIN;')
end
end
rescue StandardError => e
# エラーハンドリング
# レコード削除の処理とか
raise
end
解説
-
Target.each
だと一気に全件取得して大量にメモリを食うのでin_batches
で5000件ずつ処理- トランザクションも全件ロールバック領域に入れるとメモリ食いまくるから5000件で区切ってコミット
- ただし、それだとロールバックで全件戻らなくなるので
rescue
してレコード削除の処理を入れる
-
each
の代替候補はfind_each
,find_in_batches
,in_batches
があるが、以下の理由からin_batches
を採用- 5000件の処理の前後にも処理を入れたいから
find_each
はなし -
find_each
の中でfind_in_batches
を、find_in_batches
の中でin_batches
を呼んでるらしい (参考)- 実際
in_batches
がパフォーマンスが一番よかった
- 実際
- 5000件の処理の前後にも処理を入れたいから
3. N+1を潰す
after
def run!
ActiveRecord::Base.transaction do
target_preload_relation.in_batches(of: 5000) do |targets|
summaries = []
targets.each do |target|
# Summaryと関連モデルを保存せずに生成する
summary = Summary.new(name: target.name, 以降省略)
summary.build_summary_one_child(name: target.target_one_child.name, 省略)
target.target_many_childs.each do |target_many_child|
summary.summary_many_childs.build(name: target_many_child.name, 省略)
end
# 生成したモデルの配列を作る
summaries << summary
end
# バルクインサート 関連モデルも一緒に保存できるように recursive: true
Summary.import! summaries, recursive: true
end
end
rescue StandardError => e
# エラーハンドリング
# レコード削除の処理とか
raise
end
# TargetにpreloadさせてN+1を抑制
def target_preload_relation
Target.preload(
:target_one_child,
:target_many_childs
)
end
解説
-
SummaryOneChild
,SummaryManyChild
を生成する際に、関連モデルの値の参照することで起きるN+1をつぶす
さらに速さを追求するなら
-
- 自分では試してないけどgithubのドキュメントによるとこれが一番速いらしい
-
- 予想だけど↑と組み合わせてpluckとか生SQLの実行でActiveRecordのインスタンス生成コストを抑えると更に速くなるかも
- ARのインスタンス生成が遅いのは有名な話だし
- データ件数が倍になっても実行時間は1.2倍にしかならなかったのでおそらくデータ件数以外の部分の割合が多そう
〆
次回はバッチの全体像(system()
でrails runner
をコールしたりとかテーブルロックとか)について記事を書きたいと思います