🏃

Railsで大量データINSERT(Activerecord-Import)

2024/03/03に公開

はじめに

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ファイル

20240101000000_create_tasks.rb
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ファイル

task.rb
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ファイル

20240101000001_create_sub_tasks.rb
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ファイル

sub_task.rb
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を用意

tasks_create_job.rb
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で実行することとします。

tasks_truncate_job.rb
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を用意

tasks_insert_all_job
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を実行する必要があります。

Gemfile
gem "activerecord-import"

これで、モデル.import!を使えるようになります。
TasksImportJobというジョブを定義して、Railsコンソールで実行したいと思います。

TasksImportJobを用意

tasks_import_job.rb
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