6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

active_job / delayed_job を高速化するハック

Posted at

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_argumentsdeserialize_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_argumentsdeserialize_arguments
private メソッドなので 、将来変更があるかもしれず、できれば使わない方がよさそうです。

6
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?