Choosing a Strategy

Decision Table

SituationUse
Simple counter (views, stock decrement)atomic @Modifying UPDATE with a condition
Edit forms, low chance of conflictoptimistic @Version + 409 to the user
Background job, conflicts can be retriedoptimistic @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 tablePESSIMISTIC_WRITE + SKIP LOCKED
Across services or long user think-timeneither: 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.