ActiveRecord::RecordNotUniqueが起きたときにリトライ処理を正しく実装する

  • MySQL5.6
  • REPEATABLE READ
  • rails5.2.2
  • ruby2.6

文脈

ActiveRecord::RecordNotUniqueは、ユニーク制約を貼っているカラムに対して複数のコネクションから同時に同じ値の書き込み処理が行われた時に発生します。
WEBアプリケーションでこれが発生すると、1リクエストのみが正常終了し他のリクエストは異常終了してしまいます。
この手のエラーはよく発生しますが、発生頻度が低く致命的ではなかったり、RDBのトランザクションの知識が要求されるので放置気味な印象がありますが、
リクエストがエラーになるとユーザはエラー画面を見ることになるのでできれば直したいですね。

結論

railsドキュメントにあるようにtransaction(requires_new: true)ブロックで囲ってその中で内でretryを実行すればよいです。 https://github.com/rails/rails/blob/94b5cd3a20edadd6f6b8cf0bdf1a4d4919df86cb/activerecord/lib/active_record/relation.rb#L115-L161

begin
  CreditAccount.transaction(requires_new: true) do
    CreditAccount.find_or_create_by(user_id: user.id)
  end
rescue ActiveRecord::RecordNotUnique
  retry
end

説明

requires_new: trueは何かというと、すでにトランザクションの中にいる時にトランザクションをネストしてくれるようになります。
トランザクション中でトランザクションがネストしていないと、retryが走ったところで他トランザクションでコミットされたレコードが見えずに、ActiveRecord::RecordNotUniqueが永遠と発生して無限ループになってしまいます。
つまりトランザクションの中ではなかったら、requires_new: trueはなくても動作します。

しかし、今後rescue ActiveRecord::RecordNotUnique; retryを実装するときは、呼び出し元の実装が変わって、トランザクション中でコールされてもいいようにtransaction(requires_new: true)で囲っておくとよいでしょう。

これは余談ですが、find_or_create_byを使うと上記のようにretryを自分で実装する必要がありますが、rails6には create_or_find_byというretry処理の実装が不要なメソッドがマージされました。
https://github.com/rails/rails/pull/31989
処理順番を入れ替えることでretryが不要になるのはおもしろいですね。