環境:
rails (4.1.6)
activerecord (4.1.6)
mysql2 (0.3.16)
Rails4 というか Rails でカウンター用カラムをインクリメントする簡単な方法がないか調べてみた。
やりたいこと
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 を使うやり方を採用することにした。