MySQL
laravel
mysql5.7
laravel5.5

Laravel5.5でバージョン番号の楽観ロックを実装する

環境

  • Laravel 5.5
  • MySQL5.7(トランザクション分離レベル:REPEATABLE READ)

準備

  • テーブルに「version」というカラムを用意する

前提条件

  • EloquentORMを使って更新している

Eloquentのイベントをフックするためのオブザーバを作る

それぞれ関数名をフックしたいイベントの名称にする。
MySQLでトランザクション分離レベルを「REPEATABLE READ」に設定している場合は、「lockForUpdate」などで悲観ロックをかければ、そのレコードに対して他トランザクションの更新後の値が取得できるようになる(「READ COMITTED」のような動きをする)ため、いい感じに楽観ロックをしてくれる。

OptimisticLockObserver.php
class OptimisticLockObserver
{

    public function created(Model $model)
    {
        $version = $model->getAttribute($model->getVersionColumn());
        if (!isset($version)) {
            $model->setAttribute($model->getVersionColumn(), 0);
        }
    }

    public function updating(Model $model)
    {
        $version = $this->optimisticLockCheck($model);
        // バージョンを1加算する
        $model->setAttribute($model->getVersionColumn(), ++$version);
    }

    private function optimisticLockCheck(Model $model) : int
    {
        // 楽観ロックチェックに使う値の取得
        $version = $model->getAttribute($model->getVersionColumn());
        $id = $model->getKey();

        // バージョン番号設定されていないとエラー
        if (!isset($version)) {
            throw new OptimisticLockException();
        }

        // 主キーがなければエラー
        if (!isset($id)) {
            throw new OptimisticLockException();
        }

        // DBから対象のレコード取得
        if (!$model->isRestoringFlg()) {
            $dbModel = $model->getMorphClass()::lockForUpdate()->find($id);
        } else {
            // restoreは削除済みレコードを取得する必要があるため「withTrashed()」してからレコード取得
            $dbModel = $model->getMorphClass()::withTrashed()->lockForUpdate()->find($id);
        }

        // 対象のレコードが取得できない場合はエラー
        if (!isset($dbModel)) {
            throw new OptimisticLockException();
        }

        // DBに設定されているバージョン番号取得
        $dbModelVersion = $dbModel->getAttribute($dbModel->getVersionColumn());
        // バージョン番号が一致しなければエラー
        if ($version != $dbModelVersion) {
            throw new OptimisticLockException();
        }
        return $version;
    }

    public function deleting(Model $model)
    {
        $this->optimisticLockCheck($model);
    }

    public function restoring(Model $model)
    {
        // リストア時はUPDATEも入るためmodelのrestoringFlgをtrueにする
        $model->setRestoringFlg(true);
        $this->optimisticLockCheck($model);
    }

    public function restored(Model $model)
    {
        // リストアが完了したらmodelのrestoringFlgをfalseに戻す
        $model->setRestoringFlg(false);
    }
}

Modelにオブザーバを登録するトレイトを作る

OptimisticLockObserverで使いたいプロパティとかもここに置いておく。
(versionColumnを書き換えてしまえばとりあえずはカラム名の変更はできる)

OptimisticLocks.php
trait OptimisticLocks
{
    protected $restoringFlg = false;

    protected $versionColumn = 'version';

    public static function bootOptimisticLocks()
    {
        self::observe(OptimisticLockObserver::class);
    }

    public function isRestoringFlg(): bool
    {
        return $this->restoringFlg;
    }

    public function setRestoringFlg(bool $restoringFlg)
    {
        $this->restoringFlg = $restoringFlg;
    }

    public function getVersionColumn(): string
    {
        return $this->versionColumn;
    }

    public function setVersionColumn(string $versionColumn)
    {
        $this->versionColumn = $versionColumn;
    }
}

独自例外を作る

OptimisticLockException.php
class OptimisticLockException extends Exception
{
}

Modelにトレイトを追加する

通常使う時は特に意識せずに楽観ロックがかかるようにするため、Modelを複数に分けて通常名には楽観ロックのトレイトを追加し、Repositoryにモデル規約を記載する。

User.php
class User extends UserRepository
{
    use OptimisticLocks;
}
UserRepository.php
class UserRepository extends Model
{
    use SoftDeletes;

    protected $table = 'users';

    protected $guarded = array('id', 'created_at', 'updated_at');
}

使い方の例

楽観ロック有り

画面上でversionの値を保持してその値を送信する。(HTMLは省略)
その値をModelに設定することで、楽観ロックのチェックを行う。

DB::transaction(function () {
    $user = User::find($id);
    $user->name = $name;
    $user->version = $version;
    $user->save();
});

楽観ロック無し

RepositoryのModelを使うことで、楽観ロックのトレイトが適用されなくなる。
バッチ処理等で楽観ロックを必要としない場合や、大量のデータ更新が必要で処理速度重視にする場合はこっちを利用。

DB::transaction(function () {
    $user = UserRepository::find($id);
    $user->name = $name;
    $user->save();
});

できないこと

  • 複数更新の場合、Eloquentのイベントが発行されないため、楽観ロックが動作しない
  • 主キーが複数カラムで構成されているテーブルは正常に動作しないはず(試していない)
  • versionのカラム名をいい感じにModelに指定して上書き

参考にさせていただいた記事

Laravelで独自例外処理を実装する(楽観的排他制御andトランザクション処理)
https://qiita.com/sakuraya/items/a511f0e615717a6b7628
Laravelのeloquentのeventでcreated_byとかupdated_byとか更新するobserverとtrait
https://qiita.com/maimai-swap/items/6597c04721adbc48fec2