📂

ActiveRecordのpreloadで対象のレコードを絞り込んでメモリ使用量を小さくする方法

2024/02/29に公開

株式会社COUNTERWORKSでRuby on Railsを採用しているアプリの開発をしているまったんです。
この記事ではActiveRecordのpreloadによるeager loadingで、対象のレコードを絞り込む方法を紹介します。

はじめに

Railsでアプリの開発をしている方はN+1問題の解決にpreloadをよく使うと思います。
preloadでは関連する全てのレコードをeager loadingするため、eager loadingした関連レコード全てを使用するケース以外ではメモリを無駄に消費することになってしまいます。
今回はそういったケースでメモリを無駄に消費しないようにするための方法を書いていきます。

eager_loadとpreloadの使い分けに関しては以下の記事を参考にしてください。
https://moneyforward-dev.jp/entry/2019/04/02/activerecord-includes-preload-eagerload/

また、この記事で扱う方法は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
... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ... ...

コントローラーとビューは以下のような実装になっています。

stores_controller.rb
class StoresController < ApplicationController
  def index
    @stores = Store.preload(:revenues)
    @year = params[:year]
    @month = params[:month]
  end
end
index.html.erb
<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 で望んでいたクエリを発行することができます

stores_controller.rb
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 では一緒に働く仲間を絶賛募集中です。
今後の更なる成長のためには圧倒的に仲間が不足しています。皆さまのご応募お待ちしております!

https://counterworks.co.jp/recruit/?utm_source=zenn&utm_medium=referral&utm_campaign=tech_blog&utm_content=0a90056ff23e10

COUNTERWORKS テックブログ

Discussion