Hack Your Design!

Rails/ActiveRecord バッチ処理の最適化

(image)Rails/ActiveRecord バッチ処理の最適化

ということで今日はRailsバッチ処理の最適化について書いてみたいと思います。

検証環境

コードの検証に使った環境は下記の通りです。

  • macOS High Sierra (2.3 GHz Intel Core i5 / メモリ8G)
  • Ruby 2.5
  • Rails 5.1

前提条件

最適化の前提条件としては下記の通りです。

  • バッチはrakeタスクとして実行する
  • 今回、最適化対象とするのは Userモデルのバッチ処理
    • 使用するUserモデルはdeviseで作られるUserモデル(rails generate devise:install)を基本として、そのスキーマ定義にint型のpointカラムをつけたもの
    • pointカラムは登録ユーザーが自由につかえるポイントの意
  • Userデータとして事前に 50万件のユーザーデータ を投入しておく
  • こちらのコードを参考に処理の 実行時間メモリ使用量 を計測する
  • executeなどによる直接SQL実行はせずにDB操作を行う
  • シンプルにするために、登録日 = User.created_atとする
    • Userのタイムゾーンは無いものとする(すべてUTCとして扱う)
  • データベースのトランザクション処理は考慮しない
  • 今回行う最適化の対象はアプリケーションコードでありDBの最適化は考えない

オリジナルコード

さて今回最適化するコードは下記です。処理内容としては 全ユーザーの中から2017年以降の登録ユーザーへ100ポイントを付与する というものです。いかにも販促活動の一環としてありそうな話です(2017年以降のところの条件は別になんでも良かったのですが、処理対象をある程度確保するために今回はそのように設定しました)。

# Task: batch:original
User.all.each do |user|
  if user.created_at >= "2017-01-01"
    user.point += 100
    user.save
  end
end

あなたはこのコードをぱっと見てどこが悪いかすぐにわかりますか?(言うまでもなくこのコードは問題アリアリのコードです!!)

中級者以上のRailsエンジニアであれば「そんなコードは絶対書かないよ!」と思うかもしれませんが、「RailsでWebプログラミングを初めてまだ一ヶ月です!」みたいな初級エンジニアであれば上記のように書いても全然おかしくはないコードだと思います。

ベンチマーク

まずはこの問題のあるコードがどれだけ時間がかかっているかを計測してみましょう。

※ 前提条件で書いた通り、こちらのコードを参考に時間とメモリ使用量を計測します。また結果は数回実施した上で大きく外れていない平均的なスコア結果を掲載します。

$ rake batch:original
Time: 339.42 secs
Memory: 2219.72 MB

実行時間は340秒、メモリ使用量は2200MB程でした。今回の最適化のゴールは この処理時間をできるだけ速くして、あわせてメモリ使用量も抑えることです。

では早速このコードを最適化していきましょう。

最適化1: 簡単な最適化

まずは簡単な最適化から始めましょう。日付の比較はStringを使うよりもDateクラスを使ったほうが速そうです。また、ループの中で何度も同じ値が使われるのも良くないので定数に切り出しちゃいましょう。結果、下記のコードのようになりました。

# Task: batch:improvement1
POINT_DATE = Date.new(2017)

User.all.each do |user|
  if user.created_at >= POINT_DATE
    user.point += 100
    user.save
  end
end

ベンチマーク

さて計測結果です。

$ rake batch:improvement1
Time: 320.0 secs
Memory: 2244.71 MB

メモリ使用量は変わらず、実行時間は10数秒程度速くなったくらいでしょうか。小さな最適化レベルでまだまだ全然速くなったとは言えません。

最適化2: where & each を使う

次はもう少し本格的な最適化を入れていきましょう。

まずはUser.all.eachで全件ユーザーを取得している点が真っ先に気になるところです。これは 全件取得せず2017年以降の登録ユーザーをあらかじめフィルターしてからループさせる ほうが良さそうです。

# Task: batch:improvement2
User.where("created_at >= ?", POINT_DATE).each do |user|
  user.point += 100
  user.save
end

あらかじめ処理対象ユーザーだけをフィルターできているので、ループ内のifも消すことができました。

ベンチマーク

$ rake batch:improvement2
Time: 294.35 secs
Memory: 1623.5 MB

実行時間が前の結果より20秒程改善、メモリ消費もユーザーを全件取得する必要がなくなった分、500MB程空きました。良い感じですね。

最適化3: find_each を使う

ちょっと待って下さい、大量データを一度にロードしなくてもいいように、ActiveRecordがfind_eachという便利メソッドを用意してくれてるのでした。これを使わない手はないでしょう。

# Task: batch:improvement3
User.where("created_at >= ?", POINT_DATE).find_each do |user|
  user.point += 100
  user.save
end

これで少しつづユーザーをロードして処理してくれるようになり、メモリに優しいコードになったと思います。

ベンチマーク

$ rake batch:improvement3
Time: 290.88 secs
Memory: 31.41 MB

実行時間が前の結果と変わらないこそすれ、メモリ使用量は前の結果の50分の一となりました。 大きな改善と言っていいでしょう。

最適化4: in_batches & update_all を使う

ここで一件一件updateが走る点が気になってきました。そこはActiveRecordのupdate_allを使ってまとめて更新するようにしてあげれば解決できそうです。

またupdate_allActiveRecord::Relationのメソッドですが、ActiveRecord::Relationを先のfind_eachのように返してくれる便利メソッドがin_batchesです。

このin_batchesupdate_allを組み合わせて処理してあげれば効率良く更新できそうな気がします。

# Task: batch:improvement4
User.where("created_at >= ?", POINT_DATE).in_batches do |users|
  users.update_all("point = point + 100")
end

ベンチマーク

$ rake batch:improvement4
Time: 2.46 secs
Memory: 7.26 MB

実行時間が100倍速くなりました。 劇的な改善と言っていいでしょう。またメモリの使用量も前の結果よりさらに抑えられています。

最適化5: where & update_all

勘の良い方なら既にお気づきですね。…ハイ、先のコードはin_batchesすら不要です。単純にupdate対象をwhereでフィルターした上でupdate_allすれば良さそうです。出来上がったコードがこちら。

# Task: batch:improvement5
User.where("created_at >= ?", POINT_DATE).update_all("point = point + 100")

一行のシンプルなコードに仕上がりました。

ベンチマーク

$ rake batch:improvement5
Time: 0.78 secs
Memory: 0.82 MB

実行時間は前の結果より3倍早くなり、メモリ使用量もさらに10分の一まで抑えられました。

これを今回の最適化コードの最終形としたいと思います。


追記ここから

kamipoさんからご指摘頂いたとおり、update_allは通常のActiveRecordの更新とは異なりcallback, validationをスキップするという仕様となっております。よってオリジナルコードとは等価な処理では無くなっているので、実際の現場においてはsaveからupdate_allに変更する際は「本当にcallback, validationスキップしても大丈夫なんだっけ?」ということをしっかり考えてから実施するようにしてください。

it does not trigger Active Record callbacks or validations

http://api.rubyonrails.org/classes/ActiveRecord/Relation.html#method-i-update_all

なお今回のコード例ではモデルのcallback, validationをスキップしても問題ないコードとして話を進めています。

加えて、今回データベースの最適化は最適化の範囲外としたのでcreated_atカラムのindexは貼りませんでした。実際の現場においてはRubyのコードレベルの最適化に加えてデータベースの最適化も考えてINDEXを貼ることも検討したほうがいいでしょう。

追記ここまで


最終結果

オリジナルコードと最適化済みの最終コードを比較すると下記の通りの改善が確認できました。

  実行時間 メモリ消費
オリジナルコード 339.42 secs 2219.72 MB
最適化コード 0.78 secs 0.82 MB
改善結果 :rocket: 435倍高速化 :recycle: 約2700分の一まで省メモリ化

「ActiveRecordデータ処理アンチパターン」で発表します

上述したようなオリジナルコードは極端な例ではありますが、ActiveRecordでデータを扱うときはきちんと遅くならないように意識してバッチ処理を書かないと極端に遅くなってしまうケースがあります。

そんなActiveRecordデータ処理で陥りがちな罠をパターン化し今月のRails Develper Meetupにて発表する予定です。ご興味あれば是非。

参考リンク

  • このエントリーをはてなブックマークに追加