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:
- 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 ;
- 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:
- Counter Cache is easier to consider when simply counting all associated objects;
- counter_culture can be used for complex counting when a conditional counting cache is required.