TL;DR
- active_job で
perform_later
に大きな配列を渡すと、ジョブ登録にとても時間がかかる - delayed_job はジョブの引数をYAMLで保存するが
.to_yaml
は大きな配列ではとても遅い -
serialize_arguments
をオーバーライドして、高速化できる
実際に起きた問題
「CSVファイルの中身をDBに投入する画面」で、CSVファイルの送信に時間がかかりすぎる という問題が起きました。
イメージ的には、こんなコントローラーで実装できる画面です。DBへのレコード追加(User.create!
)がやや重い処理なので、ActiveJobのジョブに分けています。
class CsvFormController < ApplicationController
# 「名前・年齢・メールアドレス」のCSVファイルをアップロードして、Userを作れる画面
#
# 太郎,32,taro@example.com
# 二郎,30,jiro@example.com
# 三郎,27,sabu@example.com
def upload
rows = CSV.parse(params[:file].read)
UserUploadJob.perform_later(rows: rows)
end
end
class UserUploadJob < ApplicationJob
def perform(rows:)
User.transaction do
rows.each do |row|
User.create!(name: row[0], age: row[1], email: row[2])
end
end
# 処理が終わったら、メールやSlackで通知
end
end
これで、CSVファイルのアップロードは数秒で完了。重い処理は、バックグラウンドで実行してくれる・・・はず。
ジョブ登録に2分かかる
CSVファイルが1,000行程度のうちは問題なく動きました。
しかし、100万行程度になると アップロード画面の処理に2分以上かかるようになってしまいました。これでは使い物になりません。また、CPUやメモリも大量に消費しているようでした。
stackprof を使い調査したところ、delayed_job の中で呼び出している to_yaml
が遅いことがわかりました。
delayed_job は、ジョブをキューに登録するとき、引数をYAMLにエンコードして保存します。CSVファイルの行数が大きくなると、エンコードするべき引数の配列も大きくなっていたのです。
module Delayed
module Backend
module Base
...
def payload_object=(object)
@payload_object = object
self.handler = object.to_yaml
end
...
end
end
end
.to_yaml
は遅い
.to_yaml
の遅さは、こんな簡単なスクリプトでもわかります。
require 'benchmark'
require 'json'
require 'yaml'
large_array = (1..1_000_000).map { |x| [x, x, x] }
puts '.to_json'
puts Benchmark::CAPTION
puts Benchmark.measure { large_array.to_json }
puts
puts '.to_yaml'
puts Benchmark::CAPTION
puts Benchmark.measure { large_array.to_yaml }
実行結果:
.to_json
user system total real
0.180000 0.030000 0.210000 ( 0.218959)
.to_yaml
user system total real
30.350000 0.890000 31.240000 ( 33.154655)
なんと!! .to_yaml
は .to_json
の 150倍以上も遅い!!
標準ライブラリの実装が悪いのでは?そもそもYAMLを使う必要がないのでは?等々、問題意識はありますがともあれ、.to_yaml
は遅いです。
まっとうな解決策
.to_yaml
が遅いせいで、ジョブの引数に巨大な配列を渡そうとすると、ジョブの登録に時間がかかります。
しかし、そもそもジョブに巨大な引数を渡すのがいけません。CSVファイルは /tmp なり S3 なりに一時保存するようにして、ジョブにはパスだけ与えるようにすればいいのです。
delayed_job はジョブ登録時にジョブの内容をログに出力します。そのため、巨大な引数をジョブに与えるとログに巨大な出力されて、fluentd が詰まるという二次災害も発生します。
ハック
とはいえ、再設計する時間は無いので、応急処置を探します。
色々試したところ、.to_yaml
は巨大な配列の処理は遅いが、巨大な文字列の処理はそれほど遅くないようでした。そこで、.to_yaml
を呼び出す前に配列を文字列に変換する処理を追加します(逆の処理をYAMLをデコードした後にも追加します)。
ジョブの serialize_arguments
と deserialize_arguments
をオーバーライドすればよいようです。
class UserUploadJob
def serialize_arguments(args)
params = args.first.clone
# rows(配列の配列)を、TSV形式の文字列に変換
params[:rows] = params[:rows].map { |r| r.join("\t") }.join("\n")
super([params])
end
def deserialize_arguments(serialized_arguments)
args = super(serialized_arguments)
# TSV形式の文字列を配列の配列に変換
args.first[:entries] = args.first[:entries].lines.map { |l| l.strip.split("\t") }
args
end
補足&感想
このハックを使うとコントローラー側のコードを変えずに、引数のシリアライズ方式を変えることができます。責務を上手く分離できているので、イケてる感じですね。
しかし、serialize_arguments
と deserialize_arguments
は
private メソッドなので 、将来変更があるかもしれず、できれば使わない方がよさそうです。