ActiveRecord::Base.transactionで並列で同じレコードを扱ったときの動きメモ
排他制御まわりがちゃんとわかっていなかったので整理。
Article.find(1).value # => 1 def inc row = Article.find(1) row.value = row.value + 1 row.save end # 同時並行処理する Thread.new { inc } Thread.new { inc } Article.find(1).value # => 2になるか,3になるかは神のみぞ知る
だけど、
Article.find(1).value # => 1 def inc ActiveRecord::Base.transaction do row = Article.find(1) row.value = row.value + 1 row.save end end # 同時並行処理する Thread.new { inc } Thread.new { inc } Article.find(1).value # => これなら3になるよね!たぶん
こうするとうまくいくはず。
検証
Article.find(1).value # => 1 def inc(wait1 = 0, wait2 = 0) sleep wait1 if wait1 > 0 ActiveRecord::Base.transaction do row = Article.find(1) row.value = row.value + 1 sleep wait2 if wait2 > 0 row.save! end end # 同時並行処理する Thread.new { inc(0, 10) } # ... t1 Thread.new { inc(5, 0) } # ... t2 # t1.transaction -> t1.find -> t2.transaction(待ちになる) -> # t1.save -> t1.commit -> t2.find -> t2.save -> t2.commit # になる想定。 Article.find(1).value # => 3にならず、2になってしまった...
だめじゃん。
検証2
悲観的ロックをかける
https://qiita.com/upinetree/items/b3329501561268f7678a
Article.find(1).value # => 1 def inc(wait1 = 0, wait2 = 0) sleep wait1 if wait1 > 0 ActiveRecord::Base.transaction do # SELECT * FROM "articles" FOR UPDATE row = Article.all.lock.find(1) row.value = row.value + 1 sleep wait2 if wait2 > 0 row.save! end end # 同時並行処理する Thread.new { inc(0, 10) } # ... t1 Thread.new { inc(5, 0) } # ... t2 # t1.transaction -> t1.find -> t2.transaction(待ちになる) -> # t1.save -> t1.commit -> t2.find -> t2.save -> t2.commit # になる想定。 Article.find(1).value # => 3になった!
これだとうまくいった。
最後に
トランザクションについて
トランザクションは、完全に排他してくれるわけではなく、あくまで、複数テーブルのUPDATEで「すべて更新される」か「すべて更新されないか」を保証するだけのもの。
トランザクション内でロックがかかった場合は、COMMITかROLLBACKされるまでロックは維持される。
DBレベルのロックについて(PostgreSQLの場合)
【テーブルレベルのロック】
ROW EXCLUSIVE
UPDATE、DELETE、およびINSERTコマンドは、(参照される他の全てのテーブルに対するACCESS SHAREロックに加えて)対象となるテーブル上にこのモードのロックを獲得します。 通常、このロックモードは、テーブルのデータを変更する問い合わせにより獲得されます。
→ つまり何も指定せずに、INSERT、UPDATE、DELETEすると、ROW EXCLUSIVEが自動でかかる。
【行レベルのロック】
FOR UPDATE
FOR UPDATEによりSELECT文により取り出された行が更新用であるかのようにロックされます。
→ 基本的にSELECTではロックはかからないけど、かけたければFOR UPDATE使う。
https://www.postgresql.jp/document/9.4/html/explicit-locking.html より
ロックについて
ロックには種類がある。
【楽観的ロック】
アプリケーションレベルのロック。
更新する直前に、対象レコードが取得時と変わっていないかを確認する。
【悲観的ロック】
DBレベルのロック。
レコード取得した時点で、対象レコードにロックをかける。
SELECT … FOR UPDATE を利用したもの。
→ 今回話しているのはこっちのロックの方。
補足
RedisObject使っているとクラスメソッドのlockが上書きされちゃうようなので、Article.lock... ではなく Article.all.lock... としたほうが無難っぽい。
https://github.com/nateware/redis-objects/blob/master/lib/redis/lock.rb#L36