Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails 7: with_lockでもtransactionのロック戦略引数を指定可能になった(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

Rails 7: with_lockでもtransactionのロック戦略引数を指定可能になった(翻訳)

コンカレンシーの制御には、具体的には2種類のメカニズムがあります。悲観的ロックと楽観的ロックです。

楽観的ロック

楽観的ロック(optimistic locking)モデルは、複数のユーザーが同じレコードを更新しようとすると、他のユーザーもそのレコードを更新しようとしていることを通知せずに更新を許可するコンカレンシー制御です。レコードの変更は、レコードがコミットされるときにだけバリデーションされます。あるユーザーがレコードの更新に成功すると、その時点でコンカレントに更新をコミットしようとしている別のユーザーに競合の存在が通知されます。

楽観的ロックモデルのメリットは、レコードの操作中にレコードをロックするオーバーヘッドを回避できることです。このモデルでは、同時更新が発生しなければ高速に更新できます。

悲観的ロック

悲観的ロック(pessimistic locking)モデルは、レコードの同時更新を阻止します。あるユーザーがレコードの更新を開始すると、ただちにそのレコードをロックし、このレコードを更新しようとしている他のユーザーに現在レコードを更新中のユーザーが存在することが通知されます。他のユーザーは、最初のユーザーが変更をコミットしてレコードのロックが解除されるまで待つ必要があります。それが終わって初めて、他のユーザーは直前のユーザーが変更した内容に基づいて変更を開始できます。

悲観的ロックモデルのメリットは、更新の競合を防止することで競合解消作業を行わずに済むことです。更新はシリアライズされるので、最初の更新より後の更新は、最初のユーザーが変更をコミットしたレコードを元に開始されます。

lock!

RailsのActiveRecord::Locking::Pessimisticは、トランザクション内でlock!メソッドを用いることによる行レベルロックをサポートしています。

たとえば、ある1件の記事に対して2人のユーザーが同時に「いいね!」ボタンを押したとすると、「いいね!」のカウントは2増えるのではなく1しか増えなくなってしまいます。これを解決するには、以下のようにトランザクション内でlock!を使います。

  ActiveRecord::Base.transaction do
    article = Article.find("00000000-0000-0000-0000-000000000001").lock!("FOR UPDATE NOWAIT")
    article.like_count += 1
    article.save!
  end

上のコードでは以下を行っています。

最初にデータベーストランザクションを開始します。次に悲観的ロックを取得します。レコードがロックされると、そのレコードはメモリに再読み込みされ、レコード上の値とデータベースでロックされた行の値が一致します。ロックがかかると他のユーザーはその行を読み書きできなくなり、他のユーザーがロックを取得するには現在のロックが解除されるまで待つ必要があります。

また、背後のデータベースでサポートされているさまざまなロック戦略lock!に渡せます。たとえばPostgreSQLでFOR UPDATE NOWAITを使うと、この行に対してUPDATEやDELETEやSELECT FOR UPDATEを実行すると、現在のトランザクションが完了するまでブロックされ、他のトランザクションが同じレコードのロックを取得しようとしても以下のようにエラーになります。

TRANSACTION (1.9ms)  ROLLBACK
/Users/murtazabagwala/.rvm/gems/ruby-3.0.1/gems/activerecord-6.1.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:672:in `exec_params': PG::LockNotAvailable: ERROR:  could not obtain lock on row in relation "articles" (ActiveRecord::LockWaitTimeout)
/Users/murtazabagwala/.rvm/gems/ruby-3.0.1/gems/activerecord-6.1.4.1/lib/active_record/connection_adapters/postgresql_adapter.rb:672:in `exec_params': ERROR:  could not obtain lock on row in relation "articles" (PG::LockNotAvailable)

with_lock

with_lockを使うと同じことをより簡潔に書けます。with_lockも同様にトランザクションを作成して背後のレコードにロックをかけます。

  article = Article.find("00000000-0000-0000-0000-000000000001")
  article.with_lock("FOR UPDATE NOWAIT") do
    article.like_count += 1
    article.save!
  end

しかしRails 7より前はwith_lockにはtransactionの引数(isolationrequires_newjoinable)を指定する方法がなかったため、たとえばネステッドトランザクションを作成するには、複数のトランザクションブロックでlock!を使う必要がありました。

変更前

  ActiveRecord::Base.transaction do
    article = Article.find("00000000-0000-0000-0000-000000000001").lock!("FOR UPDATE NOWAIT")
    article.like_count += 1
    article.save!

    ActiveRecord::Base.transaction(requires_new: true) do
      author = article.author.lock!("FOR UPDATE NOWAIT")
      author.articles_liked += 1
      author.save!
    end
  end

変更後

Rails 7では、ネステッドトランザクションの作成でwith_lockに以下のトランザクション引数をオプションで指定できるようになりました。

  article = Article.find("00000000-0000-0000-0000-000000000001")

  article.with_lock do
    author = article.author
    article.like_count += 1
    article.save!

    # sub-transaction/savepointというトランザクションを新たに作成する。
    # エラーの場合はこのセーブポイントまでロールバックするが、
    # 親トランザクションはロールバックしない。

    author.with_lock("FOR UPDATE NOWAIT", requires_new: true) do
      author.articles_liked += 1
      author.save!
    end
  end

詳しくは#43224を参照してください。

関連記事

Rails APIドキュメント: Active Recordのトランザクション(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。