ファイルを読み込んで長い文字列を処理する方式からIOオブジェクトで処理する方式に変えたら10%高速化した話

株式会社リゾーム システム企画・開発部 第4グループの尾古(@patorash)です。

弊社で扱っているシステムで、複数のgzip圧縮したファイルを読み込んでDBにインポートする処理がありました。今回はそれのリファクタリングを行い、10%ほど速度改善した話です。

リファクタリング前の処理

リファクタリング前は、ファイルの内容を読み込んで変数に入れた後、インポート処理を行っていました。そのインポート処理においても、巨大文字列を1行ずつ読み込んで処理するようにしてありました。

file_path = Rails.root.join('files', 'import_file.jsonl.gz')
contents = Zlib::GzipReader.open(file_path) do |gz|
             gz.read
           end
ImportFile.import_from_string!(contents)

class ImportFile
  class << self

    # 文字列からデータをインポートする
    # @param [String] contents 改行コード区切りのJSON文字列
    # @option [Integer] batch_size 何件毎にインポートするかを指定。デフォルト1,000件。
    def import_from_string!(contents, batch_size: 1_000)
      records = []
      contents.each_line do |line|
        records << JSON.parse(line.chomp)
        if records.size == batch_size
          # activerecord-importを使ってバルクインサート
          import(records, validate: false, batch_size: batch_size)
          records = []
        end
      end
      ImportFile.import(records, validate: false)
    end
  end
end

問題点

まず、最も問題なのは、ファイルの内容を一度に全て読み込んでしまっているところです。今回の処理では、gzip圧縮から解凍した際のファイルサイズは100MBを超えるくらいのものでした。これが100MBどころではなく、もっと大きなファイルであった場合、メモリが足りなくなる可能性があります。

そして、次の問題点は、変数の内容を1行ずつ読み込んでいるところでした。1行ずつ読み込むこと自体はそんなに悪くないのですが、activerecord-importでバルクインサートをするかどうかのif文のチェックが毎回実行されていました。

リファクタリング後の処理

では、こちらをリファクタリングしていきます。

file_path = Rails.root.join('files', 'import_file.jsonl.gz')
io = File.open(file_path, 'r')
Zlib::GzipReader.wrap(io) do |gz|
  ImportFile.import_from_io!(gz)
end

class ImportFile
  class << self

    # IOオブジェクトからデータをインポートする
    # @param [IO] io IOオブジェクト
    # @option [Integer] batch_size 何件毎にインポートするかを指定。デフォルト1,000件。
    def import_from_io!(io, batch_size: 1_000)
      io.each_slice(batch_size) do |lines|
        jsons = lines.map { |line| JSON.parse(line.chomp) }
        # activerecord-importを使ってバルクインサート
        import(jsons, validate: false)
      end
    end
  end
end

変更点

IOオブジェクトを使うようにした

ファイルを一気に読み込むのではなく、IOオブジェクトから読み込むようにしました。

#each_sliceで1,000行ずつ読み込む

#each_lineメソッドで1行ずつ読み込むのをやめて、#each_sliceメソッドを使って、1,000行ずつ読み込むようにしました。1,000行読み込んでいるので、わざわざ1,000件のデータがあるかどうかのチェックは不要になったので、if文は削除できました。また、読み込む量も1,000行分毎で済むため、メモリ使用量も少なくて済みます。

パフォーマンス確認

「推測するな、計測せよ」というわけで、benchmark-ipsを使ってパフォーマンスの違いを検証しました。圧縮を解凍したら合計140MBくらいのデータで実験しています。

検証環境のスペック

結果

11%も速くなりました!🚀

Warming up --------------------------------------
                  io     1.000  i/100ms
                text     1.000  i/100ms
Calculating -------------------------------------
                  io      0.018  (± 0.0%) i/s -      1.000  in  56.379435s
                text      0.016  (± 0.0%) i/s -      1.000  in  62.673752s

Comparison:
                  io:        0.0 i/s
                text:        0.0 i/s - 1.11x  (± 0.00) slower

かなり効果があったと言えると思います。

まとめ

今回はファイルから一気に読み込んでから処理するのではなく、IOオブジェクトを使うようにリファクタリングしたケースをご紹介しました。

ファイルを一気に読み込むのは割とやってしまいがちなのですが、ファイルサイズが大きいデータが想定される場合はあまり良い方法ではありません。そういう場合はIOオブジェクトを活用しましょう。