読者です 読者をやめる 読者になる 読者になる

Rails4 でカウンター用カラムをインクリメントする

環境:
rails (4.1.6)
activerecord (4.1.6)
mysql2 (0.3.16)

Rails4 というか Rails でカウンター用カラムをインクリメントする簡単な方法がないか調べてみた。

やりたいこと

MySQL だったら以下のような SQL を発行したい。

update books set reviews_count = reviews_count + 1, updated_at = now() where id = ?

カウンターのインクリメントと同時にソートのために更新日時も一緒に更新したい。

検証用環境の作成

$ bundle exec rails new counter_app -d mysql -T --skip-bundle
$ cd counter_app
$ bin/bundle install --path vendor/bundle
モデル作成

Book という親モデルと、その子モデルの Review を作成する。
Review の件数を Book のreviews_countカラムで管理する。

bin/rails g model book title:string reviews_count:integer
      invoke  active_record
      create    db/migrate/20141007080722_create_books.rb
      create    app/models/book.rb

bin/rails g model review book:references body:text
      invoke  active_record
      create    db/migrate/20141007080938_create_reviews.rb
      create    app/models/review.rb
マイグレーションファイル修正
class CreateBooks < ActiveRecord::Migration
  def change
    create_table :books do |t|
      t.string :title
      t.integer :reviews_count, default: 0

      t.timestamps
    end
  end
end

reviews_countカラムにdefault: 0を追加。

アソシエーション設定

app/models/book.rb

class Book < ActiveRecord::Base
  has_many :reviews
end

app/models/review.rb

class Review < ActiveRecord::Base
  belongs_to :book
end
マイグレーション実行
bin/rake db:create
bin/rake db:migrate
bin/rails dbconsole development

mysql> desc books;
+---------------+--------------+------+-----+---------+----------------+
| Field         | Type         | Null | Key | Default | Extra          |
+---------------+--------------+------+-----+---------+----------------+
| id            | int(11)      | NO   | PRI | NULL    | auto_increment |
| title         | varchar(255) | YES  |     | NULL    |                |
| reviews_count | int(11)      | YES  |     | 0       |                |
| created_at    | datetime     | YES  |     | NULL    |                |
| updated_at    | datetime     | YES  |     | NULL    |                |
+---------------+--------------+------+-----+---------+----------------+

mysql> desc reviews;
+------------+----------+------+-----+---------+----------------+
| Field      | Type     | Null | Key | Default | Extra          |
+------------+----------+------+-----+---------+----------------+
| id         | int(11)  | NO   | PRI | NULL    | auto_increment |
| book_id    | int(11)  | YES  | MUL | NULL    |                |
| body       | text     | YES  |     | NULL    |                |
| created_at | datetime | YES  |     | NULL    |                |
| updated_at | datetime | YES  |     | NULL    |                |
+------------+----------+------+-----+---------+----------------+
Book 登録
b = Book.new(title: 'rails')
b.save

以下、4つの方法を検証した。


1. increment メソッドを使う

インスタンスメソッドActiveRecord::Persistence#incrementを使う方法。

動作結果

b = Book.find(1)
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 0, created_at: "2014-10-07 08:20:21", updated_at: "2014-10-07 08:20:21">

b.reviews_count
# => 0

b.increment(:reviews_count)
# => #<Book id: 1, title: "rails", reviews_count: 1, created_at: "2014-10-07 08:20:21", updated_at: "2014-10-07 08:20:21">

b.save
# BEGIN
# UPDATE `books` SET `reviews_count` = 1, `updated_at` = '2014-10-07 08:22:16' WHERE `books`.`id` = 1
# COMMIT
# => true

b.reviews_count
# => 1

increment メソッドでモデル内のカウントをインクリメントするだけなので save メソッドで DB のカウンター用カラムをアップデートする必要がある。
カウンター用カラムと更新日時の両方を更新できているけど select と update で2回 SQL 発行して atomic にインクリメントできてないので却下。

※ 追記1
increment!を忘れてた。こちらを使うとすぐupdateが呼ばれてsaveは不要。
ただし、incrementと同様にselectしてキャッシュした値に+1するので複数クライアントから同時に行うと値がおかしくなる可能性がある。

※ 追記2
rails5 からincrement!の挙動が変更された。
b.increment!(:reviews_count)実行時には以下SQLが発行される。

UPDATE `books` SET `reviews_count` = COALESCE(`reviews_count`, 0) + 1 WHERE `books`.`id` = 1

以前と違ってDBの値に+1しているので複数クライアントから同時に行っても問題ない。
また、updated_atの更新がなくなっている。これは下で書いたincrement_counterと同じ挙動だ。
ちなみにincrementの方は以前と同じ挙動だった。


2. increment_counter メソッドを使う

クラススメソッドActiveRecord::CounterCache#increment_counterを使う方法。

動作結果

b = Book.find(1)
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 0, created_at: "2014-10-07 08:39:59", updated_at: "2014-10-07 08:39:59">

b.reviews_count
# => 0

Book.increment_counter(:reviews_count, b.id)
# UPDATE `books` SET `reviews_count` = COALESCE(`reviews_count`, 0) + 1 WHERE `books`.`id` = 1
# => 1

b.reviews_count
# => 0

b.reload
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 1, created_at: "2014-10-07 08:39:59", updated_at: "2014-10-07 08:39:59">

b.reviews_count
# => 1

直接 SQL を発行して DB のカウンター用カラムをインクリメントしてる。
しかし更新日時は別途 SQL で更新しなければならない。


3. カウンターキャッシュを使う

Rails が標準機能として持っている「カウンターキャッシュ」を使う。
名前のとおり、カウント値をキャッシュしてくれる機能なんだけど、それだけではなく子モデルが追加/削除されると自動的に親モデルが持っているカウントカラムをインクリメント/デクリメントしてくれる機能も備えている。

カウンターキャッシュの使い方

1. 親モデルにカウンター管理のためのカラムを追加する
  • 親テーブルに「子テーブル名_count」という名前で integer 型のカラムを追加する
  • デフォルト値として 0 を設定する

Book マイグレーションファイル

class CreateBooks < ActiveRecord::Migration
  def change
    create_table :books do |t|
      t.string :title
      t.integer :reviews_count, default: 0

      t.timestamps
    end
  end
end

最初からこの設定はしていたので特に何もしない。

2. 子モデルのbelongs_toメソッドcounter_cacheオプションを有効にする

Review モデル

app/models/review.rb

class Review < ActiveRecord::Base
  belongs_to :book, counter_cache: true
end

これでカウンターキャッシュ機能が有効にする。

ちなみにカウンターカラムが「子テーブル名_count」という命名規則に沿っていない場合は、以下のように明示的に指定すればOK。

class Review < ActiveRecord::Base
  belongs_to :book, counter_cache: :review_num
end
カウンターキャッシュの注意点

モデルを介さずに DB を更新した場合、もしくはコールバックを利用しないメソッド(例えば delete メソッド)でモデルを操作した場合は、カウンターは正しく管理できない。

動作結果

b = Book.find(1)
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 0, created_at: "2014-10-07 09:12:25", updated_at: "2014-10-07 09:12:25">

r = b.reviews.build(body: 'good')
# => #<Review id: nil, book_id: 1, body: "good", created_at: nil, updated_at: nil>

# Review 登録
r.save
# BEGIN
# INSERT INTO `reviews` (`body`, `book_id`, `created_at`, `updated_at`) VALUES ('good', 1, '2014-10-07 09:13:07', '2014-10-07 09:13:07')
# UPDATE `books` SET `reviews_count` = COALESCE(`reviews_count`, 0) + 1 WHERE `books`.`id` = 1
# COMMIT
# => true

b.reviews_count
# => 0

b.reload
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 1, created_at: "2014-10-07 09:12:25", updated_at: "2014-10-07 09:12:25">

b.reviews_count
# => 1

子の Review を登録すると自動的に親の Book のカウンター用カラムもインクリメントされている。更新日時の更新はなし。
Insert と Update は同一トランザクションで行われている。

データが増えてくるとデッドロックが頻発するって話があるようだ。

Railsのcounter_cacheを使ったらdeadlockが頻発した - Qiita
http://qiita.com/tachiba/items/797ea74e7eeb7f32f886

3-2. カウンターキャッシュ + touch オプションを使う

belongs_to メソッドに touch オプションがあり、これを true にすると関連する親モデルの更新日時も更新してくれるらしいのでカウンターキャッシュと組み合わせてみた。

app/models/review.rb

class Review < ActiveRecord::Base
  belongs_to :book, counter_cache: true, touch: true
end

動作結果

b = Book.find(1)
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 0, created_at: "2014-10-07 12:48:22", updated_at: "2014-10-07 12:48:22">

r = b.reviews.build(body: 'good')
# => #<Review id: nil, book_id: 1, body: "good", created_at: nil, updated_at: nil>

# Review 登録
r.save
# BEGIN
# INSERT INTO `reviews` (`body`, `book_id`, `created_at`, `updated_at`) VALUES ('good', 1, '2014-10-07 12:50:00', '2014-10-07 12:50:00')
# UPDATE `books` SET `reviews_count` = COALESCE(`reviews_count`, 0) + 1 WHERE `books`.`id` = 1
# UPDATE `books` SET `books`.`updated_at` = '2014-10-07 12:50:00' WHERE `books`.`id` = 1
# COMMIT
# => true

b.reviews_count
# => 0

b.reload
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 1, created_at: "2014-10-07 12:48:22", updated_at: "2014-10-07 12:50:00">

b.reviews_count
# => 1

更新日時も更新されるようになったけど、別途 SQL を発行して更新してる。カウンター用カラムのインクリメントと同じ SQL でやってほしいところ。


4. counter_culture を使う

magnusvk/counter_culture
https://github.com/magnusvk/counter_culture

この gem はカウンターキャッシュの機能を強化したものらしい。
子モデルの Insert と親モデルの Update を同一トランザクションで行っていたものを分けることでロックを軽減する対応もされている。

Gemfile に以下を追加

gem 'counter_culture', '~> 0.1.25'

インストール

bin/bundle install

counter_culture の使い方

モデル設定

app/models/book.rb

class Book < ActiveRecord::Base
  has_many :reviews
end

app/models/review.rb

class Review < ActiveRecord::Base
  belongs_to :book
  counter_culture :book
end

子モデル側にcounter_cultureを設定する。
カラム名のルールはカウンターキャッシュと同じく「子テーブル名_count」にする。

また、counter_cultureにはカウンター更新時にタイムスタンプも一緒に更新してくれる機能がある。
以下のように touch オプションを設定すればよい。

app/models/review.rb

class Review < ActiveRecord::Base
  belongs_to :book
  counter_culture :book, touch: true
end

動作結果

b = Book.find(1)
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 0, created_at: "2014-10-07 11:26:00", updated_at: "2014-10-07 11:26:00">

r = b.reviews.build(body: 'good')
# => #<Review id: nil, book_id: 1, body: "good", created_at: nil, updated_at: nil>

# Review 登録
r.save
# BEGIN
# INSERT INTO `reviews` (`body`, `book_id`, `created_at`, `updated_at`) VALUES ('good', 1, '2014-10-07 11:26:24', '2014-10-07 11:26:24')
# COMMIT
# UPDATE `books` SET `reviews_count` = COALESCE(`reviews_count`, 0) + 1, updated_at = '2014-10-07 11:26:24' WHERE `books`.`id` = 1
# => true

b.reviews_count
# => 0

b.reload
# SELECT  `books`.* FROM `books`  WHERE `books`.`id` = 1 LIMIT 1
# => #<Book id: 1, title: "rails", reviews_count: 1, created_at: "2014-10-07 11:26:00", updated_at: "2014-10-07 11:26:24">

b.reviews_count
# => 1

Insert 後にコミットされ、その後 Update している。
不整合が起きる可能性が出てくるがその代わりロックは軽減されそう。

Update ではカウンター用カラムに加え更新日時も更新された。

やりたかったことができたのでこの counter_culture を使うやり方を採用することにした。