Ruby
Rails
counter_culture

counter_cultureで合計値をキャッシュする

More than 1 year has passed since last update.

概要

親が子の数を数えることはよくある。ブログでカテゴリの横に記事の数を表示させるとか。
たかがカウントだが、大量アクセスがあるとDBのコストは馬鹿にならない。

Railsには標準でcounter_cacheなるものがあり、関連するテーブルのレコード数をキャッシュできるらしい。
だが今回は子の数を数えるだけでなく、子の持っている数値の合計値をキャッシュしたかった。

これをcounter_cultureというgemが解決してくれた。以下はそのメモ。

前提

本とレビューのモデルを考える。
本には複数のレビュー(コメントと評価)をつけることができる。

book.rb
class Book < ApplicationRecord
  has_many :reviews
end
review.rb
class Review < ApplicationRecord
  belongs_to :book
end

テーブルはこんな感じ。

create_books.rb
class CreateBooks < ActiveRecord::Migration[5.1]
  def change
    create_table :books do |t|
      t.string :title,  comment: "タイトル"
      t.string :author, comment: "著者"

      t.timestamps
    end
  end
end
create_reviews.rb
class CreateReviews < ActiveRecord::Migration[5.1]
  def change
    create_table :reviews do |t|
      t.references :book,    comment: "本"
      t.text       :comment, comment: "コメント"
      t.integer    :rate,    comment: "評価"

      t.timestamps
    end
  end
end

さて、ここで例えば本の情報を取得するAPIを作った場合、ついでに「コメントが何件ついているか」と「評価の平均値」も合わせて取得したくなるのが人情というもの。

まずこんな感じで書いてみる。

book = Book.find(1)
comment_count = book.reviews.where.not(comment: nil).count
average_rate  = book.reviews.average(:rate)

実行するとこんなクエリが走る。

SELECT "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
SELECT COUNT(*) FROM "reviews" WHERE "reviews"."book_id" = ? AND ("reviews"."comment" IS NOT NULL)  [["book_id", 1]]
SELECT AVG("reviews"."rate") FROM "reviews" WHERE "reviews"."book_id" = ?  [["book_id", 1]]

当然ながら「本」「コメント数」「評価の平均」の取得に3回のDBアクセスが発生している。

本1冊ならまだいいが、つい欲をかいて本を一覧で取得するAPIも作ったらどうだ。
そんなAPIをバカスカ叩かれる未来を想像すると、DBアクセスが詰まって死ぬんじゃないかという恐怖に苛まれて夜も眠れない。

なんとかDBアクセスを減らす方法はないかと調べたところcounter_cultureに行き着いた。

やったこと

Gemを追加

Gemfile
gem 'counter_culture'

テーブルにカウンターのカラムを追加

最終的に欲しいのは「評価の平均」だが、カウンターとしてはひとまず「レビュー数」と「評価の合計」をbooksテーブル追加する。
この2つがあればreviewsテーブルにアクセスしなくても平均を求められるはずだ。

add_counter_columns_to_books.rb
class AddCounterColumnsToBooks < ActiveRecord::Migration[5.1]
  def change
    add_column :books, :comments_count, :integer, null: false, default: 0, comment: "コメント数"
    add_column :books, :reviews_count,  :integer, null: false, default: 0, comment: "レビュー数"
    add_column :books, :total_rate,     :integer, null: false, default: 0, comment: "評価の合計"
  end
end

モデルにカウンターの仕掛けを追加

Reviewにカウンターの仕掛けを追加する。
これでReviewが作成/破棄/更新された際、自動的にBookにも反映されるようになる。

review.rb
class Review < ApplicationRecord
  belongs_to :book
  # レビュー数
  counter_culture :book
  # コメント数
  counter_culture :book, column_name: proc {|model| model.comment.blank? ? nil : "comments_count" }
  # 評価の合計
  counter_culture :book, column_name: "total_rate", delta_column: "rate"
end

少し細かく見てみる。

まず最初のレビュー数。これは最もシンプルな書き方。

counter_culture :book

これだけでBookに紐づくReviewがカウントされてreviews_countに保持されるようになる。
なお、カウンターのカラム名が"テーブル名_count"でない場合はcolumn_nameで指定できる。

次はコメント数。

counter_culture :book, column_name: proc {|model| model.comment.blank? ? nil : "comments_count" }

カウントに条件がある場合はブロックを渡すことになる。
条件に一致した場合はカウンターのカラム名を、それ以外はnilを渡すようにする。

最後は評価の合計。

counter_culture :book, column_name: "total_rate", delta_column: "rate"

カウンターはレコード数をキャッシュするためレコード毎に1ずつ加算されるのが普通だが、delta_columnで加算する値のカラムを指定できる。これええな。

動作検証

レビューを追加したときにカウンターが加算されていることを確認してみる。

最初はレビューがない。
カウンターも全部ゼロ。

irb(main):001:0> book = Book.first
irb(main):002:0> book.reviews_count
=> 0
irb(main):003:0> book.comments_count
=> 0
irb(main):004:0> book.total_rate
=> 0
irb(main):005:0> book.reviews.count
=> 0

レビューを追加してみる。

irb(main):007:0* book.reviews.create(comment: "Good!", rate: 5)
   (0.1ms)  begin transaction
  SQL (1.4ms)  INSERT INTO "reviews" ("book_id", "comment", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_id", 1], ["comment", "Good!"], ["rate", 5], ["created_at", "2017-12-08 16:49:07.725594"], ["updated_at", "2017-12-08 16:49:07.725594"]]
  SQL (0.2ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.1ms)  UPDATE "books" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 5 WHERE "books"."id" = ?  [["id", 1]]
   (1.6ms)  commit transaction
=> #<Review id: 1, book_id: 1, comment: "Good!", rate: 5, created_at: "2017-12-08 16:49:07", updated_at: "2017-12-08 16:49:07">
irb(main):008:0> 
irb(main):009:0* book.reviews.create(comment: "Bad.", rate: 1)
   (0.1ms)  begin transaction
  SQL (1.2ms)  INSERT INTO "reviews" ("book_id", "comment", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?, ?)  [["book_id", 1], ["comment", "Bad."], ["rate", 1], ["created_at", "2017-12-08 16:49:32.383319"], ["updated_at", "2017-12-08 16:49:32.383319"]]
  SQL (0.4ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "comments_count" = COALESCE("comments_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
   (2.4ms)  commit transaction
=> #<Review id: 2, book_id: 1, comment: "Bad.", rate: 1, created_at: "2017-12-08 16:49:32", updated_at: "2017-12-08 16:49:32">
irb(main):010:0>
irb(main):011:0* book.reviews.create(comment: nil, rate: 3)
   (0.1ms)  begin transaction
  SQL (0.6ms)  INSERT INTO "reviews" ("book_id", "rate", "created_at", "updated_at") VALUES (?, ?, ?, ?)  [["book_id", 1], ["rate", 3], ["created_at", "2017-12-08 16:53:46.922476"], ["updated_at", "2017-12-08 16:53:46.922476"]]
  SQL (0.3ms)  UPDATE "books" SET "reviews_count" = COALESCE("reviews_count", 0) + 1 WHERE "books"."id" = ?  [["id", 1]]
  SQL (0.2ms)  UPDATE "books" SET "total_rate" = COALESCE("total_rate", 0) + 3 WHERE "books"."id" = ?  [["id", 1]]
   (1.4ms)  commit transaction
=> #<Review id: 3, book_id: 1, comment: nil, rate: 3, created_at: "2017-12-08 16:53:46", updated_at: "2017-12-08 16:53:46">

reviewsのINSERTの後、同一トランザクション内でbooksに対するUPDATEが走っている。
3つ目のレビューはコメントがないのでcomments_countのUPDATEはされていない。
total_rateはちゃんとrateの値が加算されている。

それではBookを確認。

irb(main):022:0* book.reload
  Book Load (0.3ms)  SELECT  "books".* FROM "books" WHERE "books"."id" = ? LIMIT ?  [["id", 1], ["LIMIT", 1]]
=> #<Book id: 1, title: "Crime and Punishment", author: "Dostoyevsky", created_at: "2017-12-08 16:40:13", updated_at: "2017-12-08 16:40:13", reviews_count: 3, comments_count: 2, total_rate: 9>
irb(main):023:0> book.reviews_count
=> 3
irb(main):024:0> book.comments_count
=> 2
irb(main):025:0> book.total_rate
=> 9

無事カウンターが回っている。
最後に評価の平均を求めておしまい。

irb(main):029:0> book.total_rate > 0 ? book.total_rate / book.reviews_count.to_f : 0 # ゼロ除算に注意
=> 3.0

はい、これで本の一覧を取得してもSELECT "books".* FROM "books"一発で全部取れるようになりました。
ようやく安心して眠れるね。おやすみ。

参考