在庫の引き当てや残高の更新で、「読んだ値をもとに更新する」処理は競合するとやっかいですよね。二つのリクエストが同じ在庫を同時に読んで、それぞれ引き算して書き戻すと、片方の更新が消えてしまいます。

楽観的ロックでリトライさせる手もありますが、競合が多い在庫や決済では「そもそもDBで直列化してしまいたい」ことがあります。そこで使うのが悲観的ロックです。この記事では Spring Data JPA の @LockPESSIMISTIC_WRITE を付与する実装から、FOR UPDATE の確認、タイムアウト、デッドロック回避までを実コードで見ていきます。

悲観的ロックとは何か - 楽観的ロックとの違い

悲観的ロックは、行を読む時点でDBに排他ロックを取らせ、トランザクションが終わるまで他のトランザクションを待たせる方式です。競合が起きてから検知するのではなく、最初から順番待ちさせて競合そのものを防ぎます。

対して楽観的ロックは @Version カラムで更新時に競合を検知し、ぶつかったら例外を投げてリトライさせます。競合が稀ならロックのコストがなく高速ですが、競合が多いとリトライだらけになります。

つまり、在庫・残高・決済のように「確実に一件ずつ処理したい」ケースは悲観的ロック、更新がめったにぶつからないケースは楽観的ロックが向いています。楽観的ロックの実装詳細は Spring Bootで楽観的ロック(@Version)を実装する にまとめているので、そちらと合わせて読むと使い分けが見えてきます。

@LockとLockModeType

悲観的ロックは Repository メソッドに @Lock を付けるだけで有効になります。在庫エンティティ Stock を題材にしてみましょう。

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Optional<Stock> findByIdForUpdate(@Param("id") Long id);
}

PESSIMISTIC_WRITE は排他ロックで、他のトランザクションからの読み書きを待たせます。更新前提のロックはこれを使います。

一方 PESSIMISTIC_READ は共有ロックです。自分は読むだけで他者の更新は待たせたいが、他者の読み取りは許すという場面で使います。多くのユースケースでは更新するために読むので、PESSIMISTIC_WRITE を選んでおけば間違いありません。

なお findById をそのままオーバーライドして @Lock を付けることもできますが、ロックする版としない版を明確に分けたいので、上のように専用メソッド名にしておくと読みやすいです。

@Transactional境界が必須な理由

ここが一番ハマるポイントです。悲観的ロックはトランザクションが開いている間だけ保持されます。@Transactional がないと、ロックを取った直後にトランザクションが閉じてロックも即解放され、まったく意味をなしません。

読み取りから更新までを必ず同一トランザクションに収めます。

@Service
public class StockService {

    private final StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional
    public void consume(Long stockId, int quantity) {
        Stock stock = stockRepository.findByIdForUpdate(stockId)
                .orElseThrow(() -> new IllegalArgumentException("在庫が見つかりません"));

        if (stock.getQuantity() < quantity) {
            throw new IllegalStateException("在庫不足です");
        }
        stock.setQuantity(stock.getQuantity() - quantity);
    }
}

findByIdForUpdate でロックを取ってから在庫を減らすまでが同じトランザクション内なので、この間ほかのリクエストは同じ行を触れません。結果として在庫の引き算が一件ずつ直列化されます。トランザクション境界そのものの挙動が不安な人は Spring Bootのトランザクション管理 も参照してください。

発行されるSQLをログで確認する

本当にロックが効いているか、発行SQLをログで確かめましょう。application.properties でSQLログを出します。

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG

PESSIMISTIC_WRITE なら、PostgreSQLではこんな出力になります。

select s.id, s.quantity from stock s where s.id = ? for update

MySQLでも同様に for update が付きます。PESSIMISTIC_READ の場合はDBによって異なり、PostgreSQLでは for share、MySQLでは for share(8.0以降)や lock in share mode になります。

ロックが実際に効くかは、二つのトランザクションで観察すると分かりやすいです。片方でロックを取って処理を止め、もう片方から同じIDを findByIdForUpdate で読むと、後者は前者のコミットまでブロックされます。これが直列化の正体です。

jakarta.persistence.lock.timeoutでロック待ちを制御する

ロック待ちを無制限に放置すると、詰まったリクエストがコネクションを握り続けてしまいます。jakarta.persistence.lock.timeout で待ち時間を制御しましょう。@QueryHints で指定します。

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdForUpdateWithTimeout(@Param("id") Long id);

値の意味はこうです。正の値は待機ミリ秒(上の例は3秒)、0 は待たずに即失敗する NOWAIT、-1 は無限待ちです。

タイムアウトすると jakarta.persistence.LockTimeoutExceptionPessimisticLockException が飛んできます。ここでリトライさせるかエラーを返すかは、業務要件しだいです。

注意したいのは、このヒントのサポートがDB依存という点です。PostgreSQLは待機ミリ秒指定に対応しておらず、0 の NOWAIT のみ効き、待機時間は lock_timeout パラメータ側で制御します。MySQLは innodb_lock_wait_timeout が基本で、こちらも数値指定の扱いに癖があります。想定するDBで必ずログと実挙動を確認してください。

デッドロックの発生原理と回避

複数行をロックし始めると、デッドロックが顔を出します。口座Aから口座Bへの送金と、逆方向の送金が同時に走る場面を考えます。

  • トランザクション1がAをロックし、次にBを取りにいく
  • トランザクション2がBをロックし、次にAを取りにいく

お互いが相手の持つロックを待ち、永遠に進みません。これがデッドロックです。DBが検知して片方を強制的に失敗させます。

回避の定石は、ロックを取る順序を常に同じにすることです。たとえばIDの昇順で必ず取得すれば、逆順待ちの状況そのものが起きません。

@Transactional
public void transfer(Long fromId, Long toId, int amount) {
    // 常にID昇順でロックを取得してデッドロックを防ぐ
    List<Long> ids = Stream.of(fromId, toId).sorted().toList();
    Map<Long, Account> locked = new HashMap<>();
    for (Long id : ids) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElseThrow(() -> new IllegalArgumentException("口座なし"));
        locked.put(id, account);
    }

    Account from = locked.get(fromId);
    Account to = locked.get(toId);
    from.withdraw(amount);
    to.deposit(amount);
}

送金でも在庫振替でも、複数行を触るなら「必ずソートしてから順にロック」を徹底するのが安全です。それでもデッドロックがゼロになるとは限らないので、DeadlockLoserDataAccessException を捕まえて数回リトライする備えも入れておくと堅牢になります。

楽観的ロックと悲観的ロックの選択基準

最後に、どちらを選ぶかの目安を整理します。

競合が頻繁で、多少待たせても確実に処理したいなら悲観的ロックです。人気商品の在庫引き当てや残高更新のように、リトライが空振りしやすい場面で効きます。

逆に競合が稀で、スループットを優先し、たまのリトライを許容できるなら楽観的ロックです。ロックによる待ちがないぶん、並行性が高くなります。

決済や在庫引当のように「一件も取りこぼしたくない」処理は、悲観的ロックで直列化するほうが素直です。一方、外部からの二重リクエスト対策はロックだけに頼らず、冪等キーの実装 と組み合わせると安心です。また、ロックで待つトランザクションが増えるとコネクションを消費するので、待ち時間の設計は HikariCPのプールチューニング とセットで考えておきましょう。

まとめ

悲観的ロックは @Lock(LockModeType.PESSIMISTIC_WRITE) を Repository メソッドに付け、@Transactional の中で読み取りから更新までを行うのが基本形でした。発行される FOR UPDATE はSQLログで確認でき、jakarta.persistence.lock.timeout で待ちを制御し、複数行はID順にロックしてデッドロックを避けます。競合の頻度とリトライの許容度を軸に、楽観的ロックと使い分けていきましょう。