22
10

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 3 years have passed since last update.

Increments × cyma (Ateam Inc.)Advent Calendar 2020

Day 15

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

Last updated at Posted at 2020-12-14

Increments × cyma (Ateam Inc.) Advent Calendar 2020 の15日目は、
Increments株式会社 プロダクト開発グループ Qiita開発チームの @mishiwata1015 が担当します!:qiitan:

はじめに

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

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

Rails5まではbulk insertのためにactiverecord-importを使うのが主流でした。

Rails6からはActiveRecord標準でbulk insertが行えるようになりました。

Ruby on Rails 6.0 リリースノート - Railsガイド

  • 一括INSERTを行うinsert_all/insert_all!/upsert_allメソッドを追加 (Pull Request)

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

insert_all ってどれくらい早いの?

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

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

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

  • Ruby: 2.6.3
  • Rails: 6.0.3.3

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

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環境用意しなくても検証できるのでこのやり方覚えておくと楽です。)

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

  • 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は明示的に指定する必要があります。

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
    • ActiveRecord::Relation に対する処理をブロックで渡せる
  • find_in_batches
    • ActiveRecord::Relationto_a したモノに対する処理をブロックで渡せる
  • find_each
    • ActiveRecord::Relationto_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人のUserPostを持っている状態にしています。

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

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_batchesfind_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 の16日目は、株式会社エイチーム EC事業本部の@shimura_atsushi がお送りします!! :qiitan:

参考

22
10
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
22
10

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?