LoginSignup
21
21

More than 5 years have passed since last update.

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

Posted at

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

環境

  • Rails
    • ActiveRecord
  • MySQL

構成

図1.png

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

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

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

みたいな感じです。

ActiveRecord::RecordInvalid例外を発生させてみる

  • validatesのuniqueness制約を設定します。
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!メソッドを使い、例外が発生するようにします。

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のコミット直前に実行されます。つまり、同一トランザクション内です。
これで、先にあげた問題点は解決できそうです。

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件以下の場合、コミット

を行います。

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文では他のトランザクションのコミットを見ることができません。
これでは、並行に処理が走った場合、同じレコードが挿入されてしまう可能性があります。

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レベル相当の動きになります。

SQLの例
SELECT * FROM posts FOR UPDATE

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

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ヘ今回の処理が行われた場合)。
理由は、今回の処理が、シリアライザブルな処理になるためです。

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

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

まとめ

読んだコード

参考

心の声

ちゃんと、ユニーク制約貼りましょうね・・・(震え声)

21
21
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
21
21