Counter Cache 和 counter_culture
Counter Cache and counter_culture
你是否遇到过这样的需求:假设我们是一个书评网站(比如豆瓣),在书目的列表中,我们需要显示每本书的评价数量。我们会有一个 Book model,以及一个 Review model,他们是一对多的关系,即 Book.has_many :reviews
。
最直接的做法就是在书目的列表页面这样 books.each { |book| book.reviews.count }
,它可以工作,但是会产生一个N+1的问题,每一book模型都会产生一条count SQL。
想象一个更复杂但依旧现实的场景——Review模型也会有自己一些 scopes,比如:
class Review < ApplicationRecord
enum :status, %i[draft under_review published]
scope :positive, -> { where('rating >= 9') }
scope :featured, -> { where(featured: true) }
end
我需要在列表页同时显示 book.reviews.published.count
或者是 book.reviews.published.featured.count
时,这样的数据库查询就会比较复杂了。
针对上面的简单情况,我们可以利用 Rails 自带的 Counter Cache 来解决,而对于复杂的情形,可以用到 counter_culture 这个 gem 来解决。
Counter Cache
Counter Cache为ActiveRecord的提供了一系列类方法,可以帮助缓存一条record与其associations的记录数。这些方法很有用,但是更常用的做法是在,belongs_to这样的方法上设置 counter_cache 这个选项。
比如:
class Book < ApplicationRecord
has_many :reviews
end
class Review < ApplicationRecord
belongs_to :book, counter_cache: true
end
我们可以在Rails console里尝试一下,创建一条新的review时,它会自动给 books.reviews_count 做 +1 的操作。
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
同样地,在删除一条review的时候,它也会在同一个事务里将 books.reviews_count 进行 -1 的操作:
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
读取这个cache数据的时候也有 rails 的 convention,如果我们调用 book.reviews.count
,它依然会去数据库进行,查询,但是如果使用 size
或者 length
就不会触发 count SQL,或者直接调用 book.reviews_count
也是可以的。
# 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 在大多数使用情况下都是可靠的。但在某些情况下,比如使用 review.delete
删除一条数据,因为 delete
会跳过 callbacks,那么此时 counter cache没有刷新。这时,我们需要手动刷新计数器缓存:
Book.reset_counters(book.id, :reviews)
reset_counters
同样也适用于为已经有的数据库添加 xxx_count 字段初始化数据时使用。
Counter Culture
对于简单的情况,rails自己的 Counter Cache已经足够使用,但是对于下面更复杂的情况,它则有点力不从心了:
- 计数器不只是简单的 count 所有的关联记录,而是要根据关联对象的具体属性来判断,比如 books.reviews.published.size;
- 当关联对象属性发生变化时,比如 review.status 从 draft 变成 published时,counter cache也需要刷新,而不是只是在创建和删除时刷新。
我们再来看看开头提到的复杂例子,对于 Review 这个模型,它有一个 status 字段,还有 featured 这样的 scope,而我们此时想在 books 列表显示的是 published reviews 的数量,和 published featured reviews 的数量。
那么此刻,我们就需要用到 counter_culture 这个gem,它的文档介绍说“与Rails 内置的Counter Cache相比有巨大改进”:
- 在值发生变化时更新计数器缓存,而不止是在创建和销毁时
- 支持多层关联的计数器缓存
- 支持动态列名,可为不同类型的对象分别设置计数器缓存
- 可以保持运行计数或者运行总数
让我们来看看具体怎么实现上面的需求。首先是安装,在 Gemfile 加入下面这段,然后执行 bundle install
即可完成安装:
gem 'counter_culture', '~> 3.5'
我们需要给 books 表添加2个字段,用于存储计数:
add_column :books, :published_reviews_count, :integer, null: false, default: 0
add_column :books, :published_featured_reviews_count, :integer, null: false, default: 0
然后,我们在 Review 模型里调用 counter_culture
方法:
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
使用时效果就是这样:
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
小结
Counter Cache 和 counter_culture 都可以在 Rails 项目中达到缓存计数的功能:
- 当只是对关联对象进行简单的全部计数时,考虑 Counter Cache 更为简便;
- 当需要用到带条件的的计数缓存时,则使用 counter_culture 来实现复杂的计数。