Posted at

ActiveRecord の with_lock に先人の知恵を学ぶ


はじめに

先日、ActiveRecord の with_lock (lock!) を調べていた時に良くできているなあ、勉強になるなあと思ったので、その辺のことを書いてみたいと思います。

なお、以下の内容は、Rails 5.2.2 及び、PostgreSQL 10.7を前提にしていることをお断りしておきます。


with_lock とは

Rails Guide の Pessimistic Lockingwith_lock を使ったサンプルコードが登場します。要は、データ更新時に悲観的ロックをかけてくれるというものです。

SQLレベルでざっくりいうと、 SELECT ... FOR UPDATE をしてから UPDATE できるようになるということですね。

ただ、with_lock の前にモデルが変更されていた場合、どうなるのかちょっと気になりましたので、APIを調べてみました。

コードレベルでいうと以下のようなことをしていた場合どうなるのかという疑問です。

user = User.find(1)

user.name = 'Hanako'
...
...
user.with_lock do
user.email = 'hanako@exmample.com'
user.save!
end


API を調べ ソースコードを見る

with_lock には


Wraps the passed block in a transaction, locking the object before yielding. You can pass the SQL locking clause as argument (see lock!).


と書かれてます。

ソースコードを見てみましょう。

# File activerecord/lib/active_record/locking/pessimistic.rb, line 81

def with_lock(lock = true)
transaction do
lock!(lock)
yield
end
end

トランザクションの中でロックしてからブロックで渡した処理をやってくれるものだとわかります。が、なるほど、 see lock! しないと詳細はわからないですね。


see lock! する

lock!のAPI には


Obtain a row lock on this record. Reloads the record to obtain the requested lock. Pass an SQL locking clause to append the end of the SELECT statement or pass true for “FOR UPDATE” (the default, an exclusive row lock). Returns the locked record.


という訳で、引数が省略された場合、 SELECT ... FOR UPDATE による行ロックがされることがわかります。

そして、 Reloads the record to obtain the requested lock. という気になる一文に到達することができました。


ソースコードも see lock! する

lock! メソッドのソースコードを見てみましょう。

# File activerecord/lib/active_record/locking/pessimistic.rb, line 63

def lock!(lock = true)
if persisted?
if has_changes_to_save?
raise(<<-MSG.squish)
Locking a record with unpersisted changes is not supported. Use
`save` to persist the changes, or `reload` to discard them
explicitly.
MSG
end

reload(lock: lock)
end
self
end

なるほど。これでだいぶわかる(想像できる)ようになりました。

まず、 persisted? でデータベースに保存されているか確認しています。なるほど、 SELECT ... FOR UPDATE はDBに保存されていなければ、実行のしようがないですもんね。

has_changes_to_save? でDBに保存されていない変更がモデルにあるか確認し、あるときは例外を発生させています。

変更がない場合だけ reload(lock: lock) で行ロックをかけているようです。

ということで、気になった部分の答えは、「with_lock の前にモデルが変更されていた場合は、例外が発生する」が正解のようです。

また、ただ、行ロックするだけではなくて、その前に行ロックしても問題ない状態かどうかチェックしているところが良く考えられています。


実際に確認する

では、実際に確認してみます。

# User モデルを作ります

bin/rails g model user name email
Running via Spring preloader process 756
invoke active_record
create db/migrate/20190302083847_create_users.rb
identical app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
$ bin/rails db:migrate
== 20190302083847 CreateUsers: migrating ======================================
-- create_table(:users)
-> 0.0141s
== 20190302083847 CreateUsers: migrated (0.0141s) =============================

$ bin/rails c
Running via Spring preloader in process 773
Loading development environment (Rails 5.2.2)

# users テーブルにデータを保存します
irb(main):001:0> User.create(name: 'Taro', email: 'taro@example.com')
(0.4ms) BEGIN
User Create (1.2ms) INSERT INTO "users" ("name", "email", "created_at", "updated_at") VALUES ($1, $2, $3, $4) RETURNING "id" [["name", "Taro"], ["email", "taro@example.com"], ["created_at", "2019-03-02 08:39:59.702578"], ["updated_at", "2019-03-02 08:39:59.702578"]]
(6.5ms) COMMIT
=> #<User id: 1, name: "Taro", email: "taro@example.com", created_at: "2019-03-02 08:39:59", updated_at: "2019-03-02 08:39:59">

# ここからが本番
irb(main):002:0> user = User.find(1)
User Load (1.1ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 [["id", 1], ["LIMIT", 1]]
=> #<User id: 1, name: "Taro", email: "taro@example.com", created_at: "2019-03-02 08:39:59", updated_at: "2019-03-02 08:39:59">

# userはDBに保存されたUserオブジェクトなので、user.persisted? は true
irb(main):003:0> user.persisted?
=> true

# userは変更されていないので、 user.has_changes_to_save? は false
irb(main):004:0> user.has_changes_to_save?
=> false

# userを変更
irb(main):005:0> user.name = 'Hanako'
=> "Hanako"

# userが変更されたので、 user.has_changes_to_save? は true
irb(main):006:0> user.has_changes_to_save?
=> true

# user.with_lock を実行すると... 
irb(main):007:0> user.with_lock do
irb(main):008:1* user.email = 'hanako@example.com'
irb(main):009:1> user.save!
irb(main):010:1> end

# 調べた通り、例外が発生しました。エラーメッセージも lock!のコードの通りです。
(0.6ms) BEGIN
(0.5ms) ROLLBACK
Traceback (most recent call last):
1: from (irb):7
RuntimeError (Locking a record with unpersisted changes is not supported. Use `save` to persist the changes, or `reload` to discard them explicitly.)

# 一旦、saveします。
irb(main):011:0> user.save
(0.5ms) BEGIN
User Update (0.9ms) UPDATE "users" SET "name" = $1, "updated_at" = $2 WHERE "users"."id" = $3 [["name", "Hanako"], ["updated_at", "2019-03-02 08:42:34.356481"], ["id", 1]]
(7.2ms) COMMIT
=> true

# DBに保存してから user を変更していないので、user.has_changes_to_save? は false
irb(main):012:0> user.has_changes_to_save?
=> false

# 今度は、 with_lock を使っても例外は発生しないはず...
irb(main):013:0> user.with_lock do
irb(main):014:1* user.email = 'hanako@example.com'
irb(main):015:1> user.save!
irb(main):016:1> end
(0.6ms) BEGIN

# SELECT ... FOR UPDATE が発行されていますし、例外が発生せず保存できました。
User Load (1.0ms) SELECT "users".* FROM "users" WHERE "users"."id" = $1 LIMIT $2 FOR UPDATE [["id", 1], ["LIMIT", 1]]
User Update (0.9ms) UPDATE "users" SET "email" = $1, "updated_at" = $2 WHERE "users"."id" = $3 [["email", "hanako@example.com"], ["updated_at", "2019-03-02 08:43:13.995073"], ["id", 1]]
(7.3ms) COMMIT
=> true


まとめ

with_lock (lock!) は、モデルが行ロックしても問題ない状態かどうか確認してからロックすることがわかりました。

また、私の疑問に対するより正確な答えは、「 モデルを変更した状態で with_lock を使うとRollbackされて RuntimeError が発生する」になります。

たまには、みなさんも、Railsのソースコードを眺めながら、動作確認してみてはいかがでしょうか?


おまけ

 

has_changes_to_save? というのを私は知らなくて、 changed? じゃないの?と思ったのですが、 元々 changed? だったのが has_changes_to_save? に変更されていました(PR #30956)。

また、 with_lock で RuntimeErrorになるのは、Rails 5.2 以降のようです(https://github.com/rails/rails/commit/63cf15877bae859ff7b4ebaf05186f3ca79c1863 )。