🗂️

Rails の create_or_find_by がレコードの作成も検索も失敗する

2024/03/07に公開

こんにちは、simomu です。 今回は ActiveRecord の create_or_find_by の話をします。
以降は断りのない場合は

  • Ruby on Rails 7.0
  • MySQL 8.0 (InnoDB)

の環境下での話とします。

ActiveRecord の create_or_find_by

ActiveRecord には create_or_find_by というメソッドが存在しています。
このメソッドは、引数に指定されたカラムの値でレコードの作成を試み、既にテーブルに同じ値のレコードが存在していた場合は引数に指定されたカラムの値でレコードの検索する挙動をします。
引数に指定するカラムは UNIQUE 制約が入っていることが前提です。

https://github.com/rails/rails/blob/7-0-stable/activerecord/lib/active_record/relation.rb#L209-L213

似たようなメソッドに find_or_create_by というものがありますが、こちらは「先にレコードの検索をして、存在していなければレコードの作成をする」という挙動の違いがあります。

https://github.com/rails/rails/blob/5bf5344521a6f305ca17e0004273322a0a26f50a/activerecord/lib/active_record/relation.rb#L168-L170

create_or_find_byfind_or_create_by の使い分けとしては、作成 or 検索を行う操作が複数同時に発生する可能性がある場合は create_or_find_by を使うということが考えられます。

例えば、find_or_create_by がA、B同時に実行される場合、以下のような状況が起こり得ます。

# A                         # B
User.find_by(uid: 'user1')
                            User.find_by(uid: 'user1') # not found
User.create(uid: 'user1')
                            User.create(uid: 'user1') # raise ActiveRecord::RecordNotUnique  

B では User の検索が行われ、対象レコードが見つからなかったためにレコードの作成を行おうとしますが、その実行前に A で対象レコードが作成されてしまったため、B ではレコードの作成に失敗してしまいます。

create_or_find_by によって、作成を先に実行する場合は以下のようになります。

# A                         # B
User.create(uid: 'user1')
                            User.create(uid: 'user1') # record not unique
# create したので find しない
                            User.find_by(uid: 'user1') # found

A と B で同時にレコードの作成が走った場合でも対象カラムに UNIQUE 制約が入っているため A と B のどちらかは UNIQUE 制約違反で失敗します。
レコードの作成に失敗したため、 B の方ではテーブルにレコードが確実に存在しているはずなのでレコードの取得が成功します。
結果的に、find_or_create_by と比べてレコードの作成 or 検索のどちらかは実行されることになります。

create_or_find_by で作成も検索も失敗する

先述の通り、create_or_find_by を用いれば「レコードが存在しなければ作成し、存在していればそれを取得する」という挙動が期待できます。
しかしながら、create_or_find_by を トランザクション内で用いた場合、
UNIQUE 制約によりレコード作成が失敗したのにも関わらず、該当レコードを取得することができない
という現象が発生する場合があります。

# User(usersテーブル) には uid カラムがあり、UNIQUE 制約がかかっている
# T2
t = Thread.new do
  sleep 0.1
  User.create(uid: 'user1')
end

# T1
User.transaction do
  User.find_by(uid: 'user1')
  sleep 0.2

  # T2 で作成された User が見つかることを期待するが、ActiveRecord::RecordNotFound が発生する
  User.create_or_find_by(uid: "user1") 
end

t.join

上記のコードを実行すると create_or_find_by において、レコードの作成も検索も失敗し、ActiveRecord::RecordNotFound エラーが発生します。

これが発生する原因は、MySQL のデフォルトのトランザクション分離レベルが REPEATABLE READ であることで、ファントムリードが防がれることが原因です。

分離レベル ダーティリード ファジーリード ファントムリード
READ UNCOMMITTED
READ COMMITTED
REPEATABLE READ
SERIALIZABLE

✕: 該当リード現象が防がれない
◯: 該当リード現象が防がれる

上記の表はトランザクション分離レベルの説明においてよく見られる表で、
トランザクション分離レベルが REPATABLE READ の場合はファントムリードが発生する可能性があるとされますが、
これは厳密には各 RDBMS によって異なり、
MySQL のドキュメントを読むとトランザクション分離レベルが REPEATABLE READ の時はトランザクション内の読み取りは一貫している(= ファントムリードが防がれる)とあります。

https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

先程のサンプルコードの場合だと、

# T2
t = Thread.new do
  sleep 0.1
  User.create(uid: 'user1')
end

# T1
User.transaction do
  User.find_by(uid: 'user1') # A
  sleep 0.2

  User.create_or_find_by(user_id: "user1") # B
end

t.join

T1 ではトランザクション開始後の最初に users テーブルに SELECT を実行しています。( A )
この時点で T1 側では Aの SELECT 実行時点の結果が一旦確定し、
別のトランザクションで users テーブルが更新されても SELECT の結果は変わらないようになります。(=ファントムリードの防止)

次に T2 側で users テーブルに INSERT が実行されます。
その後に T1 側は create_or_find_by で、まずレコードを INSERT しようとしますが、
T2 側ですでに INSERT 済みなので当然 ActiveRecord::RecordNotUnique になります。

そのあと create_or_find_by はレコードが作成できなかったため SELECT しようとしますが、
同一トランザクション内の SELECT の結果は一貫するようになっているため、 B時点で SELECT しても A 時点の結果しか返って来なくて、T2 から INSERT されたレコードを見ることができず、
ActiveRecord::RecordNotFound が発生します。

対処方法

対処方法としては、トランザクション分離レベルをファントムリードを許容する READ COMMITTEDに落とす、もしくは SERIALIZABLE にする(強制的にトランザクションを順序付けて処理する)等が挙げられます。
また、詳細は後述しますが Rails 7.1 へアップデートすることでも解決ができます。

PostgreSQL のときはどうなるのか

PostgreSQL の場合は、デフォルトのトランザクション分離レベルが READ COMMITTED であり、
ファントムリードが発生するため上記の問題は発生しません。

https://www.postgresql.org/docs/16/transaction-iso.html

ただし、PostgreSQL における REPEATABLE READ は MySQL と同様にファントムリードが防がれるため、
先程のサンプルコードのトランザクション開始時で

User.transaction(isolation: :repeatable_read)

のように、分離レベルを REPEATABLE READ に設定すると PostgreSQL でも同様の現象が発生します。

Rails 7.1 での変更点

以上は Ruby on Rails 7.0 までの話でしたが、Ruby on Rails 7.1 からこの挙動に修正が入り、
トランザクション内で create_or_find_by が実行されたときにレコードの作成も検索の失敗することが起きなくなったようです。

https://github.com/rails/rails/pull/48053

https://github.com/rails/rails/blob/main/activerecord/lib/active_record/relation.rb#L265-L273

この実装を見ると、create_or_find_by 内の対象レコード検索時にトランザクション内で実行されていた場合は、対象レコードの検索時に SELECT ... FOR UPDATE を用いて行ロックを試みることで、
REPEATABLE READ によって読み取りが一貫している環境下でもトランザクション外で作成されたレコードを取得できるようにしているようです。

SocialPLUS Tech Blog

Discussion