並列実行される処理の中で、テーブルで同じカラムを追加されるのを防ぎたい!
だけど・・・ユニーク制約を貼りたいけど、諸事情があって貼れない場合の対処法です😇
環境
- Rails
- ActiveRecord
- MySQL
構成
- サーバ2台
- データベース1台
ざくっと絵を書きましたが、PCがアクセスすると
- ロードバランサがサーバを割り振る
- 割り当てられたサーバは処理を行う
- 場合によっては、DBへアクセスする
みたいな感じです。
ActiveRecord::RecordInvalid
例外を発生させてみる
-
validates
のuniqueness制約を設定します。
class Post < ActiveRecord::Base
# 複数カラムに対し、ユニーク制約つける
# user_idとtitleユニークであること
validates :user_id, uniqueness: { scope: :title }
end
ActiveRecord::RecordInvalid
例外が発生するタイミングは、
ActiveRecord::Base#save!
ActiveRecord::Base#create!
でバリデーションエラーが発生した時です。
create!
メソッドを使い、例外が発生するようにします。
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のコミット直前に実行されます。つまり、同一トランザクション内です。
これで、先にあげた問題点は解決できそうです。
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件以下の場合、コミット
を行います。
class UserController
def create
begin
User.create!(user_id: <user_id>, title: <title>)
rescue ActiveRecord::RecordNotUnique
head :conflict
end
end
end
これで問題解決!!・・・と思いきや、まだもう一つ問題が残っています
問題点
下記のSELECT文では他のトランザクションのコミットを見ることができません。
これでは、並行に処理が走った場合、同じレコードが挿入されてしまう可能性があります。
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のロック関連まとめで、詳しく解説されています。
SELECT * FROM posts FOR UPDATE
Railsで発行されるSQLにFOR UPDATE
をつけるには、 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
トランザクション全体のアイソレーションレベルを変更する方法が気になる方は、こちらのブログにまとめられているのでどうぞ。
これで、問題解決です!👍
気なる話
気なることといえば、ロックと処理スピードですかね。
デッドロック
今回かけているロックは、排他ロックであるため、デッドロックは起きません。
(共有ロックだったら、 🙅)
また、仮にMySQLがデッドロックを検知すると、トランザクションはロールバックされます。
他にも自動でロールバックされるタイミングは、ロック待機が一定時間ことを検知すると行われます。
処理スピード
一方で、パフォーマンスは低下する可能性があります(同時に、RDBヘ今回の処理が行われた場合)。
理由は、今回の処理が、シリアライザブルな処理になるためです。
シリアライザブルな処理とは、複数の並行に動作するトランザクションの結果が、どんな場合でも、それらのトランザクションを時間的重なりなく逐次実行した場合と同じ結果となることです。
パフォーマンスを気にする場合、重複レコードの許容性、アクセス数などを考慮し、一番最初に紹介したバリデーション処理が良いかもしれません。
それか、ちゃんとユニーク制約を貼りましょう。 ← これが一番いい🌟
まとめ
読んだコード
-
ActiveRecord::RecordInvalid
例外について -
ActiveRecord::RecordNotUnique
例外について
参考
- MySQLでトランザクションの4つの分離レベルを試す
- Railsガイド - 悲観的ロック
- 不整合が起きてはならない場合、トランザクションはシリアライザブル
- デッドロックの検出とロールバック
- InnoDB のエラー処理
心の声
ちゃんと、ユニーク制約貼りましょうね・・・(震え声)