41
18

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.

Rails6で追加されたinsert_allとimport(とその他)のパフォーマンス検証

Last updated at Posted at 2019-10-24

前回書いた記事で、activerecord-importとRails6で追加されたinsert_allのパフォーマンスを比べたところ、importの方が速そうだったのでもう少しちゃんと検証してみました。

検証方法

速度検証

1,000件のユーザーを様々な方法でバルクインサートします。
1回のバルクインサートだと数msで終わってしまうので、上記を100回実行したときの合計時間で比較します。
計測はbenchmarkを使用します。

メモリ使用量検証

こちらも同様に1,000件のユーザーを様々な方法でバルクインサートします。
計測はmemory_profilerを使用します。

環境

Ruby: 2.6.5
Rails: 6.0.0
rspec-rails: 3.9
factory_bot_rails: 5.1.1

対象

activerecord-import

Railsでバルクインサートできるようにするもっとも有名なgemではないでしょうか。
生成したモデルの配列を渡すだけで簡単にバルクインサートできます。

users = (1..100).map { User.new(name: 'name') }
User.import users

github
https://github.com/zdennis/activerecord-import

insert_all

Rails6.0.0で追加されたバルクインサート用のメソッド。
ハッシュを渡すことで簡単にバルクインサートできます。
importと異なり、created_atとupdated_atを明示的に指定する必要があるので注意が必要です。

users = (1..100).map { { name: 'name', created_at: Time.current, updated_at: Time.current } }
User.insert_all users

doc
https://edgeapi.rubyonrails.org/classes/ActiveRecord/Persistence/ClassMethods.html#method-i-insert_all

素のクエリー

Railsではクエリーをそのまま実行できるのでクエリーを文字列で生成して実行します。

sql = "INSERT INTO users (name, created_at, updated_at) VALUES ('name', NOW(), NOW()),('name', NOW(), NOW())...;"
ActiveRecord::Base.connection.execute sql

検証

実装

benchmarkで検証するbenchmark_bulk_insertとMemoryProfilerで検証するprofiler_bulk_insertを作りました。
純粋にバルクインサートの処理を検証したかったので、データ作成等はBenchmark外で生成しています。

app/models/user.rb
class User < ApplicationRecord
  class << self
    def benchmark_bulk_insert
      # create data
      import_data = []
      1_000.times { import_data << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      insert_data = []
      1_000.times { insert_data << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      values = []
      1_000.times { values << "('name', '#{Time.current.to_s(:db)}', '#{Time.current.to_s(:db)}')" }
      sql = "INSERT INTO users (name, created_at, updated_at) VALUES #{values.join(',')}"

      require 'benchmark'
      Benchmark.bm 15 do |r|
        transaction do
          r.report 'sql' do
            100.times { bulk_insert_using_sql(sql) }
          end
          raise ActiveRecord::Rollback
        end

        transaction do
          r.report 'insert_all' do
            100.times { bulk_insert_using_insert_all(insert_data) }
          end
          raise ActiveRecord::Rollback
        end

        transaction do
          r.report 'import' do
            100.times { bulk_insert_using_import(import_data) }
          end
          raise ActiveRecord::Rollback
        end
      end
    end

    def profiler_bulk_insert
      # create data
      import_data = []
      1_000.times { import_data << new(name: 'name', created_at: Time.current, updated_at: Time.current) }

      insert_data = []
      1_000.times { insert_data << { name: 'name', created_at: Time.current, updated_at: Time.current } }

      values = []
      1_000.times { values << "('name', '#{Time.current.to_s(:db)}', '#{Time.current.to_s(:db)}')" }
      sql = "INSERT INTO users (name, created_at, updated_at) VALUES #{values.join(',')}"

      p '################# sql ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_sql(sql)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end

      p '################# insert_all ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_insert_all(insert_data)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end

      p '################# import ########################'
      transaction do
        report = MemoryProfiler.report do
          bulk_insert_using_import(import_data)
        end
        report.pretty_print(retained_strings: 0, allocated_strings: 100, normalize_paths: true)
        raise ActiveRecord::Rollback
      end
    end

    def bulk_insert_using_import(users)
      import users
    end

    def bulk_insert_using_insert_all(users)
      insert_all users
    end

    def bulk_insert_using_sql(sql)
      connection.execute sql
    end
  end
end

結果

rails consoleで実行しました。

irb(main):080:0> User.benchmark_bulk_insert;nil
                      user     system      total        real
sql               0.000000   0.010000   0.010000 (  0.601744)
insert_all        8.900000   0.030000   8.930000 (  9.951685)
import           10.870000   0.210000  11.080000 ( 12.255004)

irb(main):080:0> User.profiler_bulk_insert
"################# sql ########################"
Total allocated: 4152 bytes (23 objects)
Total retained:  928 bytes (1 objects)

"################# insert_all ########################"
Total allocated: 4493518 bytes (67974 objects)
Total retained:  145088 bytes (2005 objects)

"################# import ########################"
Total allocated: 7187421 bytes (91961 objects)
Total retained:  1824536 bytes (13654 objects)

まず処理時間を見てimportが速いというのは勘違いだったとわかりました。。。ちゃんと調査しないとダメですね。
メモリー使用量を見ると、処理時間と比例して増えています。importでは処理の過程で様々なオブジェクトを生成しているので他の処理より遅いと思われます。
前回の記事でinsert_allの方が遅かったのはbuild_listで生成したオブジェクトを変換するところも計測に入っていたからですね。
そしてimportやinsert_allよりも素のsqlがパフォーマンス最強ということがわかりました。
オブジェクトを生成すればするほど遅くなるので文字列をDBに投げるだけだとかなりパフォーマンスに差がでますね。
素のsqlで実装すると可読性や生産性が落ちるので乱用はしたくないですが、ここぞというときに使うと良さそうです。

41
18
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
41
18

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?