amazon

[Rails]Model に子要素の集計結果を持たせる方法

はじめに

前回の記事「MySQL レコード単位で検索条件を変える副問い合わせ」でレコード単位で条件を変えて集計する方法を記載しましたが、Rails でデータが増えた場合に対応しようとするとなかなか大変です。

大変となる理由はレコード数が増えると DB とのやり取りが増える or データ量が増えるため処理速度が遅くなりがちとなります。

今回、2通りのやり方を記載します。

やりたいこと

Shop モデルに集計結果を返すメソッドを追加する

方法1 ActiveRecord::Relation を用いてモデル内で処理する

こちらがチュートリアルとかによく記載されている方法ですね。コードは以下のようになります。

Class Shop
  has_many :daily_results, dependent: :destroy

  # param [Date] till 取得最終日
  # return [Int]
  def sum_proceeds(till)
    # 期間限定の場合は open_date、常設の場合は till の月初
    since = open_date || till.begining_of_month 
    daily_results.where(date: since..till).sum(&:proceeds)
  end
end

class DailyResults; end

Shop.includes(:daily_results).all.each do |shop| # N + 1 問題のために daily_results を先読み
  puts shop.sum_proceeds(Date.new(2022,4, 30))
end
#=>
#  1件目の合計
#  ・・・

メリット&デメリットは以下です

  • メリット
    • (N + 1 問題対策していれば)DB アクセスが1回で済む
    • モデルに紐づいて理解しやすい
    • デバックで止めながら処理を追いやすい
  • デメリット
    • daily_results が増えると(先読みで取得するデータが多くなり)メモリを圧迫する
    • Ruby サイドで集計するため、 DB のクエリに比べると遅い

デメリットの1つ目メモリ圧迫はメモ化や変数に代入で対応できます。

しかし、デメリットの2つ目は処理量が増えるのでお金をかけて CPU を高性能にするしかないと思います。今回は店舗なので千件を超えないでしょうが商品単位にしようとすると数千を超える可能性があり、その分処理時間が伸びます。

方法2 select と生 SQL を使う

こちらは前回の副問い合わせを用いた生 SQL と select[1] を使う方法です。select メソッドは取得カラムを絞るときによく使いますが、SQL の select 構文のように任意の返却を指定することもできます。

Class Shop
  has_many :daily_results, dependent: :destroy
end

class DailyResults; end

def sum_query(date)
  << QUERY
    SELECT
      SUM(proceeds)
    FROM daily_results
    WHERE daily_results.shop_id = shops.id # Shop モデルに対して select するので INNER JOIN なしで shops のカラムを扱うことができる
    AND date <= "#{date.end_of_month}"
    AND date >= (
      SELECT
        CASE
        WHEN open_date IS NULL THEN "#{date.begining_of_month}"
        ELSE open_date
      FROM shops
    )
  QUERY
end

Shop.all.select('shops.*', "(#{sum_query(Date.new(2022, 5, 31))}) sum_sum_proceeds").each do |shop|
  puts shop.sum_proceeds
end
#=>
#  1件目の合計
#  ・・・

sum_proceeds という名前で対象範囲の売り上げ集計を返却するメソッドを select で指定しました。

メリットとデメリットは以下です。

  • メリット
    • 集計を DB に任せられるのでデータ量が多くても処理はそこまで影響を受けない
  • デメリット
    • (N + 1 問題対策版に比べると)DB へのアクセス数が増える
      • all で1回、SUM で1回と計2回になる
      • select する項目が増える毎に個別にクエリが必要
    • モデルでは定義されていない暗黙の了解のメソッドになるので、使用範囲を限定しないと後々混乱の原因になる

おわりに

  • 悪戦苦闘の日々
    • 現在、 生 SQL と select の方法で対応を進めていますが、正直モジュールが密結合になってしまうのでやりたくないです。
      • ほかによさげな方法があればそっちを選びたい
    • ここに着地するまでの案としては
      • 前日までの集計をテーブルに用意する → リアルタイム性が必要なので却下
      • ビュー → (本記事では表現していないが)10テーブルくらい LEFT JOIN したバカでかいやつが出来上がる
      • マテリアルビューで → MySQL にそんな機能はない
        • そもそも(リアルタイム性で)頻繁なキャッシュクリアが必要なので使える?
    • そもそも月次と月またぎ同一でやっているのが問題では?
      • 分割すれば月次は楽になるけど、期間限定は同じ問題を持ったまま

設計って難しいです。

参考

[1] select, https://api.rubyonrails.org/classes/ActiveRecord/QueryMethods.html#method-i-select

コメント

タイトルとURLをコピーしました