ActiveRecordのlockメソッド(Postgresql)
概要
これまであまり使っていなかったActiveRecordのlock機能に触れる機会があったので少し試したことを記録しておく
Version
- Ruby 3.1.1
- ActiveRecord 7.0.2.2
- Postgresql 14.2
詳細
lock(with_lock)メソッド
AcitveRecordのロック機能のについてはRailsガイドに解説があるが、今回調べたlockメソッドはデータベースの機能を利用した悲観的ロックを扱う。transactionで囲まれたブロック内でlockを経由してからクエリメソッドを実行すると対象のレコードにロックが実行され、このトランザクションが完了するまではその他のトランザクションから更新を防ぐ事が出来る。
User.transaction do user = User.lock.first user.update(name: "change") end
もしくは
user = User.lock.first user.with_lock do user.update(name: "change") end
動作の検証
デバッガのブレイクポイントを貼ってトランザクションの途中で停止させた状態で
User.transaction do user = User.lock.first binding.pry user.update(name: "change") end
別のRails consoleなどからレコードの更新を行うとupdate
の実行が待たされる。
# 別のRails console user = User.first user.update(name: "other name") => true # デバッガを進めてlockしたtransactinoを抜けると処理が実行され結果が返ってくる
注意しないといけない点としてトランザクションを貼った中でなければロックが実行されないのだが、その状態でlockメソッドを使用してもなんの警告もエラーもでない事だ。User.transaction
をコメントアウトした以下のコードはこの箇所のupdateだけであれば問題なく実行されるが、先ほど同様にlock直後をデバッガで停止させた状態で別のコンソールなどからupdate
を実行しても即座に更新出来てしまう。
#User.transaction do user = User.lock.first binding.pry user.update(name: "change") #end
update
の実行だけなら一見正常に実行出来たように見えてしまうのでバグを防ぐためにはきちんとロックを想定したテストを書くなどの対策が必要だろう。
行ロック・テーブルロック
Postgresqlには行ロック・テーブルロックがある。 SQL文の中身を見るとわかるがActiveRecordのlockメソッドで実行されているのは行ロックだ。
User.lock.to_sql => "SELECT \"users\".* FROM \"users\" FOR UPDATE"
また
異なる種類のロックを使いたい場合は、
lock
メソッドに生SQLを渡すことも可能です。たとえば、MySQLにはLOCK IN SHARE MODE
という式があります(レコードのロック中にも他のクエリからの読み出しを許可します)。この式を指定するには、以下のように単にlockオプションの引数で渡します。 Railsガイド/Active Record クエリインターフェイス
という事なので引数にモードの指定を渡せばRailsガイドの例にあるMySQLと同様にPostgresでも行ロックのモードを指定する事が出来る。
User.transaction do # User.lock('FOR SHARE').to_sql # => "SELECT \"users\".* FROM \"users\" FOR SHARE" user = User.lock('FOR SHARE').first user.update(name: "change") end
一方のテーブルロックだがPostgresqlではLOCKコマンドを実行する必要があるので引数でなにも指定しない場合でもSELECTメソッドが実行されるActiveRecordのlockメソッドでは使用できない。もしテーブルロックが必要であるならModel経由ではなく別に生のSQLを実行する必要がある。
User.transaction do ActiveRecord::Base.connection.execute('LOCK users IN ACCESS EXCLUSIVE MODE') user = User.first binding.pry # ここで止めると別コンソールで User.first の時点で結果が返ってこない user.update(name: "change") end
lockメソッドを使用していないがtransactionを抜ければ待たされていた実行結果は返ってくるようだ。