ActiveRecordのlockメソッド(Postgresql)

概要

これまであまり使っていなかったActiveRecordのlock機能に触れる機会があったので少し試したことを記録しておく

Version

詳細

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を抜ければ待たされていた実行結果は返ってくるようだ。

参照

Railsガイド/Active Record クエリインターフェイス PostgreSQL 13.1文書