create_or_find_by のコード見て気になって調べたことのメモ。
rails のコードを見てみる
下記が create_or_find_by
のコードですが、 transaction(requires_new: true)
で create が囲われています。
def create_or_find_by(attributes, &block) with_connection do |connection| transaction(requires_new: true) { create(attributes, &block) } rescue ActiveRecord::RecordNotUnique if connection.transaction_open? where(attributes).lock.find_by!(attributes) else find_by!(attributes) end end end
このコードが入ったのは create_or_find_by
が rails に入ったときです。
その PR を見てみるとこのようなやり取りが見つかります。
https://github.com/rails/rails/pull/31989#discussion_r168047615
This needs transaction(requires_new: true) do around the create to work in an ongoing surrounding transaction (on at least PostgreSQL)
Because it doesn't cause an SQL error and then attempt to recover. PostgreSQL remembers when an error has occurred inside a transaction, and disallows all further operations until that transaction has been rolled back.
さらにその PR の変更点見てみると、元は find_or_create_by
の説明に transaction(requires_new: true)
で囲って retry させるコードが載っていたことがわかります。
- # begin - # CreditAccount.transaction(requires_new: true) do - # CreditAccount.find_or_create_by(user_id: user.id) - # end - # rescue ActiveRecord::RecordNotUnique - # retry - # end
その説明がかかれた PR はというとこちら。
The code in the comment fails on concurrent inserts if done inside a transaction. The fix is to force a savepoint to run so that if the database raises an unique violation exception, the next query can run. Otherwise, you'll get errors like:
PostgreSQL はトランザクション内でエラーになるとその後の SQL もエラーになってしまうので、サブトランザクションを作ってエラーになるのをそのサブトランザクション内に限定させて、その後の retry を実行できるようにしているということでした。
もしも PostgreSQL 使っていて、サブトランザクション作らずに retry してしまうと、 ActiveRecord::StatementInvalid: PG::InFailedSqlTransaction: ERROR: current transaction is aborted, commands ignored until end of transaction block
が出てしまうということですね。
ちなみに、7.1 から find_or_create_by
は find 後に create_or_find_by
呼ぶようになったので自前で transaction(requires_new: true)
使っての retry は不要なはずです。
def find_or_create_by(attributes, &block) find_by(attributes) || create_or_find_by(attributes, &block) end
余談
GitLab のコードよく読ませてもらっているのですが、今回の調査後に改めて読んでたら safe_find_or_create_by
というメソッドを作ってることがわかりました。
大体やってることは同じような感じで、こちらも transaction(requires_new: true)
で囲ってます。
def self.safe_find_or_create_by(*args, &block) record = find_by(*args) return record if record.present? # We need to use `all.create` to make this implementation follow `find_or_create_by` which delegates this in # https://github.com/rails/rails/blob/v6.1.3.2/activerecord/lib/active_record/querying.rb#L22 # # When calling this method on an association, just calling `self.create` would call `ActiveRecord::Persistence.create` # and that skips some code that adds the newly created record to the association. transaction(requires_new: true) { all.create(*args, &block) } # rubocop:disable Performance/ActiveRecordSubtransactions rescue ActiveRecord::RecordNotUnique find_by(*args) end
ドキュメントにはこのようなことが書かれています。
In Rails 6 and later, there is a .create_or_find_by method. This method differs from our .safe_find_or_create_by methods because it performs the INSERT, and then performs the SELECT commands only if that call fails.
If the INSERT fails, it leaves a dead tuple around and increment the primary key sequence (if any), among other downsides.
We prefer .safe_find_or_create_by if the common path is that we have a single record which is reused after it has first been created. However, if the more common path is to create a new record, and we only want to avoid duplicate records to be inserted on edge cases (for example a job-retry), then .create_or_find_by can save us a SELECT.
Both methods use subtransactions internally if executed within the context of an existing transaction. This can significantly impact overall performance, especially if more than 64 live subtransactions are being used inside a single transaction.
create_of_find_by
あるけど、INSERT 失敗したらデッドタプル残るし主キーもインクリメントされたり他に欠点もある。
find されることが多いなら safe_find_or_create_by
使ってくださいと。
あと、最後に1つのトランザクション内で64以上のライブサブトランザクションが使用されている場合、全体のパフォーマンスに大きな影響を与える可能性があるとも書かれています。
close してしまいましたが rails にも issue 作られて話し合われたようでここらへん見てみるのも面白いです。