Careeroza
Choosing a Strategy
Decision Table
| Situation | Use |
|---|---|
| Simple counter (views, stock decrement) | atomic @Modifying UPDATE with a condition |
| Edit forms, low chance of conflict | optimistic @Version + 409 to the user |
| Background job, conflicts can be retried | optimistic @Version + automatic retry |
| High contention on few rows (flash sale, seat booking) | pessimistic PESSIMISTIC_WRITE with a timeout, or an atomic update |
| Worker queue over a table | PESSIMISTIC_WRITE + SKIP LOCKED |
| Across services or long user think-time | neither: use a reservation row with expiry, or a distributed lock |
The Atomic Update Option
public interface ProductRepository extends JpaRepository<Product, Long> {
@Modifying(clearAutomatically = true) // stale managed copies are cleared
@Query("""
update Product p
set p.stock = p.stock - :qty, p.version = p.version + 1
where p.id = :id and p.stock >= :qty
""")
int decrementStock(@Param("id") Long id, @Param("qty") int qty);
}
@Transactional
public void reserve(Long id, int qty) {
if (products.decrementStock(id, qty) == 0) {
throw new OutOfStockException(id); // 0 rows = not enough stock
}
}Tip: Bulk JPQL updates bypass the persistence context and do not bump @Version unless you do it yourself (as above) or write update versioned Product ... in HQL.
Automatic Retry
// spring-retry (add org.springframework.retry:spring-retry + @EnableRetry)
@Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2))
@Transactional
public void addLoyaltyPoints(Long userId, int points) {
User u = users.findById(userId).orElseThrow();
u.setPoints(u.getPoints() + points);
}Note: The retry must wrap the whole transaction, not code inside it. A failed transaction is rolled back and its persistence context is unusable; retrying starts a fresh one that re-reads the row.