こんにちは。横部です。
これは TECHSCORE Advent Calendar 2017 の4日目の記事です。
今回は「RailsでDBからCSVエクスポートする時に高速かつ低負荷な処理方法」についてお話しさせていただきます。
使用したバージョンは以下の通りです。
Ruby : 2.2.4
Rails : 5.1.4
CSV.generate と each でCSV生成
RailsでCSVエクスポート(ダウンロード)機能を実装する際は、以下のような処理を書くことが多いかと思います。
1 2 3 4 5 6 7 8 9 10 11 12 |
csv1 = CSV.generate do |csv| csv << User.column_names User.all.each do |model| csv << model.attributes.values_at(*User.column_names) end end File.open("./User1.csv", 'w') do |file| file.write(csv1) end # ダウンロード stat = File::stat("./User1.csv") send_file("./User1.csv", :filename => "User1.csv", :length => stat.size) |
この処理方法は全件をメモリに展開して処理を行うため、数百件単位の小規模なものであれば問題ありませんが、DBのデータ件数が膨大になるとメモリ不足でエラーになる可能性が潜んでいるコードです。
これを防ぐためには、find_each を使います。
CSV.generate と find_each でCSV生成
find_each を使うことで分割してレコードを取得することが可能になるので、メモリ不足でエラーになる問題が解消されます。
http://railsdoc.com/references/find_each
ただし実行速度の観点からみると、find_each は each よりも多少の遅れを生じさせるようです。
実際に以下のような処理を作成して実行時間の集計を行いました。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
class UsersController < ApplicationController require 'csv' require 'benchmark' def new @count = User.count @result1 = Benchmark.realtime do csv1 = CSV.generate do |csv| csv << User.column_names # ActiveRecordオブジェクトを生成しながら、全データをメモリに展開して処理 User.all.each do |model| csv << model.attributes.values_at(*User.column_names) end end File.open("./User1.csv", 'w') do |file| file.write(csv1) end end @result2 = Benchmark.realtime do csv2 = CSV.generate do |csv| csv << User.column_names # ActiveRecordオブジェクトを生成しながら、10000件ずつデータをメモリに展開して処理 User.find_each(:batch_size => 10000) do |model| csv << model.attributes.values_at(*User.column_names) end end File.open("./User2.csv", 'w') do |file| file.write(csv2) end end end end |
1 2 3 |
件数: <%= @count %>件<br> 結果1: <%= @result1.round(2) %>秒<br> 結果2: <%= @result2.round(2) %>秒<br> |
すると以下のような結果になりました。
各結果は10回実行した場合の平均値です。
1 2 3 |
件数: 100000件 結果1: 11.55秒 結果2: 12.11秒 |
このように find_each は each より若干の遅れが見受けられます。
また、each と find_each は実行の度にActiveRecordオブジェクトを作成しているため、DBのカラムや件数が増えればより負荷が高まり、実行時間は長くなっていきます。
そのため、CSV.generate を使ったエクスポートを行う際は、pluck の利用を推奨します。
CSV.generate と pluck でCSV生成
pluck は実行の度にActiveRecordオブジェクトを生成しないので、より高速に処理を行うことができます。
ただし、pluck 自体は each と同じように全データをメモリに展開して処理してしまうので、limit と offset を活用して負荷がかからないように工夫してます。
http://railsdoc.com/references/pluck
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
@result3 = Benchmark.realtime do csv3 = CSV.generate do |csv| csv << User.column_names pos = 0 # 開始位置 range = 10000 # 範囲 loop do # ActiveRecordオブジェクトを生成せず、10000件ずつデータをメモリに展開して処理 results = User.all.limit(range).offset(pos).pluck(*User.column_names) break if results.empty? pos += range results.each do |result| csv << result end end end File.open("./User3.csv", 'w') do |file| file.write(csv3) end end |
1 |
結果3: <%= @result3.round(2) %>秒<br> |
実際に計測すると以下の結果になりました。
1 |
結果3: 5.49秒 |
each や find_each に比べて倍近くの高速化に成功しており、比較することでActiveRecordオブジェクト生成に大きな負荷がかかっていることがわかります。
ここまでで、CSV.generate を使ったエクスポートの場合は、pluck を使うことで高速化できることを示しました。
これよりもさらに低負荷かつ高速化できる方法が存在します。
SQLでCSV生成
それはSQLで直接CSVを生成することです。
1 2 3 4 5 |
@result4 = Benchmark.realtime do sql = "SELECT * FROM users;" cmd = "sqlite3 -cmd '.headers on' -cmd '.mode csv' -cmd '.output ./User4.csv' db/development.sqlite3 '#{sql}'" system cmd end |
1 |
結果4: <%= @result4.round(2) %>秒<br> |
SQLで直接生成した場合は、pluck を使った場合の約10分の1以下の時間で完了しており、ダントツで早い処理であることが示されました。
1 |
結果4: 0.34秒 |
まとめ
想定される件数が少なく、速度よりもメンテナンス性を重視するのであれば、CSV.generate でCSVを生成する方法が望ましいですが、
件数が数万件単位を超え、メンテナンス性よりも速度を重視する場合は、SQLでCSVを直接生成することが良いと思われます。