はじめに
何らかのカウンター(アクセス集計など)を実装することは多いですが、
RailsでDBの値をインクリメントするにあたって、ロックや整合性をどう上手く考慮するかで悩んだのでまとめました。1
環境は**Rails4.2
**+MySQL5.6
です。
Rails5特有の書き方については、補足の中で言及しています。
「適切に」とは
- 複数のクライアントから、ほぼ同時に
UPDATE
されても整合性を保ちたい - デッドロック発生の可能性はもちろん潰したい
こんなテーブルを例に
テーブル定義
// テーブル名: article_access_counts
+----+-------------+-------+
| id | article_id | count |
+----+-------------+-------+
| 1 | article_001 | 3 |
| 2 | article_002 | 21 |
| 3 | article_003 | 11 |
| 4 | article_007 | 0 |
| 5 | article_009 | 0 |
| 6 | article_012 | 6 |
+----+-------------+-------+
// テーブル作成のコマンド
mysql> create table `article_access_counts`
-> ( `id` int auto_increment, `article_id` varchar(255) unique, `count` int not null default 0,
-> primary key (`id`), index(`article_id`) )
-> ENGINE=InnoDB DEFAULT CHARSET=utf8;
-
article_id
カラムにindexをはる -
article_id
にユニーク制約を付与- (
article_id
とcount
の複合インデックスにしてカバリングインデックスしても良さそう)
- (
-
count
のデフォルト値を0
にした - 対応するModel名は
ArticleAccessCount
とする
やりたいこと
指定したarticle_id
のcount
をインクリメントしたい!
もし指定したarticle_id
のレコードが存在しない場合は、新規に作成してcountを1にしたい!
【結論】 こう書いた!
# 「target_article_id」として指定IDが渡ってきたものとする
ArticleAccessCount.transaction do
ArticleAccessCount.find_or_create_by!(article_id: target_article_id)
ArticleAccessCount.where(article_id: target_article_id).update_all('count = count + 1')
end
-
find_or_create_by!
により、「存在しなければINSERT
」を行う。また、INSERT
に失敗した場合は、エラーを返す。 -
update_all('count = count + 1')
とすることで、値のインクリメントをMySQL側に委ねる。
補足
find_by
してインクリメントしてsave
するのはどう?
find
で取得したModelのcountフィールドをインクリメントしてsave
するのはまずい。
こう書くと...
ArticleAccessCount.transaction do
article_access_count = ArticleAccessCount.find_by!(article_id: target_article_id)
article_access_count.save!(count: article_access_count.count + 1)
end
こんな感じで、SETの値が決め打ちのSQLが発行されます。
UPDATE `article_access_counts` SET `count` = 22 WHERE `article_access_counts`.`article_id` = 'article_002';
*ほぼ同時にリクエストがとんできたら...*と考えるとつらいですね。
Rails4だと、increment!
の採用は厳しい
Railsにはincrement
やincrement!
という、いかにもなメソッドがある。
しかし、find
で取得したModelに対してそれを行うのは、上記と同じ理由でまずい。
どちらのメソッドも、Modelの値をインクリメントしてその値をsave
してるにすぎないからです。残念。
Rails5なら、increment!
の採用も視野
Rails5のincrement!
は一味違う。
こんな感じのSQLが発行される。素晴らしい。
UPDATE `article_access_counts` SET `count` = COALESCE(`count`, 0) + 1 WHERE `article_access_counts`.`article_id` = 'article_002';
なのでRail5を使ってるなら、こう書くのありになる。
ArticleAccessCount.transaction do
article_access_count = ArticleAccessCount.find_or_create_by!(article_id: target_article_id)
article_access_count.increment!(:count)
end
ただ、COALESCE
句を含んでおり、これが不要なのであれば、where & update_all
で良さそう。
ここはチームの方針次第といった感じ。
increment_counter
を使うのはあり?
Rails4のincrement!
は弱い...と悲しみながらドキュメントを読んでると、increment_counter
を見つけるかもしれません。
このメソッドは強いです。Rails5であればこれを使いたい(詳しくは後述)。
なお、これはincrement!
と異なり、クラスメソッド(ActiveRecord::CounterCache#increment_counter
)です。
こう書くと...
ArticleAccessCount.transaction do
article_access_count = ArticleAccessCount.find_or_create_by!(article_id: target_article_id)
ArticleAccessCount.increment_counter(:count, article_access_count.id)
end
いい感じのSQLを発行してくれます。
UPDATE `article_access_counts` SET `count` = COALESCE(`count`, 0) + 1 WHERE `article_access_counts`.`id` = 2;
先ほどと同じく、COALESCE
句が必要なのか、という話はあります。
lock
を使うのはどうか?
SELECT...FOR UPDATE
を発行して、レコードロックして値を適切に操作すればよくない? という話。
厳しいです。
例えばこんなふうに書くと...
ArticleAccessCount.transaction do
article_access_count = ArticleAccessCount.lock.find_by(article_id: target_article_id)
article_access_count.save!(:count, article_access_count.count + 1)
end
lock.find_by
の部分で、こんな感じのSELECT...FOR UPDATE
というSQLが発行されます。
SELECT * FROM `article_access_counts` WHERE `article_access_counts`.`article_id` = 'article_002' FOR UPDATE;
これは、target_article_id
のレコードがまだ存在しない場合にやばいです。
存在しないインデックスに対してのロックであり、**ギャップロックが発生してしまいます。
今回はロックで攻めずに、「稀に起こるINSERT
の失敗」を許容する実装**の方がシンプルでわかりやすいと思います。
updated_at
カラムがある場合、それも更新したいんだけど?
これ、すごく重要なポイント。
ここまでに登場したRails4のUPDATE
系メソッド、update_all
,increment
,increment_counter
は、
どれも**updated_at
を自動では更新しません**。
なので、**updated_at
**カラムを定義していて、それも更新したい場合は、以下あたりを行う必要があります。
-
Rails5なら、
increment_counter
にtouch
引数が実装されているので、そこで指定すればよいだけ。最高ですね。(参考: Rails5 ドキュメント increment_counter) -
update_all
で、updated_at
の値も指定する - 別途
touch
を実行する
毎回create
とupdate
すればよくない?
よくないです。
確かに、こんな感じで毎度INSERT
させてみる処理を書いて、update
もさせればデータ的に問題はないでしょう。
(INSERTの失敗をハナから許容する実装なので、create!
ではなくcreate
)
ArticleAccessCount.transaction do
ArticleAccessCount.create(article_id: target_article_id, count: 1)
ArticleAccessCount.where(article_id: target_article_id).update_all('count = count + 1')
end
しかし、カウンターは「更新処理UPDATE
の回数の方が、新規作成INSERT
よりも圧倒的に多い」はずです。
なので、これは「基本的に失敗する処理(INSERT
)を毎回行う」悲しい実装だと言えます。
避けましょう。
INSERT...ON DUPLICATED KEY UPDATE...
したいんだけど?
id
カラムのオートインクリメントが気にならないのであれば、INSERT...ON DUPLICATED KEY UPDATE update = update + 1
みたいなSQLを発行できるのが理想に思えます。
しかし、Railsにその機能はないので、やるなら生クエリを書いて実行することになります。
基本的にはRailsにのっかって実装したいはずなので、避けることが多いと思われます。
チームの方針としてそれを良しとするなら、ありの選択肢ですね。
ちなみに、ON DUPLICATED KEY
をするだけならactiverecord-importというライブラリを使えばいけなくもないですが、これは一括INSERT用のライブラリであって、UPDATE
句にcount = count + 1
みたいな指定はできません。
rescue
でエラーを拾うかどうか
場合による。
今回の場合、find_or_create_by!
の部分、具体的にはcreate
すなわちINSERT
で失敗する可能性がある。
クライアントがほぼ同時に、同一のarticle_id
に対してINSERT
した場合、以下のようなkey違反のエラーが発生しうる。
Duplicate entry 'article_002' for key 'article_id'
しかしこの可能性は極めて低く、また起きたとしても最初の頃のみ。
よって今回は、「create
で失敗した場合はクライアントにリトライしてもらう」のが良さそうだと考え、rescue
で捕捉することはしていない。
find_or_create_by
じゃダメなの?
まずいです。
先述の通り、create
は失敗する可能性があるため。
失敗した時にここでエラー出してトランザクション終了しないと、次のUPDATE
処理が走っちゃう。
!
をつけておきましょう。
おわりに
仮にRails5なら、**increment_counter (Rails5版)**使いたいなーと思いました。touch
引数あるのは地味にでかいですね。
参考
各種ドキュメント
Rails関連
- increment
- increment!
- increment! (Rails5版)
- increment_counter
- update_counter
- activerecord-import
- find_by
- find_or_create_by
- find_or_initialize_by
- where
- update_all
MySQL関連
ブログなど
- なかったらINSERTしたいし、あるならロックとりたいやん?
- ActiveRecord の find_or_create_by を確実に実行するには
- Railsにおけるレースコンディションの例とその回避方法
- レコードがなかったらINSERTして返すみたいなのを確実にやる
- Rails4でカウンター用カラムをインクリメントする
- Rails UPDATE or INSERT
- 漢のコンピュータ道 InnoDBのREPEATABLE READにおけるLocking Readについての注意点
- 良く分かるMySQL Innodbのギャップロック
-
シャーディングの話もとても重要ですが、今回は省略します。 ↩