ActiveRecord の find_or_create_by を確実に実行するには

[Gaiax Advent Calendar 2016](http://qiita.com/advent-calendar/2016/gaiax) の21日目の記事です。

ActiveRecord の find_or_create_by で苦労したので、それをまとめておきたいと思います。

---

まずは、以下のような状況を仮定します。

- あるタスクが完了したら、その日分の TaskReport の count をインクリメントする
- その日分のTaskReportレコードがあれば更新する、なければ作成する

```sql
CREATE TABLE `task_reports` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `date` date DEFAULT NULL,
  `count` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
)
```

```ruby
task_report = TaskReport.find_or_create_by(date: Date.today)
task_report.increment!(:count)
```

このコードで実行されるSQL文は下記のとおりです。

```sql
SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
BEGIN
INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
COMMIT
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1
```

TaskReportレコードが存在する場合は以下のようになります。

```sql
SELECT  `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1
```

ここで、複数プロセスA, Bが起動していて同時に処理が走った場合を考えます。この場合、以下のようなことが起こる可能性があります。

```sql
A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 <- この時点ではまだTaskReportがない
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- 二つ目のTaskReportが作成されてしまう
A> COMMIT
B> COMMIT
A> UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1
B> UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 1
```

# ユニーク制約をかける

これではTaskReportが複数作成されてしまいます。ので、ユニーク制約をかけることで防止します。

```
CREATE TABLE `task_reports` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `date` date DEFAULT NULL,
  `count` int(11) DEFAULT NULL,
  PRIMARY KEY (`id`),
  UNIQUE KEY `index_task_reports_on_date` (`date`)
)
```

```sql
A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- Duplicate entry '2016-12-22' for key 'index_task_reports_on_date' でコケる
```

ひとまずTaskReportが複数作成されてしまうことは防げましたが、プロセスBにはプロセスAの作成を待ってから取得を行ってほしいですね。

# 悲観ロックをかける

そこでデータの取得時に悲観ロックをかけてみます。
モデルの `lock` メソッドを使って、`find_or_create_by` にロックをかけました。

参考: [Railsのデータロック](http://qiita.com/merrill/items/d9d41d64df292bd6432a)

```ruby
task_report = TaskReport.lock.find_or_create_by(date: Date.today)
task_report.increment!(:count)
```

発行されるSQL文は`SELECT ~ FOR UPDATE`に変わります。

```sql
SELECT  `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE
BEGIN
INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22')
COMMIT
UPDATE `task_reports` SET `count` = COALESCE(`count`, 0) + 1 WHERE `task_reports`.`id` = 5
```

これを同時に実行されると、下記のようになります。

```sql
A> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE <- 次のINSERTまで他のINSERTをブロック
B> SELECT `task_reports`.* FROM `task_reports` WHERE `task_reports`.`date` = '2016-12-22' LIMIT 1 FOR UPDATE <- 次のINSERTまで他のINSERTをブロック
A> BEGIN
B> BEGIN
A> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- 待ち状態になる
B> INSERT INTO `task_reports` (`date`) VALUES ('2016-12-22') <- Deadlock found when trying to get lock; try restarting transaction でコケて、AのINSERTが成功する
```

プロセスAは次のデータ更新があるまでその対象の行をロックし、プロセスBはロック解除まで待つようになります。
しかしプロセスBも同時に行ロックを行うため、互いにロックがかかった状態になりデッドロックに...。

参考: [デッドロックを回避するために](https://cloudear.jp/blog/?p=1335)

また、このSQLにはもう一つ問題があります。
`SELECT ~ FOR UPDATE` は既に存在するデータに対してであれば、その対象の行だけをロックしますが、この場合は `SELECT ~ FOR UPDATE` の先が存在しないため、ギャップロックが発生します。
例えば、TaskReport に id = 10 までデータがあるとして、存在しない id = 100 にロックをかけると、id > 10 の行すべてにロックがかかってしまいます。

参考: [良く分かるMySQL Innodbのギャップロック](http://qiita.com/kenjiszk/items/05f7f6e695b93570a9e1)

# 悲観ロックせずにロールバック

最終的には以下のようになりました。(Retryable gemを使用しています)

```ruby
require 'retryable'

Retryable.retryable(:on => [ActiveRecord::RecordNotUnique], :tries => 5) do
  ActiveRecord::Base.transaction do
    task_report = TaskReport.find_or_create_by(date: Date.today)
    task_report.increment!(:count)
  end
end
```

悲観ロックはせず、`Duplicate entry ...` (ActiveRecord だと ActiveRecord::RecordNotUnique) が発生した場合にロールバックしてリトライするようにしました。
リトライ後は既にレコードが追加されている状態なので、後発の処理での `INSERT` は実行されません。

また、もう一つ `INSERT ~ ON DUPLICATE KEY UPDATE` を使用する方法もありますが、その場合はSQLが発行される度に `AUTO_INCREMENT` の値が増えていってしまう問題があります。

今回でいくつかの方法を調べましたが、最終的にはこのような方法に落ち着くようです。
下記の記事を参考にさせていただいたので、確認してみてください。

参考:
- [ActiveRecordのfind_or_create_byが投げるクエリを検証した](http://macotasu.hatenablog.jp/entry/2016/01/09/205003)
- [なかったらINSERTしたいし、あるならロック取りたいやん?](http://www.slideshare.net/ichirin2501/insert-51938787)
- [Rails におけるレースコンディションの例とその回避方法](http://d.hatena.ne.jp/LukeSilvia/20100130/p1)
- [レコードがなかったらINSERTして返すみたいなのを確実にやる](http://www.songmu.jp/riji/entry/2014-03-02-find-or-create-surely.html)