15
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Railsで書いたバッチを12倍高速化した話

Posted at

Railsでバッチを書いたら

初めてRailsでバッチを書いて
ベストプラクティスかわからないけど「バッチの書き方」と「バッチの高速化」の知見が得られたので
今回は「バッチの高速化」について書いていきたいと思います

高速化のためやったこと

    1. activerecord-importでバルクインサートする
    1. eachの代わりにin_batchesを使う
    1. 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がパフォーマンスが一番よかった

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をつぶす

さらに速さを追求するなら

    1. カラムの配列と値の配列を使う方法
    • 自分では試してないけどgithubのドキュメントによるとこれが一番速いらしい
    1. 予想だけど↑と組み合わせてpluckとか生SQLの実行でActiveRecordのインスタンス生成コストを抑えると更に速くなるかも
    • ARのインスタンス生成が遅いのは有名な話だし
    • データ件数が倍になっても実行時間は1.2倍にしかならなかったのでおそらくデータ件数以外の部分の割合が多そう

次回はバッチの全体像(system()rails runnerをコールしたりとかテーブルロックとか)について記事を書きたいと思います

15
14
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
15
14

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?