[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`にユニーク制約を付与 
  - (`article_id`と`count`の複合インデックスにしてカバリングインデックスしても良さそう)
- `count`のデフォルト値を`0`にした
- 対応するModel名は`ArticleAccessCount`とする

## やりたいこと
指定した`article_id`の`count`をインクリメントしたい！
もし**指定した`article_id`のレコードが存在しない場合**は、新規に作成してcountを1にしたい！

# 【結論】 こう書いた！
```ruby
# 「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`するのはまずい。
こう書くと...

```ruby
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が発行されます。

```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が発行される。素晴らしい。

```sql
UPDATE `article_access_counts` SET `count` = COALESCE(`count`, 0) + 1 WHERE `article_access_counts`.`article_id` = 'article_002';
```

なのでRail5を使ってるなら、こう書くのありになる。

```ruby
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`)です。

こう書くと... 

```ruby
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を発行してくれます。

```sql
UPDATE `article_access_counts` SET `count` = COALESCE(`count`, 0) + 1 WHERE `article_access_counts`.`id` = 2;
```

先ほどと同じく、`COALESCE`句が必要なのか、という話はあります。

## `lock`を使うのはどうか？
`SELECT...FOR UPDATE`を発行して、レコードロックして値を適切に操作すればよくない？　という話。
厳しいです。
例えばこんなふうに書くと...

```ruby
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が発行されます。

```sql
SELECT * FROM `article_access_counts` WHERE `article_access_counts`.`article_id` = 'article_002' FOR UPDATE;
```

これは、**`target_article_id`のレコードがまだ存在しない場合にやばい**です。
存在しないインデックスに対してのロックであり、**[ギャップロック](https://qiita.com/kenjiszk/items/05f7f6e695b93570a9e1)**が発生してしまいます。
今回はロックで攻めずに、**<font color="ORANGE">「稀に起こる`INSERT`の失敗」を許容する実装</font>**の方がシンプルでわかりやすいと思います。

## `updated_at`カラムがある場合、それも更新したいんだけど？
これ、すごく重要なポイント。
ここまでに登場したRails4の`UPDATE`系メソッド、`update_all`,`increment`,`increment_counter`は、
どれも**`updated_at`を自動では更新しません**。
なので、**`updated_at`**カラムを定義していて、それも更新したい場合は、以下あたりを行う必要があります。

- **<font color="ORANGE">Rails5なら、`increment_counter`に`touch`引数が実装されているので、そこで指定すればよいだけ</font>**。最高ですね。(参考: [Rails5 ドキュメント increment_counter](https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html#method-i-increment_counter))
- `update_all`で、`updated_at`の値も指定する
- 別途`touch`を実行する

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

```ruby
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](https://github.com/zdennis/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'
```

しかしこの可能性は極めて低く、また起きたとしても最初の頃のみ。
よって今回は、「**<font color="ORANGE">`create`で失敗した場合はクライアントにリトライしてもらう</font>**」のが良さそうだと考え、`rescue`で捕捉することはしていない。

## `find_or_create_by`じゃダメなの？
まずいです。
先述の通り、`create`は失敗する可能性があるため。
失敗した時にここでエラー出してトランザクション終了しないと、次の`UPDATE`処理が走っちゃう。
`!`をつけておきましょう。

# おわりに
仮にRails5なら、**[increment_counter (Rails5版)](https://api.rubyonrails.org/classes/ActiveRecord/CounterCache/ClassMethods.html#method-i-increment_counter)**使いたいなーと思いました。`touch`引数あるのは地味にでかいですね。

# 参考

## 各種ドキュメント

### Rails関連

- [increment](https://apidock.com/rails/ActiveRecord/Persistence/increment)
- [increment!](https://apidock.com/rails/ActiveRecord/Persistence/increment%21)
- [increment! (Rails5版)](https://api.rubyonrails.org/classes/ActiveRecord/Persistence.html#method-i-increment-21)
- [increment_counter](https://apidock.com/rails/ActiveRecord/CounterCache/ClassMethods/increment_counter)
- [update_counter](https://apidock.com/rails/v4.2.7/ActiveRecord/CounterCache/ClassMethods/update_counters)
- [activerecord-import](https://github.com/zdennis/activerecord-import)
- [find_by](https://apidock.com/rails/ActiveRecord/FinderMethods/find_by)
- [find_or_create_by](https://apidock.com/rails/v4.2.7/ActiveRecord/Relation/find_or_create_by)
- [find_or_initialize_by](https://apidock.com/rails/v4.2.7/ActiveRecord/Relation/find_or_initialize_by)
- [where](https://apidock.com/rails/v4.2.7/ActiveRecord/QueryMethods/where)
- [update_all](https://apidock.com/rails/v4.2.7/ActiveRecord/Relation/update_all)

### MySQL関連

- [内部ロック方法](https://dev.mysql.com/doc/refman/5.6/ja/internal-locking.html)
- [InnoDBのレコード、ギャップ、およびネクストキーロック](https://dev.mysql.com/doc/refman/5.6/ja/innodb-record-level-locks.html)

## ブログなど

- [なかったらINSERTしたいし、あるならロックとりたいやん？](https://www.slideshare.net/ichirin2501/insert-51938787)
- [ActiveRecord の find_or_create_by を確実に実行するには](https://qiita.com/kyrieleison/items/59d293594fbe3a139129)
- [Railsにおけるレースコンディションの例とその回避方法](http://d.hatena.ne.jp/LukeSilvia/20100130/p1)
- [レコードがなかったらINSERTして返すみたいなのを確実にやる](http://www.songmu.jp/riji/entry/2014-03-02-find-or-create-surely.html)
- [Rails4でカウンター用カラムをインクリメントする](http://xyk.hatenablog.com/entry/2014/10/07/212304)
- [Rails UPDATE or INSERT](https://nowdeveloping.blogspot.com/2016/12/rails-update-or-insert.html)
- [漢のコンピュータ道 InnoDBのREPEATABLE READにおけるLocking Readについての注意点](http://nippondanji.blogspot.com/2013/12/innodbrepeatable-readlocking-read.html)
- [良く分かるMySQL Innodbのギャップロック](https://qiita.com/kenjiszk/items/05f7f6e695b93570a9e1)

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