趣味プログラミングの題材としてごみの収集日を調べるアプリケーションを作りたいと思っています。
今回使用するデータは収集日の日付、曜日、収集区域の名称と収集品目のIDです。 以下のschemeのテーブルとモデルがあると想定してください。
create_table "collection_dates", force: :cascade do |t|
t.date "date", null: false
t.string "weekday", null: false
t.bigint "collection_area_id", null: false
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["collection_area_id"], name: "index_collection_dates_on_collection_area_id"
end
NOTE: 書いていて気づいたのですが今回の投稿では収集品目のIDが出てきません。単純に実装漏れです。
日付,曜日,中央区1,中央区2,中央区3,中央区4,中央区5,中央区6,豊平区1,豊平区2,豊平区3,豊平区4,清田区1,清田区2,北区1,北区2,北区3,北区4,北区5,北区6,東区1,東区2,東区3,東区4,東区5,東区6,白石区1,白石区2,白石区3,白石区4,厚別区1,厚別区2,厚別区3,厚別区4,南区1,南区2,南区3,南区4,南区5,南区6,南区7,西区1,西区2,西区3,西区4,手稲区1,手稲区2,手稲区3
2017-10-02,月,1,1,1,10,9,8,10,8,9,1,1,1,1,1,1,10,8,9,1,1,1,10,8,9,9,2,8,1,1,1,1,1,1,1,1,11,9,9,8,1,1,1,9,8,11,8
2017-10-03,火,8,10,9,1,1,1,1,1,1,10,9,8,9,10,8,1,1,1,10,8,9,1,1,1,1,1,1,8,9,9,8,2,9,11,8,1,1,1,1,9,11,8,1,1,1,1
2017-10-04,水,10,9,8,9,8,10,9,10,8,8,10,9,10,8,9,9,10,8,9,10,8,9,10,8,2,8,9,2,8,2,2,9,8,9,11,8,8,11,9,8,9,11,11,9,8,11
2017-10-05,木,1,1,1,8,10,9,8,9,10,1,1,1,1,1,1,8,9,10,1,1,1,8,9,10,8,9,2,1,1,1,1,1,1,1,1,9,11,8,11,1,1,1,8,11,9,9
2017-10-06,金,9,8,10,1,1,1,1,1,1,9,8,10,8,9,10,1,1,1,8,9,10,1,1,1,1,1,1,9,2,8,9,8,11,8,9,1,1,1,1,11,8,9,1,1,1,1
CSVファイルの中身を拝借しているのですが、こんな具合で日付がずらっと並んでいて収集区域の名称はDBに入っている値をselect
しようと思っています。
一番最初は収集区域毎にfor
文で回した場合の実装方法です。
garvage_collection_csv.each do |row|
date = row[0]
weekday = row[1]
row.headers[2..].each do |name|
collection_area = CollectionArea.find_by_name(name)
CollectionDate.create(
date: date,
weekday: weekday,
collection_area: collection_area,
)
end
end
実行してみると計測不能なくらい遅かったです。
日付の行は全部で738行に対して、収集区域は46箇所あるので33,948回select
文とinsert
文が実行されているわけです。
というわけでRubyのgroup_by
を使って収集区域別に日付をグループ化して一気にinsert
する方法へ書き換えてみます。
collection_dates = []
garvage_collection_csv.each do |row|
date = row[0]
weekday = row[1]
row.headers[2..].each do |name|
collection_dates.push({ date: date, weekday: weekday, name: name })
end
end
grouped_dates = collection_dates.group_by { |item| item[:name] }
grouped_dates.each do |name, collection_dates|
collection_area = CollectionArea.find_by_name(name)
ActiveRecord::Base.transaction do
collection_dates.each do |item|
CollectionDate.create(
date: item[:date],
weekday: item[:weekday],
collection_area: collection_area,
)
end
end
end
以前Railsで複数行をcreate
(insert
)するときにトランザクションを使うと処理が早くなるということを教えてもらったことがありました。
上記コードを実行した結果がこちら:
real 0m52.246s
user 0m39.794s
sys 0m1.269s
実際にはDBの初期化からdb:seed
までを行っているので純粋なinsert
の時間ではないのですが、およそ1分かからないくらいでした。
ログの一部を抜粋してみると:
CollectionDate Create (0.3ms) INSERT INTO "collection_dates" ("date", "weekday", "collection_area_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["date", "2018-01-05"], ["weekday", "金"], ["collection_area_id", 25], ["created_at", "2022-05-20 12:24:29.341672"], ["updated_at", "2022-05-20 12:24:29.341672"]]
CollectionDate Create (0.3ms) INSERT INTO "collection_dates" ("date", "weekday", "collection_area_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["date", "2018-01-06"], ["weekday", "土"], ["collection_area_id", 25], ["created_at", "2022-05-20 12:24:29.342861"], ["updated_at", "2022-05-20 12:24:29.342861"]]
CollectionDate Create (0.3ms) INSERT INTO "collection_dates" ("date", "weekday", "collection_area_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["date", "2018-01-07"], ["weekday", "日"], ["collection_area_id", 25], ["created_at", "2022-05-20 12:24:29.344034"], ["updated_at", "2022-05-20 12:24:29.344034"]]
こんな感じのログになっていました。
transaction
のブロックの位置はあまり関係ないようで、ループ全体を囲っても実行結果に大きな差は出ませんでした。
grouped_dates.each do |name, collection_dates|
collection_area = CollectionArea.find_by_name(name)
collection_dates.each do |item|
CollectionDate.create(
date: item[:date],
weekday: item[:weekday],
collection_area: collection_area,
)
end
end
それではtransaction
を使わない場合はどうなるか試してみたところ、およそ倍以上遅くなってしまいました:
real 2m27.348s
user 1m35.721s
sys 0m3.686s
これは大量のSQLの実行はトランザクションで高速化されるのかという仮説を立ててみたのですが、ログを見直してみて気づきました:
TRANSACTION (0.2ms) BEGIN
CollectionDate Create (0.4ms) INSERT INTO "collection_dates" ("date", "weekday", "collection_area_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["date", "2017-10-02"], ["weekday", "月"], ["collection_area_id", 1], ["created_at", "2022-05-20 12:28:15.006910"], ["updated_at", "2022-05-20 12:28:15.006910"]]
TRANSACTION (1.0ms) COMMIT
TRANSACTION (0.2ms) BEGIN
CollectionDate Create (0.4ms) INSERT INTO "collection_dates" ("date", "weekday", "collection_area_id", "created_at", "updated_at") VALUES ($1, $2, $3, $4, $5) RETURNING "id" [["date", "2017-10-03"], ["weekday", "火"], ["collection_area_id", 1], ["created_at", "2022-05-20 12:28:15.011202"], ["updated_at", "2022-05-20 12:28:15.011202"]]
TRANSACTION (1.0ms) COMMIT
insert
とは別にtransaction
の開始とコミットでおよそ1.2msかかっているようでした。
つまりRailsで複数のinsert
を行うときはActiveRecord::Base.transaction
のブロックを囲うことで内部のtransaction
の生成を抑えることができて高速化されたように見えたわけ。
ところでRails 6にはinsert_all
というメソッドが追加されたらしいです。
もともとリンクの記事にも言及があるactiverecord-importというgemを使うと配列をいい感じにひとつのinsert
文に書き換えてくれるようなのですが、gemを使わなくてもよくなったので早速書き換えてみました。
grouped_dates.each do |name, collection_dates|
collection_area_id = CollectionArea.find_by_name(name).id
collection_dates = collection_dates.map do |item|
{
date: item[:date],
weekday: item[:weekday],
collection_area_id: collection_area_id,
created_at: Time.current,
updated_at: Time.current,
}
end
CollectionDate.insert_all(collection_dates)
end
注意点としてはcreate
とは異なり、純粋なSQLに近いのでリレーションのIDやタイムスタンプは明示的に指定しなければならないようです。
Rails 7のinsert_all
であればタイムスタンプは自動的にinsert
してくれるようです。
上記の実行結果です:
real 0m7.766s
user 0m5.406s
sys 0m0.209s
1分近くかかっていた操作がわずか10秒近くまで早くなればこれ以上言うことはありませんよね。
このコードに対してtransaction
の有無は実行結果に影響はありませんでした。
今回はRailsでまとまったレコードの挿入にはぜひ使いたいinsert_all
でしたが、配列に変換しないといけないので若干使い勝手がよくありません。
しかし、実行結果は劇的に改善するので特に大量のデータは威力を発揮するのでぜひ忘れたくないものです。