create_or_find_by の create はなぜ transaction(requires_new: true) で囲われているか

create_or_find_by のコード見て気になって調べたことのメモ。

rails のコードを見てみる

下記が create_or_find_by のコードですが、 transaction(requires_new: true) で create が囲われています。

https://github.com/rails/rails/blob/c22f7f9a4f523e69899d567acc19a0e6eaac3d36/activerecord/lib/active_record/relation.rb#L273C1-L283C8

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 に入ったときです。

github.com

その 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 させるコードが載っていたことがわかります。

https://github.com/rails/rails/pull/31989/files#diff-18a561656864ea240daf46bdb1f50faace49f9ef74b90bcf667d9bbb17fce084L154-L160

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

その説明がかかれた PR はというとこちら。

github.com

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 は不要なはずです。

https://github.com/rails/rails/blob/c22f7f9a4f523e69899d567acc19a0e6eaac3d36/activerecord/lib/active_record/relation.rb#L231-L233

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) で囲ってます。

https://github.com/gitlabhq/gitlabhq/blob/5395cd1a29385c2cb44a953798dda5b65f7afd1b/app/models/application_record.rb#L82-L94

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

ドキュメントにはこのようなことが書かれています。

SQL Query Guidelines | GitLab

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 作られて話し合われたようでここらへん見てみるのも面白いです。

github.com

gitlab.com