Increments × cyma (Ateam Inc.) Advent Calendar 2020 の15日目は、
Increments株式会社 プロダクト開発グループ Qiita開発チームの @mishiwata1015 が担当します!
はじめに
みなさん、大量データの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
以下のスクリプトでベンチマークを取ってみました。
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
実行結果は以下の通りです。
$ 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 の方がだいぶ爆速ですね
何回か実行してみましたがほぼ同じ感じでした。
insert_all 使用時の注意点
insert_all を使う場合、created_at, updated_atは明示的に指定する必要があります。
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::Relation
をto_a
したモノに対する処理をブロックで渡せる
-
-
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 を実行します。(これでも単純ですが・・)
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
実行結果は以下の通りです。
$ 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 の16日目は、株式会社エイチーム EC事業本部の@shimura_atsushi がお送りします!!