【Rails】大量データを扱う処理を最適化する

今回はRailsで大量のデータを扱う場合の良い処理、悪い処理のパフォーマンスを比較していきます。
検証環境は以下です。
- マシン:Apple MacBook Pro
- チップ:Apple M3
- メモリ:16GB
- Ruby:3.3.4
- Rails:8.0.2
- DB:PostgreSQL 14.15 (Homebrew)
- 実行環境:ローカル(Docker未使用)
Railsプロジェクトを作成済みの前提で進めていきます。
大容量CSVの読み込み
以下のような列を持つ3万行のCSVファイルを準備してpublicディレクトリ配下にusers.csv
として保存しておきます。
氏名,メールアドレス,電話番号
氏名1,gFa_xR4@test.jp,090-3473-1364
氏名2,G6IJzgAH19@sample.co.jp,090-6704-2237
氏名3,Ru6kp_X8ww@sample.org,090-2477-5567
...
readを使う場合
以下のようにCSV.read
メソッドを使用すると、CSVファイル全体を一度に読み込むことができますが、大規模なファイルに対しては効率が低下します。
csv = CSV.read("public/users.csv", headers: true)
csv.each do |row|
# 処理
end
foreachを使う場合
以下のようにCSV.foreach
メソッドを使用するとCSVファイルを1行ずつ読み込むため、ファイルサイズが大きくなっても安定した効率を維持できます。
CSV.foreach("public/users.csv", headers: true) do |row|
# 処理
end
比較
パフォーマンス測定用のモジュールであるBenchmark
を使ってCSV.read
とCSV.foreach
それぞれを使った場合の比較を行います。
namespace :csv do
task read: :environment do
Benchmark.bm do |x|
x.report("read") do
csv = CSV.read("public/users.csv", headers: true)
csv.each do |row|
# 処理
end
end
x.report("foreach") do
CSV.foreach("public/users.csv", headers: true) do |row|
# 処理
end
end
end
end
end
実行結果
$ rake csv:read
user system total real
read 0.107221 0.002387 0.109608 ( 0.110811)
foreach 0.074999 0.000900 0.075899 ( 0.076095)
3列/3万行のCSVファイルを読み取る場合では、CSV.foreach
を使う方がCSV.read
を使う場合に比べて若干早いかなという感じでした。
今回は速度よりもメモリの使用量を計測する方が適切かもしれません。
大量のデータを保存
先ほどのCSVの内容でデータを保存するためにUserモデルを作成します。
マイグレーションファイル
class CreateUsers < ActiveRecord::Migration[8.0]
def change
create_table :users do |t|
t.string :name
t.string :email
t.string :phone
t.timestamps
end
add_index :users, :email, unique: true
end
end
モデルファイル
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
end
行数分insertする場合
この場合、以下のように1行ずつデータを保存する処理にしてしまうと毎ループごとにDBへのinsert処理が走ってしまい、効率が悪いです。
CSV.foreach("public/users_1000.csv", headers: true) do |row|
name = row["氏名"]
email = row["メールアドレス"]
phone = row["電話番号"]
user = User.new(name: name, email: email, phone: phone)
user.save
end
一括でinsertする場合
ループ内ではDBにアクセスする処理はせず、データを配列にまとめる処理のみ行い、最後にinsert_all
を用いて一括で処理することで大幅に効率をあげることができます。
user_attributes = []
CSV.foreach("public/users_1000.csv", headers: true) do |row|
user_attributes << {
name: row["氏名"],
email: row["メールアドレス"],
phone: row["電話番号"]
}
end
User.insert_all(user_attributes)
比較
毎ループごとにinsertする場合と一括でinsertする場合を比較します。
namespace :users do
task import: :environment do
Benchmark.bm do |x|
User.delete_all
x.report("User.create") do
CSV.foreach("public/users.csv", headers: true) do |row|
name = row["氏名"]
email = row["メールアドレス"]
phone = row["電話番号"]
user = User.new(name: name, email: email, phone: phone)
user.save
end
end
User.delete_all
x.report("User.insert_all") do
user_attributes = []
CSV.foreach("public/users.csv", headers: true) do |row|
user_attributes << {
name: row["氏名"],
email: row["メールアドレス"],
phone: row["電話番号"]
}
end
User.insert_all(user_attributes)
end
end
end
end
実行結果
$ rake users:import
user system total real
User.create 31.516866 1.615482 33.132348 ( 44.601783)
User.insert_all 0.437329 0.011089 0.448418 ( 0.812219)
3列/3万行のデータを保存する場合は、一括でinsertをする方が毎ループごとにinsertする場合に比べて約55倍高速でした。
insert_allを分割した方が良いパターン
insert_allを使って一括でinsertをする場合でも行数や列数がさらに多い場合は分割してinsert_allを実行した方が効率が良いこともあります。
user_attributes = []
CSV.foreach("public/users.csv", headers: true) do |row|
user_attributes << {
name: row["氏名"],
email: row["メールアドレス"],
phone: row["電話番号"]
}
end
user_attributes.each_slice(1000) do |slice|
User.insert_all(slice)
end
以下のように列数を13、行数を90000件まで増やしたCSVファイルで試してみます。
Userモデルのカラムも修正、追加をしています。
性,名,フリガナ(性),フリガナ(名),メールアドレス,電話番号,性別,生年月日,年齢,郵便番号,都道府県,都道府県コード,IPアドレス
性,名,セイ,メイ,KZFeDKsBNF@example.org,090-7451-3388,男,1968/3/2,57,914-4177,埼玉県,11,128.238.9.109
性,名,セイ,メイ,K4xgmAxk@sample.net,080-3039-2473,男,1966/11/29,58,823-4155,三重県,24,85.195.29.76
...
require "csv"
require "benchmark"
namespace :users_13_90000 do
task import: :environment do
Benchmark.bm do |x|
User.delete_all
x.report("User.insert_all") do
user_attributes = []
CSV.foreach("public/users_13_90000.csv", headers: true) do |row|
user_attributes << {
name_sei: row["性"],
name_mei: row["名"],
name_sei_kana: row["フリガナ(性)"],
name_mei_kana: row["フリガナ(名)"],
email: row["メールアドレス"],
phone: row["電話番号"],
gender: row["性別"],
birthdate: row["生年月日"],
age: row["年齢"],
zipcode: row["郵便番号"],
prefecture: row["都道府県"],
prefecture_code: row["都道府県コード"],
ip_address: row["IPアドレス"]
}
end
User.insert_all(user_attributes)
end
User.delete_all
x.report("User.insert_all.each_slice") do
user_attributes = []
CSV.foreach("public/users_13_90000.csv", headers: true) do |row|
user_attributes << {
name_sei: row["性"],
name_mei: row["名"],
name_sei_kana: row["フリガナ(性)"],
name_mei_kana: row["フリガナ(名)"],
email: row["メールアドレス"],
phone: row["電話番号"],
gender: row["性別"],
birthdate: row["生年月日"],
age: row["年齢"],
zipcode: row["郵便番号"],
prefecture: row["都道府県"],
prefecture_code: row["都道府県コード"],
ip_address: row["IPアドレス"]
}
end
user_attributes.each_slice(1000) do |slice|
User.insert_all(slice)
end
end
end
end
end
実行結果
$ rake users_13_90000:import
user system total real
User.insert_all 4.403736 0.112571 4.516307 ( 6.142268)
User.insert_all.each_slice 3.759315 0.074634 3.833949 ( 5.744757)
劇的ではありませんが、若干改善されています。
この辺りは、カラム数、レコード数を見て分割数を調整していく必要がありそうです。
大量のデータを削除
13列/3万行のCSVから作成したUserのレコードを全て削除する場合を見ていきます。
destroy_allを使用する場合
以下のようにdestroy_all
を使用すると対象レコードを1件ずつeachで取り出し、それぞれに対してdestroy
を実行するため、データ量が多い場合には効率が低下します。
User.destroy_all
delete_allを使用する場合
delete_all
では各レコードを呼び出すことなく単一のSQL文で処理が完了するため、大量レコードの場合はdestroy_all
に比べて大幅に効率が向上します。
User.delete_all
比較
destroy_all
とdelete_all
のそれぞれを使った場合を比較します。
require "csv"
require "benchmark"
namespace :users do
task destroy: :environment do
Benchmark.bm do |x|
insert_users
x.report("User.destroy_all") do
User.destroy_all
end
insert_users
x.report("User.delete_all") do
User.delete_all
end
end
end
end
def insert_users
user_attributes = []
CSV.foreach("public/users_13_30000.csv", headers: true) do |row|
user_attributes << {
name_sei: row["性"],
name_mei: row["名"],
name_sei_kana: row["フリガナ(性)"],
name_mei_kana: row["フリガナ(名)"],
email: row["メールアドレス"],
phone: row["電話番号"],
gender: row["性別"],
birthdate: row["生年月日"],
age: row["年齢"],
zipcode: row["郵便番号"],
prefecture: row["都道府県"],
prefecture_code: row["都道府県コード"],
ip_address: row["IPアドレス"]
}
end
User.insert_all(user_attributes)
end
実行結果
user system total real
User.destroy_all 20.248357 1.273361 21.521718 ( 28.356763)
User.delete_all 0.000409 0.000060 0.000469 ( 0.010724)
13カラムのレコードを3万件削除する場合、delete_allを使った方が、destroy_allを使った時に比べて大幅に高速でした。
注意点
仮に以下のようにPostモデルとの関連付けを定義していた場合、User.delete_all
を実行してもPostレコードは削除されません。また、コールバックもdelete_all
では実行されないので注意が必要です。
has_many :posts, dependent: :destroy
もし上記のような関連付けをしている場合は先にPostレコードを削除する必要があります。
Post.delete_all
User.delete_all
まとめ
それぞれのメソッドがどのような処理を行うのかを把握した上でレコード数、カラム数などを考慮して最適な処理を実装する必要があります。
また、バリデーションやコールバックの実行有無も把握した上で使用しないと思わぬ不具合に繋がる恐れもあるので注意が必要です。