Spring BootにおけるObjectOptimisticLockingFailureExceptionの解決:JPA同時実行制御の戦略

intermediate Java2026-07-14

エラーの背景高トラフィック時にこのようなエラーに遭遇したことがあるかもしれません。開発環境ではすべて正常に動作していても、2人のユーザーがまったく同じミリ秒に「保存」をクリックすると、ログにエラーが溢れ出します。これは、Spring Data JPAが@Versionフィールドを使用して、あるトランザクションが別のトランザクションを黙って上書きするのを防いでいるために起こります。この戦略は「楽観的ロック(Optimistic Locking)」と呼ばれます。

Hibernateは、正確に1行を更新することを期待します。しかし、別のスレッドが先にレコードを修正してしまった場合、データベース内のバージョン番号は現在のスレッドが保持しているものと一致しなくなります。WHERE句に一致する行が0行になるため更新は失敗し、以下の例外がスローされます。

org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1

実世界の例ある商品の在庫が10個(バージョン1)ある在庫管理システムを想像してください。ユーザーAとユーザーBが同時にページを読み込みます。ユーザーAが2個購入します。システムは在庫を8に更新し、バージョンを2にインクリメントします。その1ミリ秒後、ユーザーBが1個購入しようとします。彼らのリクエストはまだバージョンが1であると考えています。データベースがid=X AND version=1を探すと、バージョンはすでに2になっているため、何も見つかりません。その結果、更新は失敗します。

根本原因:バージョンの不一致原因は、ほぼ間違いなくエンティティ内のバージョン管理用カラムにあります。ほとんどのJPA設定は以下のようになっています。

@Entity
public class Inventory {
    @Id
    private Long id;

    private Integer stockCount;

    @Version
    private Long version;
}

内部的に、Hibernateは特定のSQLクエリを実行します。

UPDATE inventory SET stock_count = ?, version = 2 WHERE id = ? AND version = 1;

このクエリが完了する前に別のプロセスがバージョンを2に変更していた場合、WHERE句は影響を受ける行として0を返します。Springはこれを検知し、データの整合性を守るために即座にObjectOptimisticLockingFailureExceptionをスローします。

エラーの解決方法### アプローチ1:Spring Retryの使用(業界標準)これらの衝突は通常一時的なものであるため、最も簡単な解決策は再試行することです。2回目の試行ではデータベースから最新のバージョンを取得するため、成功する可能性が高くなります。これはエンドユーザーにとってシームレスな解決策です。

まず、以下の依存関係をpom.xmlに追加します。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-aspects</artifactId>
</dependency>

設定クラスでリトライメカニズムを有効にします。

@Configuration
@EnableRetry
public class RetryConfig {
}

次に、サービスメソッドにアノテーションを付与するだけです。maxAttempts = 3を設定することで、システムが競合を解決するための十分な猶予を与えます。

@Service
public class InventoryService {

    @Autowired
    private InventoryRepository repository;

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class, 
        maxAttempts = 3, 
        backoff = @Backoff(delay = 100)
    )
    @Transactional
    public void updateStock(Long id, Integer quantity) {
        Inventory inventory = repository.findById(id)
            .orElseThrow(() -> new RuntimeException("Product not found"));
        
        inventory.setStockCount(inventory.getStockCount() - quantity);
        repository.save(inventory);
    }
}

アプローチ2:手動でのキャッチとリカバリ衝突が発生した際に警告をログに出力したり、監視システムに通知したりするなど、特定のロジックが必要な場合は手動での処理が適しています。アノテーションベースのアプローチよりも詳細な制御が可能です。

public void safeUpdate(Long id, Integer quantity) {
    int attempts = 0;
    while (attempts < 3) {
        try {
            businessLogicService.updateStock(id, quantity);
            return; 
        } catch (ObjectOptimisticLockingFailureException e) {
            attempts++;
            if (attempts == 3) throw e;
            log.warn("ID {} で競合が検出されました。再試行中... 試行回数 {}", id, attempts);
        }
    }
}

アプローチ3:悲観的ロックへの切り替え1つのアイテムに対して毎秒500以上の同時リクエストが発生するようなフラッシュセールを実施していますか?競合が非常に激しいシナリオでは、リトライは非効率になります。代わりに、データを読み取った瞬間にデータベースレベルでその行をロックします。

@Repository
public interface InventoryRepository extends JpaRepository<Inventory, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT i FROM Inventory i WHERE i.id = :id")
    Optional<Inventory> findByIdForUpdate(@Param("id") Long id);
}

これにより、他のトランザクションはキューで待機せざるを得なくなります。例外は完全に排除されますが、ロックが長く保持されるとアプリケーションの動作が遅くなる可能性があります。

検証手順推測するのではなく、テストしましょう。統合テストでCountDownLatchを使用して、2つのスレッドを強制的に衝突させます。これにより、リトライロジックが負荷のかかる状況で実際に機能することを確認できます。

@Test
void testConcurrentUpdate() throws InterruptedException {
    int threadCount = 2;
    ExecutorService executor = Executors.newFixedThreadPool(threadCount);
    CountDownLatch latch = new CountDownLatch(threadCount);

    for (int i = 0; i < threadCount; i++) {
        executor.execute(() -> {
            try {
                inventoryService.updateStock(1L, 1);
            } finally {
                latch.countDown();
            }
        });
    }

    latch.await(5, TimeUnit.SECONDS);
    Inventory finalState = repository.findById(1L).get();
    assertEquals(8, finalState.getStockCount()); // 元の10 - 2回の更新
}

予防のためのヒント

- **トランザクションを軽量に保つ:** `@Transactional`ブロック内で重い処理を実行したり、外部APIを呼び出したりしないでください。トランザクションが速いほど、衝突の可能性は低くなります。
- **アトミックな更新を使用する:** `stock = stock - 1`のような単純な計算には、ネイティブクエリを使用します:`UPDATE inventory SET stock_count = stock_count - :q WHERE id = :id`。これにより、データベース内で計算を行うことでバージョンチェックを完全に回避できます。
- **UIを確認する:** フロントエンドが重複したリクエストを送信しないようにしてください。最初のクリック後にボタンを「無効化」状態にするだけで、これらのエラーの50%を防ぐことができます。

Related Error Notes