Ruby
Rails
MySQL

[Rails+MySQL] カウンターの実装 【なければ新規作成したいし、あれば適切にインクリメントしたい】

はじめに

何らかのカウンター(アクセス集計など)を実装することは多いですが、
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にユニーク制約を付与
  • countのデフォルト値を0にした
  • 対応するModel名はArticleAccessCountとする

やりたいこと

指定したarticle_idcountをインクリメントしたい!
もし指定した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にはincrementincrement!という、いかにもなメソッドがある。
しかし、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_countertouch引数が実装されているので、そこで指定すればよいだけ。最高ですね。(参考: Rails5 ドキュメント increment_counter)
  • update_allで、updated_atの値も指定する
  • 別途touchを実行する

毎回createupdateすればよくない?

よくないです。
確かに、こんな感じで毎度INSERTさせてみる処理を書いて、updateもさせればデータ的に問題はないでしょう。
(INSERTの失敗をハナから許容する実装なので、create!ではなくcreate)

ArticleAccessCount.transaction do
  article_access_count = 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関連

MySQL関連

ブログなど


  1. シャーディングの話もとても重要ですが、今回は省略します。