前提
-
悲観的ロックとはデータベースを複数のユーザーが同時に操作する前提にたち、あるセッションが操作を行なっている際は他のセッションからの操作をロックして防ぐ仕組みのこと。
-
Laravelでは悲観的ロックを実現する方法として、Eloquentの
sharedLock
メソッドとlockForUpdate
メソッドが存在する(もちろん生のクエリを書くという方法もある)。 -
ただ、日本語版公式ドキュメント(ReadDouble)の説明があっさりしていて、どのように動いているのかイメージが湧きづらかったので、検証した内容をまとめることにした。
-
Laravel×MySQL8.xを前提としているが、Laravelに限った話ではないのでタイトルにLaravelはつけていない。
公式の説明
クエリビルダには、
select
ステートメントを実行するときに「悲観的ロック」を行うために役立つ関数も含まれています。「共有ロック」を使用してステートメントを実行するには、sharedLock
メソッドを呼び出すことができます。共有ロックは、トランザクションがコミットされるまで、選択した行が変更されないようにします。
… コード例省略
または、lockForUpdate
メソッドを使用することもできます。「更新用」ロックは、選択したレコードが変更されたり、別の共有ロックで選択されたりするのを防ぎます。
… コード例省略
LaravelのEloquentやクエリビルダで悲観的ロックを行うには、sharedLock
メソッドかlockForUpdate
メソッドを使えば良いことが記されている。
しかし、上記の引用では(使用例のコードを省略したが)トランザクションの記述が省略されていたり、説明も割りとあっさりしていているので、どのように使えば良いかイメージが湧きづらかった。
- sharedLockメソッドを使ってデータをロックしている間は、他のユーザーはそのデータを読み取ることはできるが、更新することはできない。
- lockForUpdateメソッドを使ってデータをロックしている間は、他のユーザーはそのデータを読み取ることも更新することもできない。
とりあえず上記のことが読み取れるが、まだ具体的なイメージは湧かない。
以下sharedLockを付与することでMySQL上で行われる処理を共用ロック、lockForUpdateを付与して行われる処理を占有ロックと呼ぶ。
具体的な挙動を調べた結果
Q1. 複数のトランザクションが同じリソースを占有ロック(lockForUpdate)で操作しようとするとどうなる?
トランザクションのコミットが同時に行われることは許可されず、後から占有ロックをかけようとしたトランザクションのコミットは前のトランザクションのコミットが終わるまで実行されない。
=>デッドロックによって失敗することはないが、もう片方のトランザクションは最初のトランザクションがロックしている間は待たされることになる。
Q2. 共用ロック(sharedLock)は占有ロック(lockForUpdate)と何が違う?
共用ロックをかけているリソースは、他のトランザクションから読み取りはできるもののコミットは許可しない。
つまり、あるトランザクションが共用ロックを行なっているリソースに対して別のトランザクションから共用ロックをかけた場合、最初に実行されたトランザクションはもう一方の共用ロックによって失敗する。
占有ロックと違ってエラーが発生するので、再度リクエストを送信する必要がある。
Q3. 占有ロックを行なっているリソースに対してロックをかけないトランザクションを実行しようとどうなるのか?
占有ロックに対して占有ロックを行った場合は前のトランザクションが終わるまでリソースの読み取りもできない。なので、後のトランザクションは前のトランザクションの結果を元にクエリを実行できる。
占有ロックに対してロックをかけないトランザクションを実行しようとすると、前のトランザクションがコミットされるまで後のトランザクションを許可しないまでは同様である。しかし、SELECT句やUPDATE句の値自体は先のトランザクションがコミットされる前に確定しており、ロック以外のリクエストに対してはリソースの選択を許してしまっていることになる。
なお、占有ロックに対して共用ロックのトランザクションを実行した場合は、占有ロック×占有ロックと同じ振る舞いとなり、Readableの下記の説明は確かに言っている通りなのだと分かる。
「更新用」ロックは、選択したレコードが変更されたり、別の共有ロックで選択されたりするのを防ぎます。
検証に使うセット
-
適当なコントローラの適当なメソッドに上記の処理を記述し、api.phpに適当なエンドポイントからのルーティングを定義する。
-
リクエストパラメータにflg=1をつけた場合とflg=2をつけた場合で分岐処理。
- まずはflg=1でリクエストを送り、sleep関数を使ってトランザクション処理を延ばしつつ、flg=2のリクエストを送ることで、flg=1の処理で行ったロックが正しく動作しているかを確認する。
- レコードは1行しかない状態にする。
用意するもの
-
ローカルでのDocker環境(Laravel + Nginx + MySql)
- MySQLについてはログが出力されるように設定しておく。
-
ローカルホストにHTTPリクエストが実行できるツール
- 今回はAPIテストツールであるPostmanとInsomniaの計2台を使用。
サンプルプログラム
リクエストパラメータを参照してflg=1(以下「A」)とflg=2(以下「B」)の処理に分岐させる。
Aの処理ではSELECT句を実行した後にsleep()を使って、トランザクション内10秒待機を発生させている。
Bの処理では待機は発生させずに、Aがロックをかけている間にBを実行させるとどうなるのかを検証する。
if ($request->input('flg') == 1) {
$counter = DB::transaction(function () {
$counter = Counter::where('id', 1)->lockForUpdate()->first();
sleep(10);
$counter->update(['counter' => $counter->counter + 1]);
return $counter;
});
} elseif ($request->input('flg') == 2) {
$counter = DB::transaction(function () {
$counter = Counter::lockForUpdate()->first();
$counter->update(['counter' => $counter->counter + 1]);
return $counter;
});
}
return response()->json([
'count' => $counter->counter,
'updated_at' => $counter->updated_at
]);
なお、MySQLのgeneral_logにはIDが表示されているので、ログ中のステートメントがAとBのどちらの実行であるかは判別することができる。
サンプルテーブル
デフォルトで0を持つカウンターテーブルを作成。
CREATE TABLE `counters` (
`id` bigint UNSIGNED NOT NULL,
`counter` int NOT NULL DEFAULT '0',
`created_at` timestamp NULL DEFAULT NULL,
`updated_at` timestamp NULL DEFAULT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
初期データ
下記のようにカウンターの初期値0で1行だけレコードを作成しておく。
検証結果
パターン1:どちらもロックをつけずに実行する場合
長くなるのでタイムスタンプや処理に関係ない箇所は省略した。
一番左の列の数字が接続を識別するIDである。
8が先ほどのflg=1をつけたAパターン、9がflg=2をつけたBパターンになる。
8 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
8 Query use `laravel`
8 Query START TRANSACTION
8 Prepare select * from `counters` limit 1
8 Prepare select * from `counters` limit 1
8 Execute select * from `counters` limit 1
8 Close stmt
9 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
9 Query use `laravel`
9 Query START TRANSACTION
9 Prepare select * from `counters` limit 1
9 Execute select * from `counters` limit 1
9 Close stmt
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-11 23:29:34' where `id` = 1
9 Close stmt
9 Query COMMIT
9 Quit
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-11 23:29:41' where `id` = 1
8 Close stmt
8 Query COMMIT
8 Quit
- 上記では「Aのトランザクション開始」→「AのSELECT実行」→(SleepによるAの待機)→「Bのトランザクション開始」→「BのSELECT実行」→「BのUPDATE実行」→「Bのコミット」→「(Sleepから復帰した)Aのアップデート実行」→「Aのコミット」→という順序で実行されている。
- 両方のトランザクションでlockForUpdateを使わずに実行した場合は、入力を受け付けた順番にコミットされており、Aのコミットをsleepで遅らせている分、Bの方が先にコミットされている。
- また、AとBの両方でcounter=1でUPDATEされており、ロックされていないことによって実行順序から想定される整合性が取れていないと確認できる。
パターン2: 占有ロックに対してロックを試みずに実行する場合
8 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
8 Query use `laravel`
8 Query START TRANSACTION
8 Prepare select * from `counters` limit 1 for update
8 Prepare select * from `counters` limit 1 for update
8 Execute select * from `counters` limit 1 for update
8 Close stmt
9 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
9 Query use `laravel`
9 Query START TRANSACTION
9 Prepare select * from `counters` limit 1
9 Execute select * from `counters` limit 1
9 Close stmt
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-11 23:32:07' where `id` = 1
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-11 23:32:13' where `id` = 1
8 Close stmt
8 Query COMMIT
9 Close stmt
9 Query COMMIT
9 Quit
8 Quit
- 上記では「Aのトランザクション開始」→「AのSELECT実行」→(SleepによるAの待機)→「Bのトランザクション開始」→「BのSELECT実行」→「BのUPDATE実行」→「(Sleepから復帰した)Aのアップデート実行」→「Aのコミット」→「Bのコミット」という順序で実行されている。
- BのコミットはAのコミットが完了するのを待ってから実行されているが、BのUPDATE句の値がAのコミットを待たずに確定されており、結果としてはパターン1と同じように実行順序から想定される整合性は取れていないことになる。
パターン3:占有ロックに占有ロックを試みる場合
8 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
8 Query use `laravel`
8 Query START TRANSACTION
8 Prepare select * from `counters` limit 1 for update
8 Prepare select * from `counters` limit 1 for update
8 Execute select * from `counters` limit 1 for update
8 Close stmt
9 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
9 Query use `laravel`
9 Query START TRANSACTION
9 Prepare select * from `counters` limit 1 for update
9 Execute select * from `counters` limit 1 for update
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-11 23:01:53' where `id` = 1
8 Close stmt
8 Query COMMIT
9 Close stmt
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Execute update `counters` set `counter` = 2, `counters`.`updated_at` = '2023-07-11 23:01:53' where `id` = 1
9 Close stmt
9 Query COMMIT
8 Quit
9 Quit
- 上記では「Aのトランザクション開始」→「AのSELECT実行」→(SleepによるAの待機)→「Bのトランザクション開始」→「BのSELECT実行」→「(Sleepから復帰した)Aのアップデート実行」→「Aのコミット」→「BのUPDATE実行」→「Bのコミット」という順序で実行されている。
- 先ほどと違ってBのUPDATE句はAのコミットを待ってから実行されている。AのトランザクションとBのトランザクションでコンフリクトを起こすことなく、A→Bの順番に実行されており、悲観的ロックの意図に沿って実行されていることが分かる。
パターン4:どちらも共用ロックをつけて実行する場合
8 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
8 Query use `laravel`
8 Query START TRANSACTION
8 Prepare select * from `counters` limit 1 lock in share mode
8 Prepare select * from `counters` limit 1 lock in share mode
8 Execute select * from `counters` limit 1 lock in share mode
8 Close stmt
9 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
9 Query use `laravel`
9 Query START TRANSACTION
9 Prepare select * from `counters` limit 1 lock in share mode
9 Execute select * from `counters` limit 1 lock in share mode
9 Close stmt
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-12 21:58:55' where `id` = 1
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-12 21:59:03' where `id` = 1
8 Close stmt
9 Close stmt
8 Query ROLLBACK
9 Query COMMIT
9 Quit
8 Quit
-
「Aのトランザクション開始」→「AのSELECT実行(共有ロック)」→(SleepによるAの待機)→「Bのトランザクション開始」→「BのSELECT実行(共有ロック)」→「(Sleepから復帰した)Aのアップデート実行」→「Aのコミット」…と続くはずが、Aがコミットしようとした際にその行はBにロックされており、AとBが互いにロックし合っていることでデッドロック状態となる。
-
先にAがデッドロックのエラーを起こしてロールバックされることでAの共有ロックが解放され、Bはコミットに成功した状態となっている。
パターン5:占有ロックに共用ロックを試みる
8 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
8 Query use `laravel`
8 Query START TRANSACTION
8 Prepare select * from `counters` limit 1 for update
8 Prepare select * from `counters` limit 1 for update
8 Execute select * from `counters` limit 1 for update
8 Close stmt
9 Connect user@xxx.xxx.xxx.xxx on laravel using TCP/IP
9 Query START TRANSACTION
9 Prepare select * from `counters` limit 1 lock in share mode
9 Execute select * from `counters` limit 1 lock in share mode
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
8 Execute update `counters` set `counter` = 1, `counters`.`updated_at` = '2023-07-12 23:20:42' where `id` = 1
8 Close stmt
8 Query COMMIT
9 Close stmt
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Prepare update `counters` set `counter` = ?, `counters`.`updated_at` = ? where `id` = ?
9 Execute update `counters` set `counter` = 2, `counters`.`updated_at` = '2023-07-12 23:20:42' where `id` = 1
9 Close stmt
9 Query COMMIT
8 Quit
9 Quit
- 占有ロックに対して占有ロックを試みた場合と同じように、先のトランザクションがコミットするのを待ってからUPDATE句の値が確定している。
まとめ
共有ロック同士だとデッドロックを起こしてエラーが発生するので再度リクエストを送信する必要がある。
逆に占有ロックではエラーが発生しないものの、同時実行が制限されて待たされることになるので、これらの特徴を踏まえて選択することになると思われる。
また、悲観的ロックによってデータの整合性を保ちたい場合は、常にロック付きでデータの選択や更新を行うように実装する必要がある。