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

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

# 高速化のためやったこと
- 1. [activerecord-import](https://rubygems.org/gems/activerecord-import)でバルクインサートする
- 2. `each`の代わりに`in_batches`を使う
- 3. N+1を潰す

# 結果
およそ18万件のレコードの処理にかかる時間を1時間から5分まで短縮できた

# バッチ処理の概要と前提
実際はもっと複雑だけど簡略化して

`Target` : `TargetOneChild` : `TargetManyChild` = 1 : 1 : N
というモデルから
`Summary` : `SummaryOneChild` : `SummaryManyChild` = 1 : 1 : N

というモデルを作る処理を例にする
修正前のソースはざっくりとこんな感じ

```ruby: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](https://rubygems.org/gems/activerecord-import)でバルクインサートする

```ruby: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のみ](https://github.com/zdennis/activerecord-import#recursive))
- ※1 has_oneのモデルの生成については[ここを参照](https://railsguides.jp/association_basics.html#has-one%E3%81%A7%E8%BF%BD%E5%8A%A0%E3%81%95%E3%82%8C%E3%82%8B%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89-build-association-attributes)
- ※2 has_manyのモデルの生成については[ここを参照](https://railsguides.jp/association_basics.html#has-many%E3%81%A7%E8%BF%BD%E5%8A%A0%E3%81%95%E3%82%8C%E3%82%8B%E3%83%A1%E3%82%BD%E3%83%83%E3%83%89-collection-build-attributes) `.new`でもいいみたい

ちなみに、モデル生成時に関連モデルを

```ruby
master_target_id: target_many_child.master_target.id,
```

と書くと`import!`の中で`master_targets`への`SELECT`が走ってN+1みたいなことが起こるので

```ruby
master_target: target_many_child.master_target,
```
と書く



## 2. `each`の代わりに`in_batches`を使う
```ruby: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`を呼んでるらしい ([参考](https://qiita.com/Sayaapy/items/a2575c85a58b7327dc20#%E8%A3%9C%E8%B6%B3-rails5%E4%BB%A5%E9%99%8D))
        - 実際`in_batches`がパフォーマンスが一番よかった

# 3. N+1を潰す
```ruby: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. [カラムの配列と値の配列を使う方法](https://github.com/zdennis/activerecord-import#columns-and-arrays)
    - 自分では試してないけどgithubのドキュメントによるとこれが一番速いらしい
- 2. 予想だけど↑と組み合わせてpluckとか生SQLの実行でActiveRecordのインスタンス生成コストを抑えると更に速くなるかも
    - ARのインスタンス生成が遅いのは有名な話だし
    - データ件数が倍になっても実行時間は1.2倍にしかならなかったのでおそらくデータ件数以外の部分の割合が多そう

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