Rails: with_lock のブロックはトランザクション内で呼ばれる
はじめに
説明の内容や開発環境は Rails 7、PostgreSQL を前提としています。
with_lock について
Railsには、モデルに行ロックをかける(クエリレベルで言うとSELECT ~ FOR UPDATEをする)方法として、model_name.with_lock(&block)というメソッドが用意されています。これを使うことによって、ブロックの内部では行ロックがかかり排他制御された状態で処理を記述することができます。 code:with_lock_sample.rb
book = Book.first
book.with_lock do
# このブロックはトランザクション内で呼び出される
# bookはロック済み
book.increment!(:views)
end
ただし、引用した上記のサンプルコードにもコメントで書かれている通り、ブロック内部はトランザクション内で実行されることに注意してください。
code:with_lock_sample2.rb
book = Book.create!(title: "Book 1")
pp "start"
book.with_lock do
pp "with_lock start"
book.update!(title: "Book 1 Updated")
Paper.create!(title: "Paper 1")
pp "with_lock end"
end
pp "end"
上記のサンプルコードを見ると、bookオブジェクトに対してwith_lockブロックを囲っているため、ブロック内部ではbookレコードに行ロックがかかり、それとは関係のないPaperモデルに関してはPaper.create!の直後にコミットされるように見えますが、実際にはそうではありません。実行結果は以下のようになります。
code:with_lock_sample2_result.rb
TRANSACTION (0.1ms) BEGIN
Book Load (0.4ms) SELECT "books".* FROM "books" WHERE "books"."id" = $1 LIMIT $2 FOR UPDATE "id", 1], ["LIMIT", 1
"with_lock start"
Paper Create (0.5ms) INSERT INTO "papers" ("title", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" "title", "Paper 1"], "created_at", "2022-07-22 11:39:06.108269", ["updated_at", "2022-07-22 11:39:06.108269" "with_lock end"
TRANSACTION (0.3ms) COMMIT
Railsのソースコードを読んでもトランザクションが作られていることがわかります。
試しに、with_lockのブロックが終わる前に別のスレッドからPaperレコードをクエリで呼び出してみると、コミットされていないのでエラーになることがわかります。
code:with_lock_sample3.rb
book = Book.create!(title: "Book 1")
book.with_lock do
book.update!(title: "Book 1 Updated")
paper = Paper.create!(title: "Paper 1")
Thread.start {
pp Paper.find(paper.id)
}
sleep 1
end
code:with_lock_sample3_result.rb
TRANSACTION (0.1ms) BEGIN
Book Load (0.5ms) SELECT "books".* FROM "books" WHERE "books"."id" = $1 LIMIT $2 FOR UPDATE "id", 6], ["LIMIT", 1
Paper Create (0.5ms) INSERT INTO "papers" ("title", "created_at", "updated_at") VALUES ($1, $2, $3) RETURNING "id" "title", "Paper 1"], "created_at", "2022-07-22 15:57:39.868531", ["updated_at", "2022-07-22 15:57:39.868531" Paper Load (0.4ms) SELECT "papers".* FROM "papers" WHERE "papers"."id" = $1 LIMIT $2 "id", 6], ["LIMIT", 1
/Users/username/.anyenv/envs/rbenv/versions/3.1.2/lib/ruby/gems/3.1.0/gems/activerecord-7.0.3.1/lib/active_record/core.rb:284:in `find': Couldn't find Paper with 'id'=6 (ActiveRecord::RecordNotFound)
from (irb):6:in `block (2 levels) in <main>'
TRANSACTION (0.8ms) COMMIT
=> 1
まとめ
行ロックはトランザクションがコミットされるまでの間維持されるため、トランザクションを作ることを知っていれば当たり前のことですが、Railsのコードを読む時にはついついモデル単位でそのオブジェクトを捉えてしまい、思わぬ勘違いをしてしまうことがあります。今回はその一例を紹介しました。それでは楽しいRailsライフを!