Railsで大量データINSERT(Activerecord-Import)
はじめに
Ruby on Railsで大量のデータをInsertする際に、どのような方法があるかを調べ、簡単に速度計測をしてみました。
前提条件
環境
ruby 3.2.3
Rails 7.1.3
PostgreSQL 15.5
やりたいこと
- Taskが複数のSubTaskを持つ状態を想定します。
- テスト環境構築のため、ActiveJobでTaskのレコードを10000件投入します。
- 1つのTaskは5つのSubTaskを持ち、結果としてSubTaskは50000件投入します。
Taskクラス
migrationファイル
class CreateTasks < ActiveRecord::Migration[7.1]
def change
create_table :tasks, id: :uuid do |t|
t.string :name, null: false, limit: 50
t.string :description, null: false, limit: 250
t.timestamps
end
end
end
modelファイル
class Task < ApplicationRecord
has_many :sub_tasks, dependent: :destroy
validates :name, presence: true, length: { maximum: 50 }
validates :description, presence: true, length: { maximum: 500 }
end
SubTaskクラス
migrationファイル
class CreateSubTasks < ActiveRecord::Migration[7.1]
def change
create_table :sub_tasks, id: :uuid do |t|
t.references :task, type: :uuid, null: false, foreign_key: true
t.string :name, null: false, limit: 50
t.string :description, null: false, limit: 250
t.timestamps
end
end
end
modelファイル
class SubTask < ApplicationRecord
belongs_to :task
validates :name, presence: true, length: { maximum: 50 }
validates :description, presence: true, length: { maximum: 500 }
end
ActiveRecordのcreate!を使う
まずは愚直に1件ずつモデル.create!
していくパターンです。
TasksCreateJob
というジョブを定義して、Railsコンソールで実行したいと思います。
TasksCreateJobを用意
class TasksCreateJob < ApplicationJob
queue_as :default
def perform(task_num, sub_per_task_num)
ActiveRecord::Base.transaction do
task_num.times do |i|
task = Task.create!(name: "Task #{i}", description: "Description #{i}")
sub_per_task_num.times do |j|
task.sub_tasks.create!(name: "SubTask #{i}-#{j}", description: "Description #{i}-#{j}")
end
end
end
end
end
Railsコンソールで実行
irb(main):001> TasksCreateJob.perform_now(10000, 5)
:
:
:
Performed TasksCreateJob (Job ID: xxx) from Async(default) in 393881.38ms
コンソールの出力は省略しましたが、1件ずつINSERT文が発行され、とんでもない行数になっていました。100件程度なら許容できるかしれませんが、10000件のデータ投入ともなるとちょっとこのやり方は避けたいですよね。時間も私の環境だと約393秒(393881.38ms)かかっており、どうにかしたいです。
実験のためTasksTruncateJobを用意
ここで次の実験をする前にtasksテーブルとsub_tasksテーブルを全レコードを削除するTasksTruncateJob
も用意しておきます。色々やり方はあると思いますが、今回はTRUNCATEにCASCADEをつけたSQL分をActiveRecord::Base.connection.execute
で実行することとします。
class TasksTruncateJob < ApplicationJob
queue_as :default
def perform()
ActiveRecord::Base.connection.execute("TRUNCATE TABLE tasks CASCADE")
end
end
irb(main):001> Task.count # 10000
irb(main):002> SubTask.count # 50000
irb(main):003> TasksTruncateJob.perform_now
irb(main):004> Task.count # 0
irb(main):005> SubTask.count # 0
これでこれから行う実験も、最初にTasksCreateJob
を実行した時と同じ条件で行えるようになりました。
ActiveRecordのinsert_all!を使う
1件ずつcreate!をしてしまうとINSERT文が1件ずつ発行されてしまうのでとんでもない時間がかかってしまうのでした。なので次は1回のINSERT文で済ますためにモデル.insert_all!
としてみたいと思います。
TasksInsertAllJob
というジョブを定義して、Railsコンソールで実行したいと思います。
TasksInsertAllJobを用意
class TasksInsertAllJob < ApplicationJob
queue_as :default
def perform(task_num, sub_per_task_num)
ActiveRecord::Base.transaction do
tasks = []
sub_tasks = []
task_num.times do |i|
uuid = SecureRandom.uuid
task = { id: uuid, name: "Task #{i + 1}", description: "Description #{i + 1}" }
tasks << task
sub_per_task_num.times do |j|
sub_task = { task_id: uuid, name: "SubTask #{i + 1}-#{j + 1}", description: "Description #{i + 1}-#{j + 1}" }
sub_tasks << sub_task
end
end
Task.insert_all(tasks)
SubTask.insert_all(sub_tasks)
end
end
end
Railsコンソールで実行
irb(main):001> TasksInsertAllJob.perform_now(10000, 5)
:
:
:
Performed TasksInsertAllJob (Job ID: xxx) from Async(default) in 7781.3ms
コンソールの出力は省略しましたが、10000件のTaskのレコードを投入するINSERT文と、その後に50000件のレコードを投入するINSERT文の計2回の発行で済みました。時間も約8秒(7781.3ms)ということでだいぶ短縮されていることがわかります。
insert_all!での問題点
時間も短縮できたということで「insert_all!
を使えばいいね、めでたしめでたし」となれば良いのですが、insert_all!
にはいくつか弱点がありそうです。
validationをしない
配列を渡して最後にまとめてINSERTをするため、validationは行われないです。
tasks = [
{ name: "Task", description: "Description" },
{ name: "Task looooooooooooooooooooooooooooooooooooooooooooooooooog name", description: "Description" },
]
Task.insert_all!(tasks)
例えば上記のようなコードを実行するとActiveRecord::ValueTooLong(PG::StringDataRightTruncation: ERROR: value too long for type character varying(50))
というエラーとなり、validationが行われずDBに定義したカラムの最大文字数を超えたことを検知していることがわかります。
仮に、(あんまりないとは思いますが)DBで定義したカラムの最大文字数limit: 50
よりも小さい数字でRails側でvalidationlength: { maximum: 10 }
をしていたような場合には、Railsで定義したvalidationを素通りしてしまい、50文字以下であればレコードを作成できるということになってしまいます。
ちゃんとDB定義とモデルのvalidationが一致しているのだとしても、文字数が超過している時にはちゃんとActiveRecord::RecordInvalid
を返したいケースはありそうです。
activerecord-importのimport!を使う
では、validationをしたい場合には1件ずつcreate!
しないといけないのでしょうか?
安心してください。実はinsert_all!
のvalidationは行われないという問題点を容易に解決できるactiverecord-importというgemがあります。
Railsの標準ではないので、下記の一行をGemfileに追記してbundle install
を実行する必要があります。
gem "activerecord-import"
これで、モデル.import!
を使えるようになります。
TasksImportJob
というジョブを定義して、Railsコンソールで実行したいと思います。
TasksImportJobを用意
class TasksImportJob < ApplicationJob
queue_as :default
def perform(task_num, sub_per_task_num)
ActiveRecord::Base.transaction do
tasks = []
task_num.times do |i|
task = Task.new(name: "Task #{i + 1}", description: "Description #{i + 1}")
sub_per_task_num.times do |j|
task.sub_tasks.build(name: "SubTask #{i + 1}-#{j + 1}", description: "Description #{i + 1}-#{j + 1}")
end
tasks << task
end
Task.import tasks, recursive: true
end
end
end
Railsコンソールで実行
irb(main):001> TasksImportJob.perform_now(10000,5)q
:
:
:
Performed TasksImportJob (Job ID: xxx) from Async(default) in 29028.74ms
今回もコンソールの出力は省略しますが、insert_all!
の時と同様に10000件のTaskのレコードを投入するINSERT文と、その後に50000件のレコードを投入するINSERT文の計2回の発行で済みました。時間は、1件ずつcreateしていた時の約393秒と比べると1/13程度に削減できていることがわかります。insert_all!
のときの約8秒よりも長い約29秒(29028.74ms)ではありますが、これはvalidationを行なっている関係もあるので、そこよりは高速にはならないということかなと思います。
ちなみに、モデルのvalidationで定義した文字数を超過するレコードを登録しようとするとActiveRecord::RecordInvalid
がしっかり返ってきました。
まとめ
細かい仕様や使い分けについてはもちろん「公式ドキュメントを読み込みましょう」という話になりますが、今回の速度計測の実験だけで言うと下記の表のようなことが言えそうです。
方法 | 速度 | 特徴 |
---|---|---|
create! | 遅い | 1件ずつINSERT文が発行される |
insert_all! | 速い | バリデーションが行われない |
activerecord-import | 比較的速い | INSERT文はモデルごとにまとめられ、バリデーションも行われる |
上手く使いこなして、大量のデータ登録を最適化させたいですね!
Discussion