🧪

【Rails】楽観ロックでロストアップデートを防いでみた

2024/06/24に公開

経緯と目的

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テーブル

DDL
create table users
(
    id bigint unsigned auto_increment primary key,
    age int unsigned default '0' not null,
    version bigint unsigned default '0' not null,
)

モデル

user.rb
class User < ApplicationRecord
  self.locking_column = :version
...
end

初期値

rails console
> User.first.age
60
> User.first.version
9

楽観ロック設定したモデルで実際にupdateを行ってみる

  • トランザクションT1, T2を並列で実行してロックの挙動を確認します
  • T1 → T2 の順に実行します
  • T2 → T1 の順にCOMMITされます。(T1にsleepを入れてT2が先に完了するようにします)

T1

rails console
> 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

rails console
> 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後:

rails console
> 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の設定や環境要因かもしれません。今後詳しく調査してみます。
https://github.com/rails/rails/blob/5bec50bc70380bb1e70e8fb0a1654130042b1f16/activerecord/lib/active_record/locking/optimistic.rb#L128

まとめ

  • 楽観ロックを設定したカラムでupdateを行うと、WHERE句でversionが指定される
  • 並列実行されたトランザクションにおいて、commitが遅い方のupdate(ロストアップデート)は行われない
  • UPDATE文のWHEREではMVCCのスナップショットではなく実際のデータを指定しに行く模様
脚注
  1. 脚注1 楽観ロックサポート機能 ↩︎

Discussion