複数Transactionでの同一レコードEntityの更新処理について

きっかけ

「セキュア・バイ・デザイン」の第7章で複数のスレッドでEntityが共有された時に起こりうるデータ不整合について書かれており
正直これを読んで不整合の対策として挙げられていた「エンティティ・スナップショット」はあまり理解できず、代わりに
複数Transactionでほぼ同時に特定のレコードに対してEntityを通して更新処理を行った場合の挙動がどうなるのか気になり調べてみました。

前提

今回、例として50万円の残高がある銀行口座に対してほぼ同時に
1. 口座の持ち主が30万円引き出す。
2. 銀行が40万円の自動振替を行う
を行うとします。

口座クラス

Account.php

<?php
class Account {
    
    private ?Int $id;

    private Balance $balance;

    public function __construct(
        ?Int $id,
        Balance $balance,
    ){
        $this->id = $id;
        $this->balance = $balance;
    }

    public function withdraw(Money $money): Account{
        if (!$this->balance->moreThan($money)) {
            throw new \Exception('残高以上に引き出すことはできない。');
        }
        $balance = $this->balance->subtract($money);

        return new Account(
            $this->id,
            $balance,
        );
    }
}

残高クラス

Balance.php

<?php
class Balance {

    private Int $value;

    public function __construct(
        Int $value,
    ){
        if ($value < 0) {
            throw new \Exception('残高金額は0円以上でなければならない。');
        }
        $this->value = $value;
    }

    public function moreThan(Money $money): bool {
        return $this->value < $money->getValue();
    }

    public function subtract(Money $money): Balance {
        return new Balance(
            $this->value - $money->getValue()
        );
    }

    private function getValue():Int {
        return $this->value;
    }
}

金額クラス

Money.php

<?php
class Money {

    private Int $value;

    public function __construct(
        Int $value,
    ){
        if ($value < 0) {
            throw new \Exception('金額は0円以上でなければならない。');
        }
        $this->value = $value;
    }

    public function getValue():Int {
        return $this->value;
    }
}

口座の持ち主が30万円引き出す(疑似コード)

AccountController.php

<?php
// LaravelのControllerの処理を記述しています。

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;

class AccountController extends Controller {

    public function withdraw(
        Request $request,
        AccountServiceInterface $accountService,
    ) {
        // ログイン情報からユーザを取得
        $user = Auth::user();
        // 残高50万円で取得
        $account = $accountService->findByUser($user->id);
        if (is_null($account)) {
            session()->flash('error_message','ユーザの口座が存在しない。');
            return back();
        }

        $withdrawAmount = $request->input('amount');
        $money = new Money($withdrawAmount);
        // 残高から30万円引き出し、保存
        $accountService->withdraw($account, $money);

        session()->flash('success_message','引き出し成功');
        return redirect()->route('account.success');
    }
}

銀行が40万円の自動振替を行う(疑似コード)

BankController.php

<?php

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;

class BankController extends Controller {

    public function transfer(
        Int $fromAccountId,
        Int $toAccountId,
        Request $request,
        AccountServiceInterface $accountService,
    ) {
        // 残高50万円で取得
        $fromAccount = $accountService->find($fromAccountId);
        if (is_null($fromAccount)) {
            session()->flash('error_message','送り元の口座が存在しない。');
            return back();
        }
        $toAccount = $accountService->find($toAccountId);
        if (is_null($toAccount)) {
            session()->flash('error_message','送り先の口座が存在しない。');
            return back();
        }

        $transferAmount = $request->input('amount');
        $money = new Money($transferAmount);
        // 残高から40万円振替を行い、保存。
        $accountService->transfer($fromAccount, $toAccount, $money);

        session()->flash('success_message','振替成功');
        return redirect()->route('account.success');
    }
}

問題点

どちらの処理も1度DBから残高50万円のデータをEntityに格納しているため
1.30万円を引き出すためにEntityに格納(残高50万円)
2.40万円を振替するためにEntityに格納(残高50万円)
3.引き出し処理が完了し、残高が20万円で保存される。
4.残高20万円-40万円=−20万円でエラーが起きてロールバックされる?
→本来は振替処理が完了し、残高が10万で保存されてしまう!!!(ロストアップデート)

※DBの想定としてはMySQLでデフォルトのRepeatable Readです。

候補1:排他ロックを使う

・該当のレコードに行ロックがかかり、操作1のトランザクション中は他のトランザクションは同じレコードを参照できず待機状態になるため不整合が起きない

SELECT * from accounts where id=1 for update;

候補2:SQLのUPDATE文をデクリメントするように記述する

この方法はTeratailの質問に回答していただいた方の方法なのですが
・UPDATE文を

UPDATE accounts set balance = 200000 where id = 1;

ではなく

UPDATE accounts set balance = balance - 300000 where id = 1;

という方法です。
これはロックを使用しないため、使いやすくて良いかもと思ったのですが
今回はRepeatable Readであるため
トランザクション中は他のトランザクションの更新情報は反映されません。
(ファジーリード)
よってこれは解決策にはなりませんでした。

まとめ:明示的ロックを理解して使いましょう