メモリ消費を抑えた大量データ取り扱い方法について
Railsでメモリを意識してコードを書いていますでしょうか?
RailsではRubyのガベージコレクション※1を利用しているため、メモリ解放などを気にすることなくコードを書くことが出来ます。
※1 Rubyは使用されなくなったオブジェクトを回収し、自動的にメモリを解放します。
その為、いつの間にかメモリをクソほど食う実装をしているにも関わらず、その事に気づかずに本番サーバーが急に落ちる(メモリエラーで)といった非常事態になるケースがあります。
なぜそんなことが言えるかというと、僕が今働いている現場でこの現象が起きたからですw
実装修正の担当が自分になり修正したのですが、その経験で多くを学ぶことが出来たので忘れないためにもメモ残します。
原因調査
まずはそもそもどこで、メモリエラーになっているのかを調査しなければなりません。
Railsでメモリ使用量の調査をする為にObjectSpace.memsize_of_all
を利用しました。
このメソッドを利用することで、すべての生存しているオブジェクトが消費しているメモリ使用量をバイト単位で調査することが出来ます。
このメソッドを実行処理が落ちそうな箇所にチェックポイントとして設置し、どこでメモリを大量消費しているのかを地道に調べていきます。
■ メモリ使用量を調べる使用例
class Hoge
def self.hoge
puts 'mapでメモリ展開される前のオブジェクトメモリ数'
puts '↓'
puts ObjectSpace.memsize_of_all <==== チェックポイント
array = ('a'..'z').to_a
array.map do |item| <==== ①
puts "#{item}のオブジェクトメモリ数"
puts '↓'
puts ObjectSpace.memsize_of_all <==== チェックポイント
item.upcase
end
end
end
■ 実行結果
irb(main):001:0> Hoge.hoge
mapでメモリ展開される前のオブジェクトメモリ数
↓
137789340561
aのオブジェクトメモリ数
↓
137789342473
bのオブジェクトメモリ数
↓
137789342761
cのオブジェクトメモリ数
↓
137789343049
dのオブジェクトメモリ数
↓
137789343337
eのオブジェクトメモリ数
↓
137789343625
.
.
.
xのオブジェクトメモリ数
↓
137789349097
yのオブジェクトメモリ数
↓
137789349385
zのオブジェクトメモリ数
↓
137789349673
=> ["A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z"]
この実行結果からまずmapで渡されるデータを一気にメモリ展開しており、そこでメモリ消費量が増えたことがわかります。(①の箇所)
さらにループ処理される度にメモリ消費量が増えていることもわかります。
今回のサンプルコードのように処理が単純である場合は特に問題ありませんが。
渡されるデータが大量であり、なおかつループ処理で行う実装が複雑であればメモリ消費量が圧迫されて。
メモリーエラー(メモリ処理が追いつかなくなった場合に起こるエラー)になってしまいます。
今回の調査も以上の手順で調べました、その結果、渡されるデータが大量であり尚且つmapでクエリを吐きまくる重い処理を実装していたのでメモリエラーになったという結論になりました。
対策方法
原因はわかりました。
次に対策方法について考えましょう。
最初に考えついた対策は以下の3つです。
1. 金の力でメモリ増やす
2. Thread(スレッド)で並行処理にする
3. バッチ処理にする
1. 金の力でメモリ増やす
正直これが一番早い、お金の力でサーバーのメモリスペックを上げれば済むことなのでこれにしよう!
っと、思ったのですが。
この処理以外にメモリ負担の大きい実装はないので、この箇所のためだけにお金をかけるのは馬鹿らしいなと思いこの案はやめました。
2. Thread(スレッド)で並行処理にする
次の対策方法としてRubyの並行処理を考えついたのですが、ボトルネックが処理時間(timeout)の場合、複数スレッドを立てて並列して計算させてマージすると早くなるので正しいのですが、今回はメモリエラーでボトルネックがメモリ圧迫なので、複数スレッドにしようが扱うデータ量は変わらないので結局メモリエラーになると想定されるので、この案はやめました。
3. バッチ処理にする
今回のメモリエラーになる最大の原因は大量のデーターを一気にメモリ展開して、ループで高負荷処理を繰り返すことが原因で発生するメモリエラーです。
なので、大量データを一気にメモリ展開せずにバッチ処理で1,000件単位などに分けて実装すればメモリを節約しながら実装できるので良いのではないかと考えました。
Railsではfind_in_batches
というメソッドが準備されており、こちらを利用するとデフォルトで1000件ずつ処理を実施することができます。
例)10,000件の場合は1,000件の処理に分けて10回のバッチ処理に分ける。
find_in_batchesで制限をかけて処理することで利用するメモリを少なくするイメージ。
結論
find_in_batchesを利用してバッチ処理にする
実装
対策方法がわかれば、あとは実装あるのみです。
実装していきましょう。(実際に会社のコードを見せる事はできないのでイメージだけ記載します)
■ 実装イメージ
User.find_in_batches(batch_size: 1000) do |users|
# なんか処理
end
もし仮にUserデータが10,000件取得されたとしても、find_in_batchesを利用すれば1000ずつ処理されます。
つまり、10,000 / 1000 = 10回の処理に分けるイメージです。
結果
メモリ消費量が1/100になりました。
もっと良くするためのアイデア
ただ、この実装の最大のデメリットは、処理時間がかかりすぎるポイントです。
herokuなどを利用している場合はこの実装ではRequestTimeOutエラー※1になります。
※1 herokuでは30秒以上かかる処理はRequestTimeOutエラーになる仕様
ですので、この高負荷処理がかかる実装はバックグランド処理に移動させるのがベターと考えます。
Railsを利用している場合はSidekiqなどを利用すれば実現可能です。
以下のような手順で作業すれば、良いと思います。
STEP1. find_in_batchesを利用してメモリ消費量を抑える
STEP2. STEP1が完了した段階で、時間はかかるけどメモリエラーにならずに動く状態になってるはず。
ただ、処理に時間がかかるのでその処理をバックグランドに移動させる
まとめ
最初は、めんどくさそうなタスクだな〜と思ってたのですが。
学びが多く、今思うと実装してよかったです。
参考
https://techblog.lclco.com/entry/2019/07/31/180000
https://qiita.com/kinushu/items/a2ec4078410284b9856d