RailsのInsertを高速化する

May 20, 2022

趣味プログラミングの題材としてごみの収集日を調べるアプリケーションを作りたいと思っています。

今回使用するデータは収集日の日付曜日収集区域の名称収集品目の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でしたが、配列に変換しないといけないので若干使い勝手がよくありません。 しかし、実行結果は劇的に改善するので特に大量のデータは威力を発揮するのでぜひ忘れたくないものです。


Profile picture

Personal blog by Seiichi Yonezawa.