Hegwin.Me

The bitterest tears shed over graves are for words left unsaid and deeds left undone.

Counter Cache and counter_culture

Counter Cache 和 counter_culture

Have you ever encountered a requirement where, assuming we are a book review site (e.g., douban.com), we need to display the number of reviews for each record in a list of books. We'll have a Book model and a Review model, which have a one-to-many relationship, Book.has_many :reviews.

The most straightforward approach would be to do something like books.each { |book| book.reviews.count } on the book list page, which works, but creates an N+1 problem, where each book model generates a count SQL.

Imagine a more complex but still realistic scenario - the Review model would also have some scopes of its own, like:

class Review < ApplicationRecord
  enum :status, %i[draft under_review published]

  scope :positive, -> { where('rating >= 9') }
  scope :featured, -> { where(featured: true) }
end

I need to display both book.reviews.published.count or book.reviews.published.featured.count on the list page, such database queries would be more complex.

For the simple cases above, we can utilize the Counter Cache that comes with Rails, and for the more complex cases, we can use the counter_culture gem.

Counter Cache

Counter Cache provides a set of class methods for ActiveRecord to help cache a count of its associations. These methods are useful, but it's more common to set counter_cache on methods like belongs_to.

For example:

class Book < ApplicationRecord
 has_many :reviews
end

class Review < ApplicationRecord
  belongs_to :book, counter_cache: true
end

We can try this in the Rails console, and it will automatically give books.reviews_count a +1 when we create a new review.

book = Book.last

review = book.reviews.new rating: 9
 => #<Review:0x0000000139075d98 ...> 

# Will execute both INSERT INTO "reviews"
# and UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1
review.save
book.reload.reviews.size
 => 1

Similarly, when deleting a review, it will -1 books.reviews_count in the same transaction:

review.destroy
  TRANSACTION (0.6ms)  BEGIN
  Review Destroy (0.6ms)  DELETE FROM "reviews" WHERE "reviews"."id" = $1  [["id", 1]]
  Book Update All (0.7ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) - $1 WHERE "books"."id" = $2  [["reviews_count", 1], ["id", 1]]
  TRANSACTION (16.1ms)  COMMIT

There's also a rails convention for reading this cache, if we call book.reviews.count it will still do a query to the database, but if we use size or length it won't trigger the count SQL, or we can just call book.reviews_count directly.

# No SQL execution
book.reviews.size 
 => 1 
# No SQL execution
book.reviews.length
 => 1 
book.reviews.count
   (1.1ms)  SELECT COUNT(*) FROM "reviews" WHERE "reviews"."book_id" = $1  [["book_id", 1]]
 => 1 

Counter Cache is reliable in most use cases. However, in some cases, such as deleting a piece of data using review.delete, because delete skips callbacks, the counter cache is not refreshed. In this case, we need to manually flush the counter cache:

Book.reset_counters(book.id, :reviews)
 => true 

reset_counters is also used to initialize data for adding the xxx_count field to an existing database.

Counter Culture

For simple cases, rails' own Counter Cache is sufficient, but for the more complex cases below, it's a bit out of reach:

  1. Counter is not just a simple count of all associated records, but also based on the specific properties of the associated object to determine , such as books.reviews.published.size ;
  2. When the properties of the associated object change, for example, review.status changes from draft to published, the counter cache also needs to be refreshed, not just when it is created and deleted.

Let's take a look at the complex example mentioned at the beginning. For the Review model, it has a status field and a scope such as featured, and what we want to show in the books list at this moment is the number of published reviews and the number of published featured reviews.

At this point, we need to use the counter_culture gem, which is documented as "a huge improvement over Rails' built-in Counter Cache":

  • Updates counter cache when values change, not just when creating and destroying
  • Supports counter caches through multiple levels of relations
  • Supports dynamic column names, making it possible to split up the counter cache for different types of objects
  • Can keep a running count, or a running total

Let's see how we can implement the above requirements. The first step is to install it, by adding the following paragraph to your Gemfile and running bundle install:

gem 'counter_culture', '~> 3.5'

We need to add 2 fields to the books table to store the counts:

add_column :books, :published_reviews_count, :integer, null: false, default: 0
add_column :books, :published_featured_reviews_count, :integer, null: false, default: 0

Then, we call the counter_culture method in the Review model.

class Review < ApplicationRecord
  enum :status, %i[draft under_review published]

  scope :positive, -> { where('rating >= 9') }
  scope :featured, -> { where(featured: true) }

  enum :status, %i[draft under_review published]

  belongs_to :book

  scope :positive, -> { where('rating >= 9') }
  scope :featured, -> { where(featured: true) }

  counter_culture :book,
    column_name: proc { |model| model.published? ? 'published_reviews_count' : nil }

  counter_culture :book,
    column_name: proc { |model| model.featured? && model.published? ? 'published_featured_reviews_count' : nil }
end

This is what it looks like when used:

review = book.reviews.new
review.save # Won't update any counters

# Will execute SET "published_reviews_count" = COALESCE("published_reviews_count", 0) + 1
review.published!
book.reload.published_reviews_count
 => 1 
book.published_featured_reviews_count
 => 0

review.featured = true
# Will execute SET "published_featured_reviews_count" = COALESCE("published_featured_reviews_count", 0) + 1 
review.save
book.reload.published_reviews_count
 => 1 
book.published_featured_reviews_count
 => 1

# Will execute 2 SQLs
# SET "published_reviews_count" = COALESCE("published_reviews_count", 0) - 1
# SET "published_featured_reviews_count" = COALESCE("published_featured_reviews_count", 0) - 1
review.draft!
book.reload.published_reviews_count
 => 0
book.published_featured_reviews_count
 => 0

Summary

Both Counter Cache and counter_culture can be used in Rails projects to achieve the functionality of cache counting:

  1. Counter Cache is easier to consider when simply counting all associated objects;
  2. counter_culture can be used for complex counting when a conditional counting cache is required.
< Back