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
の引数(isolation
、requires_new
、joinable
)を指定する方法がなかったため、たとえばネステッドトランザクションを作成するには、複数のトランザクションブロックで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
に以下のトランザクション引数をオプションで指定できるようになりました。
requires_new
: これをtrue
に設定すると、データベースのセーブポイントでブロックがサブトランザクションとしてラップされます。isolation
: 分離レベルを指定してダーティ読み出しを回避できます。joinable
: これをfalse
に設定することで、カスタムのネステッドトランザクションを扱うときに心臓に悪い思いをせずに済みます。
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を参照してください。
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。