Hegwin.Me

疏影横斜水清浅,暗香浮动月黄昏。

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已经足够使用,但是对于下面更复杂的情况,它则有点力不从心了:

  1. 计数器不只是简单的 count 所有的关联记录,而是要根据关联对象的具体属性来判断,比如 books.reviews.published.size;
  2. 当关联对象属性发生变化时,比如 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 项目中达到缓存计数的功能:

  1. 当只是对关联对象进行简单的全部计数时,考虑 Counter Cache 更为简便;
  2. 当需要用到带条件的的计数缓存时,则使用 counter_culture 来实现复杂的计数。
< Back