Backend Development 13 min read

Mastering Optimistic Locking in Spring Boot 3: From Pitfalls to Proven Solutions

This article examines the challenges of using JPA optimistic locking for inventory deduction under high concurrency, demonstrates why naive retry logic can cause deadlocks and stale data, and walks through five progressively refined implementations that resolve transaction, caching, and isolation issues.

Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Spring Full-Stack Practical Cases
Mastering Optimistic Locking in Spring Boot 3: From Pitfalls to Proven Solutions

1. Introduction

In high‑concurrency scenarios, inventory deduction is a classic challenge: multiple threads competing for the same product can cause overselling (negative stock) or deadlocks. Developers often use JPA optimistic locking (version field) with a retry strategy, but improper transaction management and retry logic can introduce hidden deadlock problems.

2. Practical Case

Below is a typical Spring Data JPA snippet that throws ObjectOptimisticLockingFailureException when concurrent updates conflict:

<code>@Entity
@Table(name = "c_product")
@DynamicUpdate
public class Product {
    @Id
    private Long id;
    private String name;
    private Integer stock;
    @Version
    private Integer version;
    // getters, setters
}</code>
<code>INSERT INTO `mall`.`c_product` (id, name, stock, version) VALUES (1, 'Spring Boot3实战案例100例', 2, 1);</code>
<code>public interface ProductRepository extends JpaRepository<Product, Long> {}
</code>
<code>@Transactional
public void deductStock(Long productId, int quantity) {
    productRepository.findById(productId).ifPresentOrElse(p -> {
        if (p.getStock() >= quantity) {
            p.setStock(p.getStock() - quantity);
            productRepository.save(p);
        } else {
            throw new RuntimeException("库存不足");
        }
    }, () -> { throw new RuntimeException("商品不存在"); });
}
</code>

The accompanying unit test simulates ten concurrent threads attempting to deduct stock:

<code>@Test
public void testDeductStock() throws Exception {
    final int MAX = 10;
    CountDownLatch cdl = new CountDownLatch(MAX);
    CyclicBarrier cb = new CyclicBarrier(MAX);
    for (int i = 0; i < MAX; i++) {
        new Thread(() -> {
            try {
                cb.await();
                productService.deductStock(1L, 1);
            } catch (Exception e) {
                // ignore
            } finally {
                cdl.countDown();
            }
        }, "T" + i).start();
    }
    cdl.await();
    System.err.println("执行完成...");
}
</code>

The test shows that only one thread succeeds while the others encounter optimistic‑lock exceptions, which were initially swallowed.

2.1 Optimistic‑Lock Retry – Version 1

First attempt: replace save with saveAndFlush to force immediate SQL execution, and catch the optimistic‑lock exception to recursively retry.

<code>@Transactional
public void deductStock(Long productId, int quantity) {
    try {
        // original logic
        productRepository.saveAndFlush(p);
    } catch (ObjectOptimisticLockingFailureException e) {
        deductStock(productId, quantity); // retry
    }
}
</code>

Result: the test reports “库存不足” even though the database still shows remaining stock, because the same JPA EntityManager caches the entity and the version field is decremented in memory before the failed update.

2.2 Optimistic‑Lock Retry – Version 2

To force a fresh read on retry, the EntityManager is injected and entityManager.clear() is called inside the catch block.

<code>catch (ObjectOptimisticLockingFailureException e) {
    entityManager.clear();
    deductStock(productId, quantity);
}
</code>

Result: all threads enter an infinite loop because the transaction’s isolation level (REPEATABLE_READ) provides a snapshot view; each retry still reads the stale version.

2.3 Optimistic‑Lock Retry – Version 3

The transaction isolation is changed to READ_COMMITTED, allowing each retry to see the latest data.

<code>@Transactional(isolation = Isolation.READ_COMMITTED)
public void deductStock(...){ ... }
</code>

Result: the program finishes, but an additional exception appears – “Transaction marked as rollback-only, therefore silently rolled back” – because the original transaction was already marked for rollback when the optimistic‑lock exception occurred.

2.4 Optimistic‑Lock Retry – Version 4

Solution: each retry runs in a new transaction using propagation = Propagation.REQUIRES_NEW and the service calls itself via a self‑injected proxy.

<code>@Transactional(propagation = Propagation.REQUIRES_NEW)
public void deductStock(...){ ... }
</code>

Result: deadlock occurs because the original transaction still holds a row lock while the new transaction attempts another update.

2.5 Optimistic‑Lock Retry – Version 5

Final approach: remove the transactional annotation from the method entirely, and perform a direct recursive call after catching the optimistic‑lock exception. Each call runs in its own transaction (default Spring behavior for repository methods).

<code>public void deductStock(Long productId, int quantity) {
    productRepository.findById(productId).ifPresentOrElse(p -> {
        if (p.getStock() >= quantity) {
            p.setStock(p.getStock() - quantity);
            try {
                productRepository.saveAndFlush(p);
            } catch (ObjectOptimisticLockingFailureException e) {
                System.err.println(Thread.currentThread().getName() + " - 乐观锁异常, " + e.getMessage());
                deductStock(productId, quantity); // retry in new transaction
            }
        } else {
            throw new RuntimeException("库存不足");
        }
    }, () -> { throw new RuntimeException("商品不存在"); });
}
</code>

Result: all threads complete successfully, the database reflects the correct stock and version, and no deadlocks occur. Both save and saveAndFlush work because each retry starts a fresh transaction.

Conclusion

The article demonstrates that naive optimistic‑lock retry logic can cause stale reads, infinite loops, or deadlocks due to transaction isolation and JPA’s first‑level cache. Proper handling requires either clearing the persistence context and using READ_COMMITTED isolation, or, more cleanly, removing the surrounding transaction and letting each retry execute in a new transaction, as shown in Version 5.

TransactionconcurrencySpring BootretryJPAOptimistic Locking
Spring Full-Stack Practical Cases
Written by

Spring Full-Stack Practical Cases

Full-stack Java development with Vue 2/3 front-end suite; hands-on examples and source code analysis for Spring, Spring Boot 2/3, and Spring Cloud.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.