ActiveRecordのpreloadで対象のレコードを絞り込んでメモリ使用量を小さくする方法
株式会社COUNTERWORKSでRuby on Railsを採用しているアプリの開発をしているまったんです。
この記事ではActiveRecordのpreloadによるeager loadingで、対象のレコードを絞り込む方法を紹介します。
はじめに
Railsでアプリの開発をしている方はN+1問題の解決にpreloadをよく使うと思います。
preloadでは関連する全てのレコードをeager loadingするため、eager loadingした関連レコード全てを使用するケース以外ではメモリを無駄に消費することになってしまいます。
今回はそういったケースでメモリを無駄に消費しないようにするための方法を書いていきます。
eager_loadとpreloadの使い分けに関しては以下の記事を参考にしてください。
また、この記事で扱う方法はRailsの非公開APIを使用しています。使い方には注意が必要です。
前提条件
今回はコンビニの売上管理サービスを例として説明していきます。
売上は店舗ごとに日次で集計されており、テーブルはstoresとrevenuesの2つがあります。
以下がその図です。
休業などで売上がない日はrevenuesのレコードは作成されません。
このサービスでは以下のような表で店舗の月ごとの日次売上を見ることができるページが存在します。
店舗名 | 1日 | 2日 | 3日 | 4日 | 5日 | 6日 | 7日 | 8日 | 9日 | 10日 | 11日 | 12日 | 13日 | 14日 | 15日 | 16日 | 17日 | 18日 | 19日 | 20日 | 21日 | 22日 | 23日 | 24日 | 25日 | 26日 | 27日 | 28日 | 29日 | 30日 |
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
広尾1丁目店 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 |
広尾2丁目店 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 |
代々木4丁目店 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 | 123123123123 |
... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
コントローラーとビューは以下のような実装になっています。
class StoresController < ApplicationController
def index
@stores = Store.preload(:revenues)
@year = params[:year]
@month = params[:month]
end
end
<table>
<thead>
<tr>
<th>店舗名</th>
<% (1..Time.days_in_month(@month, @year)).each do |day| %>
<th><%= day %></th>
<% end %>
</tr>
</thead>
<tbody>
<% @stores.each do |store| %>
<tr>
<td><%= store.name %></td>
<% store.revenues.select { |revenue| revenue.created_on.year == @year && revenue.created_on.month == @month }.each do |revenue| %>
<td><% revenue.value %></td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
通常のpreload
上記の Store.preload(:revenues)
では以下のようなクエリを発行します。
SELECT `stores`.* FROM `stores
SELECT `revenues`.* FROM `revenues` WHERE `revenues`.`store_id` IN (1, 2, 3, ...)
※このクエリはstoresのレコード数が多いとIN句で指定する値が大きくなりフルスキャンする問題等も抱えていますが今回は触れません。
クエリからも分かる通り全件のrevenuesをeager loadingしていますが、今回の仕様では特定年月のもの以外は不要なので、かなり無駄にメモリを使用していることになります。15年分のレコードがあるとすると、14年11ヶ月分のレコードを無駄にeager loadingしていることになります。
preloadで発行して欲しいクエリは以下のようなものです。
SELECT
`revenues`.*
FROM
`revenues`
WHERE
`revenues`.`created_on` BETWEEN '2024-02-01' AND '2024-02-29'
AND `revenues`.`store_id` IN (1, 2, 3, ...)
ActiveRecordのpreloadメソッドを使う方法だと、 BETWEEN '2024-02-01' AND '2024-02-29'
この部分の日付を動的に変える方法がなさそうです。
ActiveRecord::Associations::Preloaderを使う
今回の困り事を解決するには ActiveRecord::Associations::Preloader
を使います。(※はじめに書いていますが、非公開APIなので使用には注意が必要です。)
以下のように ActiveRecord::Associations::Preloader#call
で望んでいたクエリを発行することができます
class StoresController < ApplicationController
def index
@stores = Store.all
@year = params[:year]
@month = params[:month]
ActiveRecord::Associations::Preloader.new(
records: @stores,
associations: :revenues,
scope: Revenue.where(created_on: Date.new(@year, @month, 1)..Date.new(@year, @month, -1))
).call
end
end
これでメモリ使用量を劇的に小さくすることができ、パフォーマンスがかなり良くなりました。
ちなみに、 ActiveRecord::Associations::Preloader#call
はRailsの7から定義されたメソッドで、6系までは ActiveRecord::Associations::Preloader#preload
が定義されており、以下のように使います。
ActiveRecord::Associations::Preloader.new.preload(
@stores,
:revenues,
Revenue.where(created_on: Date.new(@year, @month, 1)..Date.new(@year, @month, -1))
)
eager_loadの場合
以下のようにeager_loadを使って似たようなことができそうだなと思った方もいると思います。
@stores = Store.eager_load(:revenues).where(revenues: { created_on: Date.new(@year, @month, 1)..Date.new(@year, @month, -1) })
実際に発行されるクエリをみてもらうとわかるのですが、上記のようなことをしてしまうと、 Revenue.where(created_on: Date.new(@year, @month, 1)..Date.new(@year, @month, -1)))
このrevenuesと紐づいていないstoresのレコードは対象外となってしまいます。
売上がない日はrevenuesのレコードは作成されないので、意図しない挙動をすることになるためこの記事のケースではeager_loadではくpreloadを使用しています。
おわりに
サービスの特性や、運用年数、運用予算などによって最適なパフォーマンスの出し方は変わってくると思いますが、eager loadingの対象を狭めることは基本的にポジティブに働くと思います。
この記事でパフォーマンスを改善できた方が増えると嬉しいです。
We are hiring!!
COUNTERWORKS では一緒に働く仲間を絶賛募集中です。
今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!
ポップアップストアや催事イベント向けの商業スペースを簡単に予約できる「SHOPCOUNTER」と商業施設向けリーシングDXシステム「SHOPCOUNTER Enterprise」を運営しています。エンジニア採用強化中ですので、興味ある方はお気軽にご連絡ください! counterworks.co.jp/
Discussion