API・データベース ·

楽観ロックと悲観ロックの使い分け — versionカラムと衝突の見せ方

  • データベース
  • 排他制御
  • Laravel

この記事で扱う問題

注文の確定とキャンセルは、どちらも「注文を読み、状態を変え、保存する」処理です。

$order = $this->orders->findOrFail($id);
$order->confirm($operatorId, $this->clock->now());
$this->orders->save($order);

状態変更のルールは Order が守っています。未確定の注文しか確定できず、未確定の注文しかキャンセルできません。ところが、確定とキャンセルがほぼ同時に来ると、例えば次のことが起こり得ます。

  1. 確定の処理が注文を読む(未確定)
  2. キャンセルの処理が同じ注文を読む(未確定)
  3. 確定の処理が「確定済み」で保存する
  4. キャンセルの処理が「キャンセル済み」で保存する

どちらの処理も、自分が読んだ時点では正しい判断をしています。結果として、出荷の依頼まで済んだ注文がキャンセル済みになり、エラーはどこにも出ません。後から書いた更新が先の更新を黙って消す、いわゆる lost update です。

人が画面で編集する場合も同じです。2人の担当者が同じ注文の配送先を開き、それぞれ直して保存すると、先に保存した人の修正は消えます。こちらは、読んでから書くまでの間が数分になることもあります。

判断

私は次の3つを、衝突の頻度と「読んでから書くまでの長さ」で使い分けます。

方式 向いている場面 衝突したとき
条件付きの UPDATE 加算・減算のように、読まずに1文で書ける更新 0行になる。業務の失敗(在庫切れなど)として扱う
楽観ロック(version カラム) 集約の保存全般。人が画面で編集する更新 保存が失敗する。やり直すか、利用者に知らせる
悲観ロック(SELECT ... FOR UPDATE) 同じ行に短い間隔で更新が集中する処理 後から来た処理が待つ

既定は楽観ロックにし、条件付きの UPDATE で済むものはそれで済ませ、悲観ロックは衝突が多いと分かった処理にだけ使います。

楽観ロック:version を条件にして保存する

既存の Order に、読み込んだ時点の version を持たせます(confirm() などのほかのメソッドは変わりません)。version は永続化の都合にも見えますが、後で見るように「利用者が画面で見ていた版と、今の版が同じか」を判断に使う値なので、集約から読めるようにします。

final class Order
{
    private function __construct(
        private readonly OrderId $id,
        private OrderStatus $status,
        private ?DateTimeImmutable $confirmedAt,
        private ?int $confirmedBy,
        private readonly int $version,   // 読み込んだ時点の version。まだ保存していない注文は 0
    ) {
    }

    public static function place(OrderId $id): self
    {
        return new self($id, OrderStatus::Pending, null, null, 0);
    }

    public static function reconstitute(
        OrderId $id,
        OrderStatus $status,
        ?DateTimeImmutable $confirmedAt,
        ?int $confirmedBy,
        int $version,
    ): self {
        if ($status === OrderStatus::Confirmed && ($confirmedAt === null || $confirmedBy === null)) {
            throw new InvalidOrderState($id);
        }

        return new self($id, $status, $confirmedAt, $confirmedBy, $version);
    }

    public function version(): int
    {
        return $this->version;
    }

    // id()、confirm()、cancel() は既存の定義のまま(省略)
}

version の確認は、Repository の save() に持たせます。呼び出し側が確認を書き忘れても、保存の経路を通る限り必ず効きます。

final class EloquentOrderRepository implements OrderRepository
{
    // findOrFail() は既存の定義のまま。OrderMapper::toDomain() が version も読み込む

    /** UseCase のトランザクションの中で呼ぶ。1回の操作で、同じ Order の保存は1回だけ */
    public function save(Order $order): void
    {
        if ($order->version() === 0) {
            $model = new OrderModel();
            OrderMapper::fill($model, $order);   // fill() は version を書かない
            $model->version = 1;
            $model->push();

            return;
        }

        // 読み込んだときの version のままなら、1つ進める。ほかの処理が先に保存していれば 0 行になる
        $updated = OrderModel::query()
            ->whereKey($order->id()->value)
            ->where('version', $order->version())
            ->update(['version' => $order->version() + 1]);

        if ($updated === 0) {
            throw new OrderWasModifiedConcurrently($order->id());
        }

        // 上の UPDATE で行ロックを取っているので、コミットまでほかの save() はここに割り込めない
        $model = OrderModel::findOrFail($order->id()->value);
        OrderMapper::fill($model, $order);
        $model->push();
    }
}

冒頭の例では、確定とキャンセルのどちらかが OrderWasModifiedConcurrently で失敗し、トランザクションごとロールバックされます。

save() は手元の Order の version を進めません。同じオブジェクトをもう一度保存すると、新しい注文なら主キー違反、既存の注文なら DB の version がもう進んでいるので、衝突ではないのに OrderWasModifiedConcurrently になります。1回の操作で保存は1回にし、続けて変更するときは読み直します。

DB による違いも確かめておきます。MySQL(InnoDB)の既定の REPEATABLE READ でも、UPDATE はスナップショットではなく最新のコミット済みの行を読むので、先に保存された行は version が合わず 0 行になります。PostgreSQL の既定の READ COMMITTED では、後から来た UPDATE は先のトランザクションの終了を待ち、コミットされた行で条件を評価し直して 0 行になります。PostgreSQL の REPEATABLE READ 以上では、0 行ではなく serialization failure の例外になるので、その例外も同じ「衝突」として扱う必要があります。Laravel の DB::transaction() に試行回数を渡せば、デッドロックや serialization failure のときにトランザクションごとやり直してくれるので、これを使う手もあります。

また、PDO で MySQL に接続した既定の設定では、UPDATE が返すのは「条件に合った行数」ではなく「値が変わった行数」です。version のように必ず値が変わる列を更新しておけば、この2つがずれません。

衝突したら:やり直すか、利用者に返すか

衝突したときの扱いは、操作が「何を見て」判断しているかで分けます。

**今の状態だけで判断する操作は、読み直してやり直します。**確定やキャンセルの可否は、保存されている状態だけで決まります。やり直せば、新しい状態を読んで正しく判断し直します(キャンセル済みなら、今度は OrderCannotBeConfirmed になります)。

final class RetryOnConcurrentModification
{
    public function __construct(private int $maxAttempts = 3)
    {
    }

    /**
     * 読み込みから保存までを含む操作を渡す。外側にトランザクションがない場所で呼ぶ
     *
     * @template T
     * @param callable(): T $operation
     * @return T
     */
    public function run(callable $operation): mixed
    {
        for ($attempt = 1; ; $attempt++) {
            try {
                return $operation();
            } catch (OrderWasModifiedConcurrently $e) {
                if ($attempt >= $this->maxAttempts) {
                    throw $e;
                }
            }
        }
    }
}

Controller では $retry->run(fn () => $this->useCase->execute($id, $operatorId)) のように UseCase ごと包みます。外側にトランザクションがあると、やり直しても同じトランザクションの中なので、新しい状態を読めないことがあります(MySQL の REPEATABLE READ なら、同じスナップショットを読み続けます)。キューのジョブなら、例外をそのまま投げてジョブのリトライに任せれば足ります。

**人が画面で見た内容をもとにする操作は、やり直さずに利用者へ返します。**配送先の変更をサーバーが勝手にやり直すと、相手の修正を、それを見ていない人の入力で上書きすることになり、lost update をそのまま再現します。

画面を開いたときの version をクライアントに渡し、保存のリクエストで送り返してもらいます。

final class ChangeShippingAddress
{
    public function __construct(
        private OrderRepository $orders,
        private TransactionManager $transaction,
    ) {
    }

    public function execute(OrderId $id, int $expectedVersion, ShippingAddress $address): void
    {
        $this->transaction->run(function () use ($id, $expectedVersion, $address): void {
            $order = $this->orders->findOrFail($id);

            if ($order->version() !== $expectedVersion) {
                throw new OrderWasModifiedConcurrently($id);   // 画面を開いた後に、誰かが更新した
            }

            $order->changeShippingAddress($address);
            $this->orders->save($order);   // 読んでから保存するまでの間の割り込みは、save() が止める
        });
    }
}

(Order に配送先と changeShippingAddress() がある前提です。定義は省略します。)

API では、取得のレスポンスに version を含め、更新のリクエストで必須にします。衝突したら 409 Conflict を返し、利用者には「ほかの人が先に更新しました」と伝えます。このとき、入力した内容は捨てずに画面に残し、最新の内容と並べて見せると、利用者は自分の修正をもう一度当て直すだけで済みます。HTTP の標準に沿うなら、ETag と If-Match で同じことができ、不一致は 412 Precondition Failed になります。どちらでも考え方は同じなので、クライアントが扱いやすい方を選びます。

条件付きの UPDATE:読まずに書けるなら、それで済ませる

在庫の引き当てのように、同じ行に更新が集中し、しかも「残っていれば減らす」という1文で書ける更新は、ロックもバージョンも要りません。

use Illuminate\Database\ConnectionInterface;

final class DatabaseStockAllocator implements StockAllocator
{
    public function __construct(private ConnectionInterface $db)
    {
    }

    /** $quantity は1以上。在庫が足りれば引き当てて true、足りなければ何もせず false を返す */
    public function allocate(string $sku, int $quantity): bool
    {
        $updated = $this->db->table('stocks')
            ->where('sku', $sku)
            ->where('available', '>=', $quantity)
            ->decrement('available', $quantity);

        return $updated === 1;
    }
}

確認と更新が1文の中で行われるので、間に割り込まれません。楽観ロックにすると、人気の商品では失敗とやり直しがほとんどになります。

悲観ロック:衝突が多いと分かった処理にだけ

決済の通知、在庫の引き当ての結果、利用者のキャンセルが、同じ注文に短い間隔で集中する、といった処理では、楽観ロックだと失敗とやり直しが続きます。こうした処理だけ、読む時点で行をロックします。

interface OrderRepository
{
    public function findOrFail(OrderId $id): Order;

    /** トランザクションの中で呼ぶ。コミットかロールバックまで、同じ注文の findForUpdate() と save() を待たせる */
    public function findForUpdate(OrderId $id): Order;

    public function save(Order $order): void;
}

実装は OrderModel::with('items')->lockForUpdate()->findOrFail($id->value) を findOrFail() と同じようにドメインのオブジェクトへ変換します。ロックするのは注文の行です。eager load の明細は別のクエリになり、明細の行はロックされません。明細だけを更新する処理も必ず注文を通して保存する、という約束で、注文の行のロックを集約全体のロックとして使います。

悲観ロックを使うときは、次のことを守ります。

  • トランザクションの中で使う:外で lockForUpdate() を呼んでも、文が終わった時点でロックは外れます
  • ロックを持ったまま待たない:外部 API の呼び出しやキューへの書き込みは、ロックの外に出します。相手が遅いと、その間ずっと同じ注文の処理がすべて止まります
  • 複数の行をロックするなら順序をそろえる:ID の順に取らないと、2つの処理が互いの行を待ってデッドロックになります
  • 待つ時間の上限を決める:MySQL の innodb_lock_wait_timeout は既定で 50 秒、PostgreSQL の lock_timeout は既定で無制限です。画面から呼ぶ処理なら、応答の予算に合わせて短くします

まだ存在しない行は、FOR UPDATE ではロックできないか、DB と分離レベルによって振る舞いが変わります。重複の防止に使うなら、一意制約で競合させるほうを選びます。

なぜそう判断したか

悲観ロックを既定にしない理由

悲観ロックは、衝突を「待つ」ことで防ぎます。待てるのは、1つのトランザクションの中、つまり1回のリクエストの中だけです。人が画面を開いてから保存するまでの数分間、DB のロックを持ち続けることはできません。画面の編集には、どのみち version が要ります。

また、悲観ロックは読む側が「ロックして読む」ことを覚えている必要があります。どこか1か所で findOrFail() を使って読み、保存すれば、そこから lost update が起きます。楽観ロックは保存の経路に確認を置くので、呼び出し側が何も覚えていなくても効きます。状態変更のルールを Order に持たせるのと同じで、守る責任を1か所にまとめたい、というのが私の判断の軸です。

楽観ロックも、保存の瞬間には UPDATE で行ロックを取っています。違いは「ロックをいつから持つか」です。読んでから書くまでの間はロックを持たず、書く瞬間だけ持つので、衝突が少ない限り、待ちもデッドロックもほとんど起きません。

updated_at を version の代わりにしない理由

updated_at を条件にする方法もあります。ただ、多くの環境では秒の単位でしか保存されず、同じ秒の2つの更新を区別できません。サーバー間の時計のずれや、別の目的で touch() された更新も衝突に見えます。version は「何回保存されたか」だけを表す値なので、比べる意味がはっきりしています。

変わった項目だけを保存する方法を選ばなかった理由

配送先の変更と状態の変更は別の項目なので、変わった列だけを UPDATE すれば衝突しない、という考え方もあります。ただ、Order が守っている不変条件は、複数の項目の組み合わせにまたがることがあります(確定済みなら確定日時と確定者がそろっている、など)。項目ごとに別々に上書きされると、それぞれは正しくても、組み合わせとして不正な状態ができ得ます。集約は1つの単位として読み、1つの単位として保存します。

どこから設計過剰になるか

  • 1人しか編集しない設定画面:衝突がほぼ起きず、起きても後勝ちで困らないなら、version は要りません。困るかどうかは、消えた更新に誰がいつ気づくかで決めます
  • 追記するだけのデータ:ログやポイントの付与記録のように、既存の行を書き換えないテーブルには、ロックもバージョンも要りません
  • すべてのテーブルに version を足す:まずは、複数の人や処理が同じ行を更新する集約から始めます。一覧や集計のように読み取りだけのデータには不要です
  • やり直しの回数を増やす:3回やり直しても衝突が続くなら、回数を増やすより、同じ行に更新が集中している設計を疑います。条件付きの UPDATE にできないか、悲観ロックに切り替えるか、集約が大きすぎないかを見直します
  • 既存のテーブルに version を足すとき:列は既定値を 1 にして足します(既存の行も 1 になります)。既定値が 0 だと、移行中に古いコードが INSERT した注文を新しいコードが「まだ保存していない注文」と読み、save() が INSERT して主キー違反になります。既定値のない NOT NULL なら、古いコードの INSERT そのものが失敗します。また、古いコードと新しいコードが同時に動く間は、古いコードが version を進めずに保存するので、その間の衝突は検出できません。すべてのサーバーが新しいコードになってから、確認が効き始めます

まとめ

  • 読んでから書き戻す処理は、同時に動くと先の更新を黙って消す。読まずに1文で書けるなら条件付きの UPDATE で済ませる
  • 既定は version カラムの楽観ロックにし、確認は Repository の save() に置く。今の状態だけで判断する操作はやり直し、画面で見た内容に基づく操作は 409 で利用者に返す
  • 悲観ロックは衝突が多いと分かった処理にだけ使い、トランザクションの中で短く持ち、外部の呼び出しをロックの外に出す

本記事の設計判断・事例・技術的見解は筆者によるものです。構成・文章編集には生成AIを利用しています。