複数のユーザーが同じ商品を同時に購入しようとした場面を想像してください。在庫が1つしかないのに、2件の注文が通ってしまう。これは「Lost Update(後勝ち上書き)問題」と呼ばれる並行処理の定番トラブルです。

この記事では、JPAの @Version アノテーションを使った楽観的ロックで、この問題をどう解決するかを実装コード付きで解説します。

Lost Update問題とは

在庫10個の商品をユーザーAとBが同時に購入しようとするケースで考えてみましょう。

  1. ユーザーAが在庫を読み取る(10個)
  2. ユーザーBも在庫を読み取る(10個)
  3. ユーザーAが在庫を3個分更新する(10 - 3 = 7個)
  4. ユーザーBが在庫を5個分更新する(10 - 5 = 5個)← Aの更新を上書き!

本来は 7 - 5 = 2個になるべきところが、5個になってしまいます。予約システムの空き席チェックでも同じことが起きます。

解決アプローチは2つあります。悲観的ロック(SELECT FOR UPDATE)はDBレベルで行をロックし、楽観的ロックはバージョン番号で競合を後から検出します。競合頻度が低いWebアプリには、オーバーヘッドの少ない楽観的ロックが適しています。

@Versionの動作原理

@Version フィールドを持つエンティティをJPAが更新するとき、通常のUPDATE文に WHERE version = ? 条件が自動で追加されます。

UPDATE product SET stock = ?, version = 2 WHERE id = ? AND version = 1

バージョンが一致すれば更新成功でバージョンをインクリメント、一致しなければ0件更新となり、JPAが OptimisticLockException をスローします。シンプルですが、競合を確実に検出できる仕組みです。

エンティティに@Versionを追加する

@Entity
public class Product {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;
    private int stock;

    @Version
    private Long version; // JPAが自動管理、手動更新不要

    // getter/setter...
}

@Version フィールドに使える型は intIntegerlongLongTimestamp などです。通常は Long で十分です。Timestamp は同一ミリ秒内の更新を検出できないリスクがあるため、整数型を推奨します。

Spring Data JPA の JpaRepository と組み合わせる場合、追加設定は不要です。save() を呼ぶだけでバージョン管理が自動で行われます。

なお、バージョン番号をAPIのリクエスト・レスポンスに含める設計にしておくと、フロントエンドが「どのバージョンに基づいて更新したか」をサーバーに伝えられます。

OptimisticLockExceptionが発生するのを確認する

Spring Data JPA を経由した場合、スローされるのは org.springframework.orm.ObjectOptimisticLockingFailureException です。これは jakarta.persistence.OptimisticLockException をSpringがラップしたものです。実装では基本的にSpring側の例外をキャッチすれば問題ありません。

同じエンティティを2つのトランザクションで読み込み、片方をコミット後に残りをコミットすると例外が発生します。実際に確認したい場合は、後述するテストコードで再現できます。

例外をキャッチしてHTTP 409で返す

楽観的ロック例外は想定されるビジネス例外です。500エラーにせず、@ControllerAdvice で409 Conflictにマッピングしましょう。

@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ResponseEntity<ErrorResponse> handleOptimisticLock(
            ObjectOptimisticLockingFailureException ex) {
        ErrorResponse body = new ErrorResponse(
            "CONFLICT",
            "他のユーザーが同じデータを更新しました。最新のデータを取得してやり直してください。"
        );
        return ResponseEntity.status(HttpStatus.CONFLICT).body(body);
    }
}

サービス層でビジネス例外に変換するパターンも有効です。

@Transactional
public void purchaseProduct(Long productId, int quantity) {
    try {
        Product product = productRepository.findById(productId).orElseThrow();
        product.setStock(product.getStock() - quantity);
    } catch (ObjectOptimisticLockingFailureException e) {
        throw new StockConflictException("在庫情報が変更されました。再度お試しください。");
    }
}

フロントエンドへのエラーレスポンスには「最新データを取得してリトライするよう促すメッセージ」を含めておくと、ユーザー体験が向上します。

例外ハンドリング全般については Spring BootのREST APIで統一的なエラーレスポンスを返す方法 もご参照ください。

リトライ戦略:@Retryableと手動ループ

競合が発生したとき、自動でリトライしたい場合の2パターンを紹介します。

Spring Retryを使う

pom.xml に依存を追加し、メインクラスまたは設定クラスに @EnableRetry を付与します。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
@Retryable(
    retryFor = ObjectOptimisticLockingFailureException.class,
    maxAttempts = 3,
    backoff = @Backoff(delay = 100)
)
@Transactional
public void purchaseProduct(Long productId, int quantity) {
    Product product = productRepository.findById(productId).orElseThrow();
    product.setStock(product.getStock() - quantity);
}

@Retryable は宣言的でシンプルですが、リトライ条件に業務ロジックを絡めたい場合は柔軟性が不足します。その場合はwhileループによる手動リトライが制御しやすいです。リトライ上限を超えた場合はユーザーへのエラー通知かアラートログを検討してください。

並行テストをExecutorServiceで書く

楽観的ロックが正しく動いているかテストで確認しましょう。CountDownLatch で全スレッドを同時にスタートさせることで、競合を確実に再現できます。

@SpringBootTest
class ProductServiceConcurrentTest {

    @Autowired
    private ProductService productService;

    @Autowired
    private ProductRepository productRepository;

    @Test
    void 同時更新で片方がOptimisticLockExceptionをスローする() throws InterruptedException {
        Product product = new Product("テスト商品", 10);
        productRepository.save(product);
        Long productId = product.getId();

        ExecutorService executor = Executors.newFixedThreadPool(2);
        CountDownLatch latch = new CountDownLatch(1);
        AtomicInteger conflictCount = new AtomicInteger(0);

        for (int i = 0; i < 2; i++) {
            executor.submit(() -> {
                try {
                    latch.await();
                    productService.purchaseProduct(productId, 1);
                } catch (ObjectOptimisticLockingFailureException e) {
                    conflictCount.incrementAndGet();
                } catch (InterruptedException ignored) {}
            });
        }

        latch.countDown();
        executor.shutdown();
        executor.awaitTermination(5, TimeUnit.SECONDS);

        assertThat(conflictCount.get()).isGreaterThan(0);
    }
}

H2のインメモリDBを使えば外部環境なしで軽量に実行できます。

楽観的ロックが向かないケース

楽観的ロックはすべての場面に向くわけではありません。

競合頻度が高いケース(フラッシュセール、人気イベントのチケット即売など)では、競合とリトライのオーバーヘッドが積み重なってパフォーマンスが悪化します。処理時間の長いバッチ更新でも、途中で他のトランザクションに上書きされる確率が高くなります。

こういった高競合・長時間処理には悲観的ロック(@Lock(LockModeType.PESSIMISTIC_WRITE))が適しています。トランザクション管理の詳細は Spring Bootの@Transactionalでトランザクション管理を理解する をご参照ください。

多くのWebアプリは「読み取り多・更新少」なので、デフォルト戦略は楽観的ロックで十分です。

まとめ

@Version フィールドを追加するだけで、JPAが自動的にUPDATE文にバージョン条件を付与し、Lost Updateを防いでくれます。

  • エンティティに @Version Long version を追加
  • ObjectOptimisticLockingFailureException@ControllerAdvice でHTTP 409にマッピング
  • 自動リトライが必要なら @Retryable か手動ループで対応
  • ExecutorService + CountDownLatch で並行テストを書いて動作を確認

JPAエンティティのリレーションマッピングは Spring BootでJPAのEntityのリレーションをマッピングする方法、パフォーマンス最適化は Spring Boot Data JPAのパフォーマンスを改善する方法 も合わせてご覧ください。