概要
親が子の数を数えることはよくある。ブログでカテゴリの横に記事の数を表示させるとか。
たかがカウントだが、大量アクセスがあるとDBのコストは馬鹿にならない。
Railsには標準でcounter_cache
なるものがあり、関連するテーブルのレコード数をキャッシュできるらしい。
だが今回は子の数を数えるだけでなく、子の持っている数値の合計値をキャッシュしたかった。
これをcounter_culture
というgemが解決してくれた。以下はそのメモ。
前提
本とレビューのモデルを考える。
本には複数のレビュー(コメントと評価)をつけることができる。
class Book < ApplicationRecord
has_many :reviews
end
class Review < ApplicationRecord
belongs_to :book
end
テーブルはこんな感じ。
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
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を追加
gem 'counter_culture'
テーブルにカウンターのカラムを追加
最終的に欲しいのは「評価の平均」だが、カウンターとしてはひとまず「レビュー数」と「評価の合計」をbooks
テーブル追加する。
この2つがあればreviews
テーブルにアクセスしなくても平均を求められるはずだ。
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
にも反映されるようになる。
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"
一発で全部取れるようになりました。
ようやく安心して眠れるね。おやすみ。