前回書いた記事で、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
素のクエリー
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外で生成しています。
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で実装すると可読性や生産性が落ちるので乱用はしたくないですが、ここぞというときに使うと良さそうです。