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

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

https://qiita.com/tatematsu-k/items/23d728fbcf591d44b959

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

# Rails で with_lock を使用した場合

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

```ruby:app/models/author.rb
class Author < ApplicationRecord
  belongs_to :book
end
```

```ruby:app/models/book.rb
class Book < ApplicationRecord
  has_many :authors, -> { order(:id) }, dependent: :destroy, inverse_of: :book
end
```

## ロックしたモデルのリレーションを直接呼び出した場合

```ruby
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実行のみ抜粋）
```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 を変数に保持していた場合を試します。

```ruby
    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
```

```sql
  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 を保持していた場合

```ruby
    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
```

```sql
  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

```php:app/Models/Book.php
class Book extends Model
{
    public function authors()
    {
        return $this->hasMany(Author::class);
    }
}
```

```php:app/Models/Author.php
class Author extends Model
{
    public function book()
    {
        return $this->belongsTo(Book::class, 'book_id', 'id');
    }
}
```

```php
    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());
        });
    }
```


```sql
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 を行う必要がある場合は、ロストアップデートが発生しないよう注意してデータを更新するようにしましょう。

https://qiita.com/shohei1913/items/52f50ae1f75ffec2696b

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