Rails の create_or_find_by がレコードの作成も検索も失敗する
こんにちは、simomu です。 今回は ActiveRecord の create_or_find_by
の話をします。
以降は断りのない場合は
- Ruby on Rails 7.0
- MySQL 8.0 (InnoDB)
の環境下での話とします。
create_or_find_by
ActiveRecord の ActiveRecord には create_or_find_by
というメソッドが存在しています。
このメソッドは、引数に指定されたカラムの値でレコードの作成を試み、既にテーブルに同じ値のレコードが存在していた場合は引数に指定されたカラムの値でレコードの検索する挙動をします。
引数に指定するカラムは UNIQUE 制約が入っていることが前提です。
似たようなメソッドに find_or_create_by
というものがありますが、こちらは「先にレコードの検索をして、存在していなければレコードの作成をする」という挙動の違いがあります。
create_or_find_by
と find_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
の時はトランザクション内の読み取りは一貫している(= ファントムリードが防がれる)とあります。
先程のサンプルコードの場合だと、
# 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
であり、
ファントムリードが発生するため上記の問題は発生しません。
ただし、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
が実行されたときにレコードの作成も検索の失敗することが起きなくなったようです。
この実装を見ると、create_or_find_by
内の対象レコード検索時にトランザクション内で実行されていた場合は、対象レコードの検索時に SELECT ... FOR UPDATE
を用いて行ロックを試みることで、
REPEATABLE READ
によって読み取りが一貫している環境下でもトランザクション外で作成されたレコードを取得できるようにしているようです。
Discussion