🔄

【Rails7.0】カウンターキャッシュ名の衝突による余分な更新を解消する

2024/02/14に公開

こんにちは。st-1985 です。

今日は Rails(ActiveRecord) のカウンターキャッシュ機能を使用していて発生した現象とその解消方法について書こうと思います。
なお、Rails 7.1 では解消されているので、7.0 での話になります。
確認はできていませんが、Rails 6.x 以前でも同様に発生する可能性があります。

カウンターキャッシュとは

データベースの関連レコードの数を効率的に管理するための機能です。
例えば、DBで複数のサイト(Site)を管理しているとします。
サイトには、それぞれ多数のユーザー(User)がおり、それぞれがさまざまなグループ(UserGroup)に所属しているとします。
各サイトやグループに所属するユーザーの件数を取得する場合、レコードの件数を取得するクエリを実行する事になりますが、
カウンターキャッシュを利用することで、それぞれのユーザー数をリアルタイムで更新し、データベースへのクエリ回数を減らすことができます。
これにより、検索時のパフォーマンスが向上し、ユーザー体験の改善が期待できます。

問題が起こった構成

カウンターキャッシュの説明に準じて以下のような構成になっていたとします。

  • Site は複数の UserUserGroup を持つ
  • UserGroupUserGroupAndUser を介して複数の User を持つ
  • Siteusers_count というカウンターキャッシュ用のカラムを持つ
  • UserGroup にもusers_count という同じ名前のカウンターキャッシュ用のカラムを持つ

モデル定義は以下です。

# Table name: sites
#
#  id              :integer          not null, primary key
#  users_count     :integer          default(0), not null
#  created_at      :datetime         not null
#  updated_at      :datetime         not null
#
class Site < ApplicationRecord
  has_many :users
  has_many :user_groups
end

# Table name: users
#
#  id         :integer          not null, primary key
#  site_id     :integer          not null
#  created_at :datetime         not null
#  updated_at :datetime         not null
#
class User < ApplicationRecord
  has_many :user_group_and_users
  has_many :user_groups, through: :user_group_and_users
  belongs_to :site, counter_cache: true
end

# Table name: user_groups
#
#  id          :integer          not null, primary key
#  site_id      :integer          not null
#  users_count :integer          default(0), not null
#  created_at  :datetime         not null
#  updated_at  :datetime         not null
#
class UserGroup < ApplicationRecord
  has_many :user_group_and_users
  has_many :users, through: :user_group_and_users
  belongs_to :site
end

# Table name: user_group_and_users
#
#  id            :integer          not null, primary key
#  user_group_id :integer          not null
#  user_id       :integer          not null
#  created_at    :datetime         not null
#  updated_at    :datetime         not null
#
class UserGroupAndUser < ApplicationRecord
  belongs_to :user_group, counter_cache: :users_count
  belongs_to :user
end

発生した現象

以下のコードのように新しくグループを作成し、そこにユーザーを追加したとします。

site = Site.create
user_group = UserGroup.create(site: site)

puts user_group.users_count # => 0
user_group.users.create(site: site)

ユーザーの追加に成功し、カウンターキャッシュの値を取得すると

puts user_group.users_count # => 2

1件の追加だったはずが余分なカウントがされています。
ちなみに user_group.userssizeusers_countの値を見る為、こちらも余分なカウントがされた状態で表示されます。

puts user_group.users.size # => 2

この余分なカウントはメモリ上のusers_countのみに影響し、DBには反映されていない為、リロード後に正しい値を取得する事ができます。

puts user_group.reload.users_count # => 1

問題の原因

この問題は、カウンターキャッシュの名前が UserGroupSite の両方で users_count として使用されている為に、関連が更新される際にSiteusers_countUserGroupusers_count取り違えて更新してしまうことに起因しているようです。

解決策

  1. Rails のバージョンアップ
    冒頭で触れた通りこの問題は、Rails 7.1.0beta1で解決されています
    なので、可能であればバージョンアップをするのが一番手っ取り早い解決策だと思います。

  2. カウンターキャッシュの名前を一意にする
    バージョンアップが難しい場合は名前の衝突を避ける事で回避できます。

    # DBのカラム変更(マイグレーション)
    class RenameUsersCountToUserGroupUsersCountInUserGroups < ActiveRecord::Migration[7.0]
      def change
        rename_column :user_groups, :users_count, :user_group_users_count
      end
    end
    
    # モデルの修正
    class UserGroupAndUser < SitelicationRecord
      belongs_to :user_group, counter_cache: :user_group_users_count # ← users_count から変更
      belongs_to :user
    end
    

    カウンターキャッシュの名前を変えたのでコードの変更も忘れないようにしましょう。

まとめ

  • カウンターキャッシュの名前の重複していると余分にカウントされる場合がある
  • Rails 7.1 にアップデートする事で根本的に解決する
  • アップデートが難しい場合は別の名前にする事で回避できる

この記事がお役に立てれば幸いです。

SocialPLUS Tech Blog

Discussion