The full picture
@Entity class Product {
@Id Long id;
@Version long version; // JPA increments; UPDATE … WHERE version = :v
int stock;
}
// Optimistic conflict surfaces as a 409 to the client:
@Transactional
public void rename(long id, String name, long expectedVersion) {
var p = repo.findById(id).orElseThrow();
if (p.getVersion() != expectedVersion) // stale form data
throw new ConflictException(); // client refetches & retries
p.setName(name);
}
// Pessimistic for the hot path:
public interface ProductRepo extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Optional<Product> findWithLockById(Long id); // SELECT … FOR UPDATE
}- Optimistic retry belongs in application middleware (
@RetryableonOptimisticLockingFailureException, capped attempts + jitter) — but only when the operation is idempotent to recompute. - Pessimistic lock scope = transaction scope: acquiring FOR UPDATE then calling a slow remote API serializes all competitors behind your latency. Locks + short transactions, always.
- Lock ordering discipline applies in SQL too: two services locking rows in opposite orders = DB deadlock; the DB kills one (translated to
CannotAcquireLockException/DeadlockLoserDataAccessException— retryable). - For user-facing edits,
@Versiondoubles as the ETag/If-Match value — optimistic concurrency end-to-end through HTTP. - Cross-service, neither works: you can't hold DB locks across service boundaries — that's reservation patterns / sagas territory.