ActiveRecordとメモリ使用量
この記事は2025 ZAICO アドベントカレンダーの8日目の記事です。
つい先日、バッチ処理を実行しているAWS Lambdaのインスタンスがメモリオーバーで強制終了されてしまうという現象に遭遇しました。Lambda内ではRailsで処理を行っているのですが、どうやら処理の中で大規模なActiveRecordの生成が行われてしまい、それによって大量のメモリが消費されてしまったことが原因のようでした。
今回の対応をきっかけに以前から気になっていたActiveRecordのメモリ使用量について具体的に調べてみました。
ActiveRecordってどのくらいメモリを消費するの?
「1000件を超えたあたりからfind_eachなどで分割して処理したほうがよさそう」というなんとなくのイメージはありますが、何件で何MBぐらいになるのかという具体的な数値までは確認したことがありませんでした。テーブルの構造や実際に保存されている値によってもサイズは異なってくるので、明確に何件で何MB、何件以上は危険とは決まらないと思いますが、目安として大量のActiveRecordを生成することでどれくらいメモリを消費するものなのかを知っておきたく、実際にメモリの消費量を調べてみました。
1. 計算で求めてみる?
以下のように計算して、メモリ使用量の概算値を算出できないか試してみました。
(1件のActiveRecordオブジェクトのサイズ)×(件数)=(メモリ使用量)
可能であれば大量のテストデータなどを用意せずに、計算で概算値を求められたらよかったのですが、結果から言うとこの方法では駄目そうでした。
実際にObjectSpace.memsize_ofを使用することで、オブジェクトのサイズを算出してみました。
require 'objspace'
record = Model.first
ObjectSpace.memsize_of(record) # 130~140
試しに算出してみたところ、recordのサイズは130~140byteしかありませんでした。仮に10万件分のオブジェクトを生成したとしても13~14MBにしかならない計算になってしまいます。本当にこの程度しかメモリが消費されないのだとすると10万件を一気に取り扱っても大してメモリは圧迫されないという結論になってしまいます。しかし、直感的に10万件のActiveRecordの生成は危険という認識だったので、この計算は実態と合っていないのではないかと思いました。
2. 実際にActiveRecordを生成してみる?
もし「ActiveRecordのオブジェクト自体の容量」以外にも、プロセスの中で何かしらのメモリ消費がされているのだとしたら、前述の計算では参考にならないような気がします。そのため、実際にローカル環境にテストデータを使ってActiveRecordを生成し、メモリ使用量の変化を観察してみることにしました。
実験の手順
- ローカル環境に20万件分のテストデータを用意
-
dockerのstatsコマンドでコンテナのメモリ使用量(MEM USAGE)を表示 -
rails consoleを起動する - console上でActiveRecordを生成する処理を実行する
- 4の処理の前後で
MEM USAGEがどれだけ変化するか観察する
今回は5で観察された変化量をActiveRecordの生成にかかったメモリ使用量と考えることにしました。(コンテナ全体のメモリ状況なので他の要因も考えられますが、概算としてこちらを利用しました)
実験の結果
| ActiveRecordの件数 | MEM USAGEの変化量(MiB) |
|---|---|
| 1000 | 3 |
| 5000 | 6 |
| 10000 | 25 |
| 50000 | 180 |
| 100000 | 360 |
| 200000 | 800 |
先ほどは10万件で13~14MBの計算でしたが、実際に計測してみると約360MBという結果になりました。(全然違いました)
仮にサーバ(Lambdaなど)のメモリ上限が512~1024MBくらいに設定されていた場合、オブジェクトの生成だけで360MBも一気にメモリが消費されるのは大分危険なのではないかと思われます。最悪な場合、サーバが落ちる可能性も十分考えられます。
大量のActiveRecordは生成しないようにする
実験の結果より、油断して大規模なActiveRecordを生成してしまうと一気にメモリが数百MBのレベルで消費されてしまう可能性があることが確認できました。アプリ側でのメモリの消費を抑えるためにも同時に大量のActiveRecordは生成しないように心がける必要があります。
最後に自分がよく利用するメモリ節約の方法をご紹介して終わりたいと思います。
1. find_eachで分割して処理する
1000件ずつなどに分割することで同時に大量のオブジェクトがメモリ上に展開されることを防ぎます。
records = Model.all
records.find_each do |record|
# 具体的な処理
# 次のループに入ったら、前の1000件をメモリから破棄して次の1000件を取得
end
2. selectで必要なカラムだけに絞る
特にカラム数が多いテーブルなどの場合に有効です。常に全カラムの内容が必要なわけではないと思うので、必要なカラムだけに絞って取得することでメモリ消費を抑えることができます。
# IDと名前だけ必要な場合
records = Model.all.select(:id, :name)
3. 不要なincludesやeager_loadはしない
関連テーブルの先行読込や結合は必要な場合だけ活用するようにします。どちらかというと不要になった場合に消し忘れてしまうようなケースが多いかもしれませんが、bulletなどのGemを入れておくとN+1クエリやeager_loadの警告などを出してくれるので便利です。
※Railsのプロジェクトではrubocop, rails_best_practices, bullet, brakemanあたりを入れておくとよいかもしれません。
4. pluckを使って配列として扱う
DBの値だけが欲しい場合(更新や削除などをActiveRecord経由で行わない場合)はpluckで配列として扱うという方法もあります。
Model.all.select(:id, :name)
# => [#<Model:xxxxxxxxxx id: 1, name: "名前1">,
# #<Model:xxxxxxxxxx id: 1, name: "名前2">,
# ...]
Model.all.pluck(:id, :name)
# => [[1, "名前1"],
# [2, "名前2"],
# ...]
ActiveRecordを生成しないのでメモリ使用量を抑えられる場合があります。
データエクスポートなどを高速化するための方法として利用したりします。
5. countやlengthなどを使い分ける
countとlengthはどちらもデータの件数を返すメソッドですが、内部処理が異なります。
countの場合はDB側でCOUNT関数を使用してレコードの件数を数えます。そして、数えた結果の件数(Integer型)だけがRuby側に返却されます。そのため、ActiveRecordの生成は行われません。
Model.count
# => SELECT COUNT(*) FROM models
一方lengthの場合はArrayのlengthと同じようにRuby側でオブジェクトの要素を数えるような動きになります。オブジェクトが既に読み込み済みの場合はよいのですが、読み込まれていない場合はActiveRecordとして生成してから要素数を数えようとします。
Model.length
# => SELECT * FROM models
大規模なデータをlengthで数えようとすると、その分のActiveRecordが生成されてしまうのでメモリ消費が激しくなる可能性が高いです。
lengthは既に読み込み済み、または読み込んだ内容を他にも使用する可能性がある場合にクエリの発行回数を抑えるために使用するケースが多いです。一方で同時に読み込むにはレコード数が多すぎるような場合はcountを利用してDB側で数えた方がよいと思います。またはsizeメソッドを使って読み込み状況に合わせて処理を分けるという方法もあります。
おわりに
今回の検証でActiveRecordで消費される具体的なメモリ容量を実感することが出来ました。今後もRailsで大規模なデータを扱う場合はActiveRecordの扱いに注意するようにしたいと思います。
次回の担当はsakiadachiさんです。ぜひお楽しみに。
Discussion