Rails では SELECT の結果を保存しておき、再度読みに行ったときにメモリから結果を返してくれる機能があります。しかし、with_lock を使うと、データの一貫性のため、再度クエリを投げてくれるようです。
この機能が Laravel にもあるのかどうか調べてみました。
Rails で with_lock を使用した場合
バージョン:Rails 7.0.4.2
RDBMS:mysql
分離レベル:REPEATABLE-READ
class Author < ApplicationRecord
belongs_to :book
end
class Book < ApplicationRecord
has_many :authors, -> { order(:id) }, dependent: :destroy, inverse_of: :book
end
ロックしたモデルのリレーションを直接呼び出した場合
class RootController < ApplicationController
def index
book = Book.find(1)
# 1回目
authors = book.authors.to_a
p authors.size
# 2回目
authors = book.authors.to_a
p authors.size
book.with_lock do
# 3回目
authors = book.authors.to_a
p authors.size
end
end
end
結果(SQL実行のみ抜粋)
Book Load (0.2ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1
Author Load (0.4ms) SELECT `authors`.* FROM `authors` WHERE `authors`.`book_id` = 1 ORDER BY `authors`.`id` ASC
TRANSACTION (0.2ms) BEGIN
Book Load (0.4ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1 FOR UPDATE
Author Load (0.7ms) SELECT `authors`.* FROM `authors` WHERE `authors`.`book_id` = 1 ORDER BY `authors`.`id` ASC
TRANSACTION (0.3ms) COMMIT
この実行では、includes(:authors)
などは使用していないため、1回目の authors
の呼び出しで SELECT が実行されています。その一方で、2回目は1回目の結果のキャッシュが返されていることが分かります。さて、3回目のアクセスですが、これは BEGIN の後に実行されていて、SELECT が再度呼ばれていることがわかります。これにより、トランザクション内で古いデータを使用してしまう心配がなく、明示的に .reload
を呼び出さなくても最新のデータを使用して安全にデータを更新することができます。
リレーションを保持していた場合
ActiveRecord::Relation を変数に保持していた場合を試します。
book = Book.find(1)
authors = book.authors
# 1回目
p authors.to_a.size
# 2回目
p authors.to_a.size
book.with_lock do
# 3回目
p authors.to_a.size
end
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1
Author Load (0.4ms) SELECT `authors`.* FROM `authors` WHERE `authors`.`book_id` = 1 ORDER BY `authors`.`id` ASC
TRANSACTION (0.2ms) BEGIN
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1 FOR UPDATE
TRANSACTION (0.2ms) COMMIT
この場合は book が更新されたことを検知できず、再度 SELECT は呼ばれないようです。
ApplicationRecord を保持していた場合
book = Book.find(1)
author = book.authors.first
# 1回目
p author.to_json
# 2回目
p author.to_json
book.with_lock do
# 3回目
p author.to_json
end
Book Load (0.2ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1
Author Load (0.5ms) SELECT `authors`.* FROM `authors` WHERE `authors`.`book_id` = 1 ORDER BY `authors`.`id` ASC LIMIT 1
TRANSACTION (0.2ms) BEGIN
Book Load (0.3ms) SELECT `books`.* FROM `books` WHERE `books`.`id` = 1 LIMIT 1 FOR UPDATE
TRANSACTION (0.2ms) COMMIT
この場合も特に .reload
はされず、キャッシュされた値が使用されるようです。
Laravel の場合
バージョン:Laravel 9.52.4
RDBMS:mysql
分離レベル:REPEATABLE-READ
class Book extends Model
{
public function authors()
{
return $this->hasMany(Author::class);
}
}
class Author extends Model
{
public function book()
{
return $this->belongsTo(Book::class, 'book_id', 'id');
}
}
public function index()
{
$book = Book::find(1);
# 1回目
$authors = $book->authors()->get();
print($authors->toJson());
# 2回目
$authors = $book->authors()->get();
print($authors->toJson());
DB::transaction(function () {
$book = Book::lockForUpdate()->find(1);
# 3回目
$authors = $book->authors()->get();
print($authors->toJson());
});
}
2023-03-25T15:12:21.943733Z 18 Execute select * from `books` where `books`.`id` = 1 limit 1
2023-03-25T15:12:21.944612Z 18 Execute select * from `authors` where `authors`.`book_id` = 1 and `authors`.`book_id` is not null
2023-03-25T15:12:21.944994Z 18 Execute select * from `authors` where `authors`.`book_id` = 1 and `authors`.`book_id` is not null
2023-03-25T15:12:21.945214Z 18 Query START TRANSACTION
2023-03-25T15:12:21.945486Z 18 Execute select * from `books` where `books`.`id` = 1 limit 1 for update
2023-03-25T15:12:21.945757Z 18 Execute select * from `authors` where `authors`.`book_id` = 1 and `authors`.`book_id` is not null
2023-03-25T15:12:21.945890Z 18 Query COMMIT
Laravel の実行結果
Laravel には SELECT の結果をキャッシュしてくれる機能はありませんでした(完)
get()
を呼び出すたびにクエリが発行される点や、ロックするために lockForUpdate()->find(...)
で明示的に SELECT を行う必要がある点など、SQLとメソッドの対応関係が分かりやすいのは、Laravel の良い点かなと思いました。
まとめ
データの更新を行う場合は、なるべく最初に SELECT FOR UPDATE を行ってから必要な他のデータを SELECT するのが良いでしょう。しかし、 SELECT FOR UPDATE の前に他のテーブルに対して SELECT を行う必要がある場合は、ロストアップデートが発生しないよう注意してデータを更新するようにしましょう。
指摘などありましたらぜひコメントお願いします。