【Rails】in_batches + pluck + insert_all で複雑な条件の大量insertを爆速で終わらせる

[Increments × cyma (Ateam Inc.) Advent Calendar 2020](https://qiita.com/advent-calendar/2020/increments-cyma) の15日目は、
Increments株式会社 プロダクト開発グループ Qiita開発チームの @mishiwata1015 が担当します！:qiitan:

## はじめに

みなさん、大量データのinsertってどうしてますか？

数が少なければ一件ずつinsertでも問題無いですが、大量データになるとかなりの時間がかかってしまいます。
そこで、複数件のデータをまとめて投入するbulk insertが性能改善の一般的な手法かと思います。

Rails5まではbulk insertのために[activerecord-import](https://github.com/zdennis/activerecord-import)を使うのが主流でした。

Rails6からはActiveRecord標準でbulk insertが行えるようになりました。
> [Ruby on Rails 6.0 リリースノート - Railsガイド](https://railsguides.jp/6_0_release_notes.html#active-record-%E4%B8%BB%E3%81%AA%E5%A4%89%E6%9B%B4)
> - 一括INSERTを行うinsert_all/insert_all!/upsert_allメソッドを追加 ([Pull Request](https://github.com/rails/rails/pull/35631))

今回は、 insert_all を実際に使って得た知見についてお話します。

## insert_all ってどれくらい早いの？

そもそも insert_all による bulk insert でどれくらい早くなるのか気になりますよね。

全ての`User`に対して`Notification`を作成する例で検証していきます。
検証する`User`は1万件とします。

実行環境は以下の通りです。

- Ruby: 2.6.3
- Rails: 6.0.3.3

以下のスクリプトでベンチマークを取ってみました。

```rb:simple_insert_all_benchmark.rb
require "bundler/inline"
require 'benchmark'

ACTIVERECORD_VERSION = '6.0.3.3'

$stdout = open(File::NULL, "w") # Disable stdout for suppress migration log.

gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", ACTIVERECORD_VERSION, require: "active_record"
  gem "sqlite3"
end

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: ":memory:",
)

ActiveRecord::Schema.define do
  create_table :users do |t|
    t.string :name, null: false
    t.timestamps
  end

  create_table :notifications do |t|
    t.integer :user_id, null: false
    t.string :message, null: false
    t.timestamps
  end
end

$stdout = STDOUT # Enable stdout

class User < ActiveRecord::Base
  has_many :notifications
  validates :name, presence: true
end

class Notification < ActiveRecord::Base
  belongs_to :user
  validates :message, presence: true
end

# Create test data
10_000.times do |index|
  User.create!(name: "qiitan#{index}")
end

p "User.count: #{User.count}"

timestamp = Time.current

Benchmark.bm 40 do |r|
  r.report "find_each + user.notifications.create" do
    User.find_each do |user|
      user.notifications.create(message: "Hey! #{user.name}")
    end
  end
  r.report "pluck + insert_all" do
    insert_data = User.pluck(:id, :name).map do |user_id, name|
      { user_id: user_id, message: "Hey! #{name}", created_at: timestamp, updated_at: timestamp }
    end
    Notification.insert_all(insert_data)
  end
end

```

（↑のスクリプトは`ruby`さえあれば手元で実行できます。わざわざRails環境用意しなくても検証できるのでこのやり方覚えておくと楽です。）

ひとまず、以下２パターンを確認しています。

- `find_each + user.notifications.create`
- `insert_all`


実行結果は以下の通りです。

```:simple_insert_all_benchmark.rb実行結果
$ ruby simple_insert_all_benchmark.rb
"User.count: 10000"
                                               user     system      total        real
find_each + user.notifications.create      5.414171   0.017714   5.431885 (  5.434358)
pluck + insert_all                         0.365297   0.002798   0.368095 (  0.368343)
```

insert_all の方が**だいぶ爆速**ですね

何回か実行してみましたがほぼ同じ感じでした。

### :warning: insert_all 使用時の注意点

insert_all を使う場合、**created_at, updated_atは明示的に指定**する必要があります。

```rb:timestampsを明示的に指定しないとダメ
timestamp = Time.current
insert_data = User.pluck(:id, :name).map do |user_id, name|
  {
    user_id: user_id,
    message: "Hey! #{name}",
    created_at: timestamp, # <= ココ！!
    updated_at: timestamp, # <= ココ！!
  }
end
Notification.insert_all(insert_data)
```

**指定しないとnil**になってしまいます。
createとかupdateのときはActiveRecordがよしなにやってくれるので忘れてしまいがちですが、注意してください。

## insert_allが爆速なのは分かった

通常の`find_each + create`と比較して、**`insert_all`が爆速である**、ということはよく分かりました。

ただし、先ほどのベンチマークはとても単純な条件でした。
**insert対象の`User`の絞り込み条件も無く、insertデータも超単純で軽量でした。**

現実はそんなに単純じゃないですよね。

insert_all用のデータを全て一つの変数に保持しようとするとメモリがいくらあっても足りません。
何件かに区切ってinsert_all用データを作成してinsert_allを繰り返し実行する必要があります。

## insert_all用データをどう作成すべきか

**ここからが本題です。**

何件かに区切って実行するには、`ActiveRecord::Batches` の以下3つのメソッドを使う方法があります。

- [in_batches](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-in_batches)
    - `ActiveRecord::Relation` に対する処理をブロックで渡せる
- [find_in_batches](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_in_batches)
    - `ActiveRecord::Relation` を `to_a` したモノに対する処理をブロックで渡せる
- [find_each](https://api.rubyonrails.org/classes/ActiveRecord/Batches.html#method-i-find_each)
    - `ActiveRecord::Relation` を `to_a` して `each` したモノに対する処理をブロックで渡せる

お察しの通り、これらのメソッドは `find_each` の中で `find_in_batches` を呼び出し、 `find_in_batches` の中で `in_batches` を呼び出す構造になっています。

`ActiveRecord::Relation` を扱える `in_batches` が最も柔軟ということですね。
https://github.com/rails/rails/blob/v6.0.3.3/activerecord/lib/active_record/relation/batches.rb#L67-L258

## で、結局どれを使えば良いのか

さっそくベンチマークを取ってみましょう。

今回は、`User`, `Notification` に加えて、 `Post`を追加しています。

Userは10050件にしました。
より現実ぽく、バッチサイズのデフォルト1000の端数処理を入れるため+50しています。
そのうち3000人の`User`が`Post`を持っている状態にしています。

**「`Post`を持たない`User`に`Notification`を作成する」**という条件でinsert_allによる bulk insert を実行します。（これでも単純ですが・・）

```rb:insert_all_benchmark.rb
require "bundler/inline"
require 'benchmark'

ACTIVERECORD_VERSION = '6.0.3.3'

$stdout = open(File::NULL, "w") # Disable stdout for suppress migration log.

gemfile(true) do
  source "https://rubygems.org"

  gem "activerecord", ACTIVERECORD_VERSION, require: "active_record"
  gem "sqlite3"
end

ActiveRecord::Base.establish_connection(
  adapter: "sqlite3",
  database: ":memory:",
)

ActiveRecord::Schema.define do
  create_table :users do |t|
    t.string :name, null: false
    100.times do |i|
      # 現実は複雑なカラムいっぱいもってるよね（これは適当だけど）
      t.string "dummy_column_#{i}", null: true
    end
    t.timestamps
  end

  create_table :posts do |t|
    t.integer :user_id, null: false
    t.string :body, null: false
    t.timestamps
  end

  create_table :notifications do |t|
    t.integer :user_id, null: false
    t.string :message, null: false
    t.timestamps
  end
end

$stdout = STDOUT # Enable stdout

class User < ActiveRecord::Base
  has_many :notifications
  has_many :posts
  validates :name, presence: true
end

class Post < ActiveRecord::Base
  belongs_to :user
  validates :body, presence: true
end

class Notification < ActiveRecord::Base
  belongs_to :user
  validates :message, presence: true
end

# Create test data
timestamp = Time.current
User.insert_all(
  10_050.times.map do |index|
    { name: "qiitan#{index}", created_at: timestamp, updated_at: timestamp }
  end
)

Post.insert_all(
  User.ids.sample(3_000).map do |user_id|
    { user_id: user_id, body: 'body', created_at: timestamp, updated_at: timestamp }
  end
)

p "User.count: #{User.count}"
p "Post.count: #{Post.count}"

Benchmark.bm 60 do |r|
  r.report "find_each + user.notifications.create" do
    User.where.not(id: Post.pluck(:user_id)).find_each do |user|
      user.notifications.create(message: "Hey! #{user.name}")
    end
  end
  r.report "find_each + insert_all" do
    insert_data = []
    User.where.not(id: Post.pluck(:user_id)).find_each do |user|
      insert_data.push(user_id: user.id, message: "Hey! #{user.name}", created_at: timestamp, updated_at: timestamp)

      if insert_data.size % 1000 == 0
        Notification.insert_all(insert_data)
        insert_data = []
      end
    end

    Notification.insert_all(insert_data)
  end
  r.report "find_in_batches + insert_all" do
    insert_data = []
    User.where.not(id: Post.pluck(:user_id)).find_in_batches do |users|
      insert_data = users.map do |user|
        { user_id: user.id, message: "Hey! #{user.name}", created_at: timestamp, updated_at: timestamp }
      end

      if insert_data.size % 1000 == 0
        Notification.insert_all(insert_data)
        insert_data = []
      end
    end

    Notification.insert_all(insert_data)
  end
  r.report "in_batches + pluck + insert_all" do
    insert_data = []
    User.where.not(id: Post.pluck(:user_id)).in_batches do |user_relation|
      user_relation.pluck(:id, :name).map do |user_id, name|
        insert_data.push(user_id: user_id, message: "Hey! #{name}", created_at: timestamp, updated_at: timestamp)
      end

      if insert_data.size % 1000 == 0
        Notification.insert_all(insert_data)
        insert_data = []
      end
    end

    Notification.insert_all(insert_data)
  end
  r.report "subquery + in_batches + pluck + insert_all" do
    insert_data = []
    User.where(
      'NOT EXISTS (
        SELECT
          user_id
        FROM
          posts
        WHERE
          users.id = posts.user_id
      )'
    ).in_batches do |user_relation|
      user_relation.pluck(:id, :name).map do |user_id, name|
        insert_data.push(user_id: user_id, message: "Hey! #{name}", created_at: timestamp, updated_at: timestamp)
      end

      if insert_data.size % 1000 == 0
        Notification.insert_all(insert_data)
        insert_data = []
      end
    end

    Notification.insert_all(insert_data)
  end
end
```
実行結果は以下の通りです。

```:insert_all_benchmark.rb実行結果
$ ruby insert_all_benchmark.rb
"User.count: 10050"
"Post.count: 3000"
                                                                   user     system      total        real
find_each + user.notifications.create                          4.599598   0.041777   4.641375 (  4.649574)
find_each + insert_all                                         0.662382   0.010569   0.672951 (  0.674389)
find_in_batches + insert_all                                   0.566521   0.006979   0.573500 (  0.575291)
in_batches + pluck + insert_all                                0.519636   0.001203   0.520839 (  0.521449)
subquery + in_batches + pluck + insert_all                     2.149521   0.005399   2.154920 (  2.155232)
```

以下の順で早く処理完了することが分かりました。

- `in_batches`
- `find_in_batches`
- `find_each`
- `subquery + in_batches`
- `find_each + create`

何回か実行してみたところ `in_batches` と `find_in_batches` の順番が前後することがありましたが、
おおむね `in_batches + pluck + insert_all` が**最も爆速**であると言えそうです。
ただ、`ActiveRecord::Batches`の3つに関してはどれも誤差の範囲内って感じでした。
条件の内容によって使い分けると良さそうです。

ちなみに、`User`のダミーカラムを無くすと `find_each` が最も高速になりました。
それだけActiveRecordのオブジェクト生成コストが影響しているということですね。

## まとめ

- 大量データ投入は**insert_allを使うと爆速で終わる**
- insert_all用データ作成には `in_batches + pluck + insert_all` が最も柔軟に対応できる。だいたいのケースで**最も爆速**
- 今回は計測してないが、pluckによってActiveRecordオブジェクト生成抑制できてるのでメモリ使用量も低いはず
- サブクエリ + in_batches で理論上はどんな複雑なリレーション条件でも書ける。ただし、性能は落ちる
    - それでも `find_each + create` より**だいぶ爆速**
- 各modelが持つメソッドを使う必要がある場合は `find_each` でよい
- なんでも早ければいいとは限らない。DBに負荷かけないようにインターバルでsleepした方が良い場合もある。
- **爆速**っていっぱい書けて満足

[Increments × cyma (Ateam Inc.) Advent Calendar 2020](https://qiita.com/advent-calendar/2020/increments-cyma) の16日目は、株式会社エイチーム EC事業本部の@shimura_atsushi がお送りします！！ :qiitan: 

## 参考

- [Rails6.0におけるbulk insert - Qiita](https://qiita.com/taiteam/items/1b1be0578d1dc6e00a17)