spacelyのブログ

Spacely Engineer's Blog

counter_cacheの導入でレイテンシ改善

株式会社スペースリー エンジニアの出口です。

普段は弊社サービスの建物や物件といった情報の管理画面を開発しています。

バックエンドはruby on railsで開発されており、今回は建物一覧のレイテンシ改善のためActiveRecordのcounter_cacheを導入した経緯と実装内容についてまとめます。

概要

サービスに登録されているアパートやマンションといった建物の一覧を表示するページでは、建物の情報に加えて建物に紐づいている物件(個々の部屋)の数や撮影された画像の数などを表示しています。

各モデルのリレーションは下図の通りです。建物と物件が親子関係にあり、建物と物件それぞれに属する画像があるというポリモーフィックの関連付けがされています。

建物一覧では建物の情報の他に紐づく物件数や画像数、画像を持っている物件数などを表示します。しかし、建物一覧のデータ取得のたびに物件数などの値を集計しているためレイテンシが悪化していました。この問題をcounter_cacheを導入することでレイテンシを改善しました。

counter_cacheを選んだ理由

レイテンシ改善のためにはデータ取得の際に集計を行わず、建物のテーブルに集計結果のカラムを作成して値を保持することにしました。 物件などのレコードの作成・削除に合わせて集計結果のカラムの値を更新するにはcounter_cachecounter_cultureを利用することで実現できます。 建物一覧の仕様を踏まえた上でのそれぞれの特徴は以下の通りです。

counter_cache

counter_culture

条件付きの集計はありますが、画像は建物と物件のポリモーフィックになっているため、今回の実装ではcounter_cacheを導入することにしました。

実装内容

実装としては下記の対応をしました。

  • 集計カラム追加
  • モデルファイルの修正
  • 既存データの集計
  • 条件付き集計カラムの更新

集計カラム追加

counter_cacheを用いて値を保存するためのカラムをRealEstateとBuildingにそれぞれ追加します。

class AddImageableCount < ActiveRecord::Migration[6.1]
  def self.up
    add_column :real_estates, :real_estate_images_count, :integer, null: false, default: 0, comment: "物件の画像枚数"
    add_column :buildings, :real_estates_count, :integer, null: false, default: 0, comment: "物件数"
    add_column :buildings, :has_real_estate_image_real_estates_count, :integer, null: false, default: 0, comment: "画像あり物件数"
    add_column :buildings, :real_estate_images_count, :integer, null: false, default: 0, comment: "建物の画像枚数"
  end

  def self.down
    remove_column :real_estates, :real_estate_images_count
    remove_column :buildings, :real_estates_count
    remove_column :buildings, :has_real_estate_image_real_estates_count
    remove_column :buildings, :real_estate_images_count
  end
end

モデルファイルの修正

集計対象のモデルファイルのbelongs_toにcounter_cacheの記述を追加します。 ポリモーフィックを用いている画像のモデルファイルではcounter_cacheでカラム名を指定します。 これにより物件や画像が作成・削除された場合には親テーブルの集計用カラムの値は増加、減少するようになります。ただし、画像を持っている物件数というような条件付き集計カラムは集計されないため別途対応します。

建物

class Building < ApplicationRecord
  has_many :real_estates
  has_many :real_estate_images, as: :imageable

  # 関係ない項目は省略
end

物件

class RealEstate < ApplicationRecord
  belongs_to :building, optional: true, counter_cache: true
  has_many :real_estate_images, as: :imageable

  # 関係ない項目は省略
end

画像

class RealEstateImage < ApplicationRecord
  belongs_to :imageable, polymorphic: true, counter_cache: :real_estate_images_count

  # 関係ない項目は省略
end

既存データの集計

counter_cacheを新たに導入した場合、集計対象のレコードが既に存在すると集計し直す必要があります。 これはcounter_cacheが集計対象のレコードの増減の際に集計用のカラムを1ずつ増減させているためです。 再集計はreset_countersのメソッドを利用することで対応できます。 条件付きの集計の場合には集計した値でカラムを更新します。

namespace :oneshot do
  task reset_count: :environment do
    RealEstate.find_each do |real_estate|
      # 画像数
      RealEstate.reset_counters(real_estate.id, :real_estate_images)
    end

    Building.find_each do |building|
      # 物件数
      Building.reset_counters(building.id, :real_estates)
      # 画像数
      Building.reset_counters(building.id, :real_estate_images)
      # 画像有り物件数
      has_real_estate_image_real_estates_count = building.real_estates.joins(:real_estate_images)
                .group(:building_id)
                .distinct
                .count[building.id].to_i
      building.update!(has_real_estate_image_real_estates_count: has_real_estate_image_real_estates_count)
    end
  end
end

条件付き集計カラムの更新

counter_cacheの対象としているレコード(物件または画像)が作成されるとcounter_cacheのupdate_countersが実行され、各カラムのカウントが加算されます。 今回はupdate_countersをオーバーライドすることで条件付き集計カラムの更新を実装しました。 しかし、物件が削除された場合や物件の画像が削除された場合にはbelogs_to.rb内のprivateメソッドであるadd_counter_cache_callbacksを使用するためオーバーライドはできず、別途処理を追加して条件付き集計カラムを更新するようにしました。

class Building < ApplicationRecord
  # 関係ない項目は省略

  # counter_cacheのupdate_countersをオーバーライド
  def self.update_counters(id, counters)
    super(id, counters)

    building = find(id)
    building.update_counts
  end

  def update_counts
    self.has_real_estate_image_real_estates_count = calc_has_real_estate_image_real_estates_count
    save
  end

  def calc_has_real_estate_image_real_estates_count
    real_estates.joins(:real_estate_images)
                .group(:building_id)
                .distinct
                .count[id].to_i
  end
end
class RealEstate < ApplicationRecord
  # 関係ない項目は省略

  after_destroy_commit do
    building&.update_counts
  end
end
class RealEstateImage < ApplicationRecord
  # 関係ない項目は省略

  after_commit do
    if real_estate && RealEstate.exists?(real_estate.id)
      real_estate.building&.update_counts
    end
  end

  def real_estate
    imageable if imageable_type == "RealEstate"
  end
end

まとめ

今回は建物一覧のレイテンシ改善のためにcounter_cacheを導入しました。 集計に条件があったこととテーブルのリレーションにポリモーフィックを用いていたことが重なり、単純にcounter_cacheやcounter_cultureを利用するだけでは実装できず本記事のような実装となりました。

最後に

spacelyでは一緒に働いてくださる方を大募集中です。