【Rails】楽観ロックでロストアップデートを防いでみた
経緯と目的
ActiveRecordで悲観ロック(排他ロック)を取って排他制御を行うことはよくありますが、楽観ロックに関してはあまり馴染みがありませんでした。そこで、Ruby on Railsの楽観ロックサポート機能[1]でupdateしたときに発行されるクエリや挙動を確認しようと思い実際にrails consoleして確かめました。今回の記事はその時のログをまとめたものです。
この記事に出てくる内容としては下記のようなものです
1. 排他制御(楽観ロック)
2. トランザクションの並列・分離
→ REPEATABLE READで2つのトランザクションを発行しロストアップデートを防げることを確認
トランザクション分離レベルやロックの特徴などは巷に様々な記事があるため割愛します
TL;DR
- 楽観ロックを設定したカラムでupdateを行うと、WHERE句でversionが指定される
- 並列実行されたトランザクションにおいて、commitが遅い方のupdate(ロストアップデート)は行われない
- UPDATE文のWHEREではMVCCのスナップショットではなく実際のデータを指定しに行く模様
-
StaleObjectError
が発生しなかった(今後要調査)
前提
- ruby Ver 3.1.3
- Rails Ver 7.0.8.4
- MySQL Ver 8.0.33 (トランザクション分離レベルは
REPEATABLE READ
)
楽観ロックの設定を行ったUser
モデルがあるとします。usersテーブルにはversion
というbigint型カラムを指定します。
usersテーブル
create table users
(
id bigint unsigned auto_increment primary key,
age int unsigned default '0' not null,
version bigint unsigned default '0' not null,
)
モデル
class User < ApplicationRecord
self.locking_column = :version
...
end
初期値
> User.first.age
60
> User.first.version
9
楽観ロック設定したモデルで実際にupdateを行ってみる
- トランザクションT1, T2を並列で実行してロックの挙動を確認します
- T1 → T2 の順に実行します
- T2 → T1 の順にCOMMITされます。(T1にsleepを入れてT2が先に完了するようにします)
T1
> ActiveRecord::Base.transaction { u = User.first; sleep 10; u.update(age: 40) }
BEGIN
SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
-- sleepしてT2待ち
UPDATE `users` SET `users`.`age` = 40, `users`.`updated_at` = '2024-06-23 08:28:04', `users`.`version` = 10 WHERE `users`.`id` = 4 AND `users`.`version` = 9
COMMIT
T2
> ActiveRecord::Base.transaction { u = User.first; u.update(age: 20) }
BEGIN
SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
UPDATE `users` SET `users`.`age` = 20, `users`.`updated_at` = '2024-06-23 08:27:56', `users`.`version` = 10 WHERE `users`.`id` = 4 AND `users`.`version` = 9
COMMIT
結果確認と挙動考察
T1T2両方のCOMMIT後:
> User.first.age
SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
20
> User.first.version
SELECT `users`.* FROM `users` ORDER BY `users`.`id` ASC LIMIT 1
10
先にcommitしたT2の age: 20 になっており、後にcommitしたT1の age: 40 は反映されていません。
T1のUpdate文は WHERE version = 9
が指定されているため、T2によってversionが10になったカラムは更新されないことがわかります。言い換えると、ロストアップデート(後勝ち)が起こっていないことがわかります。
また意外な気づきもありました。後手に回ってUPDATE文を発行したT1の、UPDATE ... WHERE users.version
で別トランザクションであるT2でCOMMITし更新したversionを指定できています。SELECTと違って、UPDATEのWHEREはMVCCのスナップショットを参照しているのではなく、実際のデータを参照しに行くためですね。SELECT以外は注意する必要があると再認識しました。
データベースの状態のスナップショットは、トランザクション内の SELECT ステートメントに適用されますが、DML ステートメントには必ずしも適用されるとは限りません。
引用: 一貫性非ロック読み取り
最後に、謎なのがStaleObjectError
が発生しなかったことです。ActiveRecordの実装を確認すると、destory時とupdate時に例外を投げる#_update_row
と#destroy_row
プライベートメソッドが用意されており、さすがに正常に動いているはずですが・・・ MySQLの設定や環境要因かもしれません。今後詳しく調査してみます。
まとめ
- 楽観ロックを設定したカラムでupdateを行うと、WHERE句でversionが指定される
- 並列実行されたトランザクションにおいて、commitが遅い方のupdate(ロストアップデート)は行われない
- UPDATE文のWHEREではMVCCのスナップショットではなく実際のデータを指定しに行く模様
-
脚注1 楽観ロックサポート機能 ↩︎
Discussion