ユニーク制約を貼ってないテーブルで、ActiveRecord::RecordNotUniqueエラーを発生させる

並列実行される処理の中で、テーブルで同じカラムを追加されるのを防ぎたい！
だけど・・・ユニーク制約を貼りたいけど、諸事情があって貼れない場合の対処法です😇

# 環境
- Rails
  - ActiveRecord
- MySQL

### 構成
![図1.png](https://qiita-image-store.s3.amazonaws.com/0/49238/f63fcf6e-b9f9-0cb4-5e0d-cd64c488dc13.png)

- サーバ2台
- データベース1台

 
ざくっと絵を書きましたが、PCがアクセスすると

- ロードバランサがサーバを割り振る
- 割り当てられたサーバは処理を行う
  - 場合によっては、DBへアクセスする

みたいな感じです。

# `ActiveRecord::RecordInvalid`例外を発生させてみる
- `validates`のuniqueness制約を設定します。

```rb:app/models/post.rb
class Post < ActiveRecord::Base
  # 複数カラムに対し、ユニーク制約つける
  # user_idとtitleユニークであること
  validates :user_id, uniqueness: { scope: :title }
end
```

`ActiveRecord::RecordInvalid`例外が発生するタイミングは、

- `ActiveRecord::Base#save!`
- `ActiveRecord::Base#create!`

でバリデーションエラーが発生した時です。

`create!`メソッドを使い、例外が発生するようにします。

```rb:app/controllers/user_controller.rb
class UserController
  def create
    begin
      User.create!(user_id: <user_id>, title: <title>)
    rescue ActiveRecord::RecordInvalid
      head :conflict
    end
  end
end
```

問題は、**いつ**、バリデーションチェックが行われるか・・・です。
タイミング次第では、先にテーブルに書き込みが行われてしまいます😇

では、バリデーションチェックの流れを見ていきます。


まず、トランザクションが貼られ、ユニーク制約のバリデーションのチェックが実行されます。

ここで、バリデーションエラーが

- 発生すると、ロールバック
- 発生しないと、レコード挿入 → コミット

実行されます。


## 問題点
レコードの書き込み前に、チェックが行われるので、`レコード挿入 → コミット`の間に、同じレコードが作成可能です。

これでは、やりたいことがちょっと違う。。。

# `ActiveRecord::RecordNotUnique`例外をコールバック処理で発生させる
- `after_save`で複数レコードが存在している場合、例外を発生させる。
 
`after_create`は、**DBのコミット直前に実行**されます。つまり、同一トランザクション内です。
これで、先にあげた問題点は解決できそうです。

```rb:app/models/post.rb
class Post < ActiveRecord::Base
  after_create do
    if Post.where(user_id: self.user_id, title: self.title).count > 1
      raise ActiveRecord::RecordNotUnique.new(self)
    end
  end
end
```

レコード作成 → `after_create`で同一レコードが存在していないかチェックします。

`after_create`内では、同じレコードが何件存在するかチェックし、

- 1件より多い場合、`ActiveRecord::RecordNotUnique`例外を発生 → ロールバック
- 1件以下の場合、コミット

を行います。


```rb:app/controllers/user_controller.rb
class UserController
  def create
    begin
      User.create!(user_id: <user_id>, title: <title>)
    rescue ActiveRecord::RecordNotUnique
      head :conflict
    end
  end
end
```

これで問題解決！！・・・と思いきや、まだもう一つ問題が残っています

## 問題点
下記のSELECT文では他のトランザクションのコミットを見ることができません。
これでは、並行に処理が走った場合、同じレコードが挿入されてしまう可能性があります。

```rb:after_createの処理
after_create do
  # これで発行されるSQLではNG！
  if Post.where(user_id: self.user_id, title: self.title).count > 1
    raise ActiveRecord::RecordNotUnique.new(self)
  end
end
```
## 原因
MySQLのデフォルトのアイソレーションレベル！（トランザクション分離レベルのこと）
MySQLは、デフォルトで`REPEATABLE READ`です。

また、MySQLの場合、このレベルでは、本来発生する**ファントムリードが起きない**です。
※ ファントムリードとは、別のトランザクションでインサートされたデータが見える状態です。

つまり、今発行されるSQLでは、他のトランザクション内のインサートを読み込むことができません。


## 解決策
MySQLのアイソレーションレベルを`READ COMMITTED`レベルに変更することで解決します。
方法としては、

- トランザクション全体のアイソレーションレベルを変更する
- 発行されるSELECT文の時、アイソレーションレベルを変更する

の2パターン考えられます。

今回は、**発行されるSELECT文の時、アイソレーションレベルを変更**できれば良いので、この方法を使います。

発行されるSQLの末尾に`FOR UPDATE`を追加することで、`READ COMMITTED`レベル相当の動きになります。

- [MySQL - InnoDBのロック関連まとめ](http://qiita.com/mizzwithliam/items/31fb68217899bd0559e8)で、詳しく解説されています。

```sql:SQLの例
SELECT * FROM posts FOR UPDATE
```



Railsで発行されるSQLに`FOR UPDATE`をつけるには、 `lock`メソッドが用意されています。


```rb:lockメソッドを使う
after_create do
  # lockメソッドを使う
  if Post.lock.where(user_id: self.user_id, title: self.title).count > 1
    raise ActiveRecord::RecordNotUnique.new(self)
  end
end
```

トランザクション全体のアイソレーションレベルを変更する方法が気になる方は、こちらのブログにまとめられているのでどうぞ。

- [Rails4 でトランザクション分離レベルを設定する](http://blog.toshimaru.net/rails-4-transaction-isolation/)


これで、問題解決です！👍

## 気なる話
気なることといえば、ロックと処理スピードですかね。

#### デッドロック
今回かけているロックは、**排他ロックであるため、デッドロックは起きません**。
（共有ロックだったら、 🙅）

また、仮にMySQLがデッドロックを検知すると、トランザクションはロールバックされます。
他にも自動でロールバックされるタイミングは、ロック待機が一定時間ことを検知すると行われます。


#### 処理スピード
一方で、**パフォーマンスは低下する可能性**があります（同時に、RDBヘ今回の処理が行われた場合）。
理由は、今回の処理が、シリアライザブルな処理になるためです。

シリアライザブルな処理とは、**複数の並行に動作するトランザクションの結果が、どんな場合でも、それらのトランザクションを時間的重なりなく逐次実行した場合と同じ結果となること**です。

パフォーマンスを気にする場合、重複レコードの許容性、アクセス数などを考慮し、一番最初に紹介したバリデーション処理が良いかもしれません。
それか、ちゃんと**ユニーク制約を貼りましょう**。 ← これが一番いい🌟


# まとめ
### 読んだコード
- `ActiveRecord::RecordInvalid`例外について
  - [ActiveRecord::Base#create!](https://github.com/rails/rails/blob/9d112c2527ab50c519fdaf21a8d432b5ca18bc85/activerecord/lib/active_record/persistence.rb#L46)
  - [ActiveModel::Validations](https://github.com/rails/rails/blob/cdb9d7f481a6b299cd53a0f647397da60d806a21/activemodel/lib/active_model/validations.rb)

- `ActiveRecord::RecordNotUnique`例外について
  - [ActiveRecord::ConnectionAdapters::DatabaseStatements](https://github.com/rails/rails/blob/00f1f95569a491c89bf78f08b78ebf0e6ff1d9d1/activerecord/lib/active_record/connection_adapters/abstract/database_statements.rb)
  - [ActiveRecord::ConnectionAdapters::AbstractMysqlAdapter](https://github.com/rails/rails/blob/8b69e32412cc2867b5fdd9a33cf4e4e759057e95/activerecord/lib/active_record/connection_adapters/abstract_mysql_adapter.rb#L620)

### 参考
- [MySQLでトランザクションの4つの分離レベルを試す](http://d.hatena.ne.jp/fat47/20140212/1392171784)
- [Railsガイド - 悲観的ロック](https://railsguides.jp/active_record_querying.html#%E6%82%B2%E8%A6%B3%E7%9A%84%E3%83%AD%E3%83%83%E3%82%AF-pessimistic)
- [不整合が起きてはならない場合、トランザクションはシリアライザブル](https://blog.ohgaki.net/use-serializable-transaction-isolation-level)
- [デッドロックの検出とロールバック](https://dev.mysql.com/doc/refman/5.6/ja/innodb-deadlock-detection.html)
- [InnoDB のエラー処理](https://dev.mysql.com/doc/refman/5.6/ja/innodb-error-handling.html)

### 心の声
ちゃんと、ユニーク制約貼りましょうね・・・（震え声）