0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

Laravel で lockForUpdate すると reload されるのか Rails と比較した

Posted at

Rails では SELECT の結果を保存しておき、再度読みに行ったときにメモリから結果を返してくれる機能があります。しかし、with_lock を使うと、データの一貫性のため、再度クエリを投げてくれるようです。

この機能が Laravel にもあるのかどうか調べてみました。

Rails で with_lock を使用した場合

バージョン:Rails 7.0.4.2
RDBMS:mysql
分離レベル:REPEATABLE-READ

app/models/author.rb
class Author < ApplicationRecord
  belongs_to :book
end
app/models/book.rb
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

app/Models/Book.php
class Book extends Model
{
    public function authors()
    {
        return $this->hasMany(Author::class);
    }
}
app/Models/Author.php
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 を行う必要がある場合は、ロストアップデートが発生しないよう注意してデータを更新するようにしましょう。

指摘などありましたらぜひコメントお願いします。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?