Spring BootPessimistic Locking
Spring Boot

Pessimistic Locking

Pessimistic locking acquires a database lock when a row is read, preventing other transactions from modifying it until the lock is released. Spring Data JPA exposes pessimistic locks through @Lock on repository methods and LockModeType on EntityManager calls. This entry covers lock types, repository usage, timeout configuration, deadlock prevention, and when to choose pessimistic over optimistic locking.

Lock Types

JPA defines three pessimistic lock modes. PESSIMISTIC_WRITE acquires an exclusive write lock — no other transaction can read or write the row. PESSIMISTIC_READ acquires a shared read lock — other transactions can read but not write. PESSIMISTIC_FORCE_INCREMENT acquires a write lock and also increments the @Version field, combining pessimistic and optimistic locking. PESSIMISTIC_WRITE is the most commonly used mode.
Java
// ── PESSIMISTIC_WRITE — exclusive lock ────────────────────────────────
// SQL generated (PostgreSQL): SELECT ... FROM orders WHERE id = ? FOR UPDATE
// SQL generated (MySQL):      SELECT ... FROM orders WHERE id = ? FOR UPDATE
//
// Other transactions:
//   Read  → blocked until lock released
//   Write → blocked until lock released

// ── PESSIMISTIC_READ — shared lock ────────────────────────────────────
// SQL generated (PostgreSQL): SELECT ... FROM orders WHERE id = ? FOR SHARE
// SQL generated (MySQL):      SELECT ... FROM orders WHERE id = ?
//                             LOCK IN SHARE MODE
//
// Other transactions:
//   Read  → allowed (shared lock is compatible with other shared locks)
//   Write → blocked until lock released

// ── PESSIMISTIC_FORCE_INCREMENT ───────────────────────────────────────
// Like PESSIMISTIC_WRITE but also increments the @Version field.
// Use when you need to signal to optimistic-locking callers that a
// change occurred even if no fields on the entity were modified.

// ── Lock mode constants ───────────────────────────────────────────────
LockModeType.PESSIMISTIC_WRITE            // exclusive write lock
LockModeType.PESSIMISTIC_READ             // shared read lock
LockModeType.PESSIMISTIC_FORCE_INCREMENT  // write lock + version bump

// ── Database lock SQL by dialect ──────────────────────────────────────
// PostgreSQL:
//   PESSIMISTIC_WRITE           → FOR UPDATE
//   PESSIMISTIC_READ            → FOR SHARE
//   PESSIMISTIC_FORCE_INCREMENT → FOR UPDATE
//
// MySQL / MariaDB:
//   PESSIMISTIC_WRITE           → FOR UPDATE
//   PESSIMISTIC_READ            → LOCK IN SHARE MODE
//
// H2 (testing):
//   PESSIMISTIC_WRITE           → FOR UPDATE
//   PESSIMISTIC_READ            → FOR UPDATE (H2 has no shared locks)

@Lock on Repository Methods

Annotate a repository method with @Lock to apply a pessimistic lock to the query. The lock is held for the duration of the enclosing transaction. Always call locked repository methods from within a @Transactional method — without a transaction, JPA cannot acquire or release the lock.
Java
// ── Repository with pessimistic lock methods ─────────────────────────
public interface OrderRepository
        extends JpaRepository<Order, Long> {

    // Exclusive lock — block all other readers and writers
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForUpdate(@Param("id") Long id);

    // Shared lock — allow concurrent reads, block writes
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForShare(@Param("id") Long id);

    // Lock multiple rows
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT o FROM Order o WHERE o.status = :status")
    List<Order> findByStatusForUpdate(@Param("status") OrderStatus status);

    // Lock with skip locked — skip rows already locked by other tx
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(name = "jakarta.persistence.lock.timeout",
                   value = "-2")    // -2 = SKIP_LOCKED
    })
    @Query("SELECT o FROM Order o WHERE o.status = 'PENDING'")
    List<Order> findPendingSkipLocked(Pageable pageable);
}

// ── Service — must be @Transactional ─────────────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final OrderRepository orderRepo;

    @Transactional
    public OrderResponse ship(Long orderId) {
        // Lock the row immediately — no other tx can modify it
        Order order = orderRepo.findByIdForUpdate(orderId)
            .orElseThrow(() -> new OrderNotFoundException(orderId));

        if (order.getStatus() != OrderStatus.CONFIRMED) {
            throw new BusinessRuleException(
                "ORDER_NOT_CONFIRMED",
                "Only CONFIRMED orders can be shipped");
        }

        order.setStatus(OrderStatus.SHIPPED);
        order.setShippedAt(LocalDateTime.now());
        return OrderResponse.from(orderRepo.save(order));
        // Lock released when @Transactional method returns
    }
}

EntityManager Lock

Acquire a pessimistic lock through EntityManager.lock() or EntityManager.find() with a lock mode when you need finer control — for example, upgrading a lock mid-transaction, locking an entity that was already loaded, or passing lock hints directly.
Java
@Service
@RequiredArgsConstructor
public class InventoryService {

    private final EntityManager     em;
    private final InventoryRepository inventoryRepo;

    // ── Lock an already-loaded entity ─────────────────────────────────
    @Transactional
    public void adjustStock(Long productId, int delta) {
        InventoryItem item = inventoryRepo.findByProductId(productId)
            .orElseThrow();

        // Upgrade to write lock before mutating
        em.lock(item, LockModeType.PESSIMISTIC_WRITE);

        item.setStockLevel(item.getStockLevel() + delta);
        inventoryRepo.save(item);
    }

    // ── Lock at load time via EntityManager.find() ────────────────────
    @Transactional
    public void reserveStock(Long itemId, int quantity) {
        Map<String, Object> hints = Map.of(
            "jakarta.persistence.lock.timeout", 5000   // 5s timeout
        );

        InventoryItem item = em.find(
            InventoryItem.class, itemId,
            LockModeType.PESSIMISTIC_WRITE, hints);

        if (item == null) throw new ResourceNotFoundException(
            "InventoryItem not found: " + itemId);

        if (item.getStockLevel() < quantity) {
            throw new InsufficientStockException(
                itemId, quantity, item.getStockLevel());
        }

        item.setStockLevel(item.getStockLevel() - quantity);
    }

    // ── PESSIMISTIC_FORCE_INCREMENT ────────────────────────────────────
    // Increments @Version to signal change to optimistic-lock callers
    @Transactional
    public void touchWithVersionBump(Long itemId) {
        InventoryItem item = em.find(
            InventoryItem.class, itemId,
            LockModeType.PESSIMISTIC_FORCE_INCREMENT);
        // Version incremented even if no field changes
    }

    // ── Check current lock mode ────────────────────────────────────────
    @Transactional
    public LockModeType inspectLock(Long itemId) {
        InventoryItem item = em.find(InventoryItem.class, itemId);
        return em.getLockMode(item);
    }
}

Lock Timeouts

A pessimistic lock blocks competing transactions until it is released. Configure a lock timeout so waiting transactions fail fast rather than blocking indefinitely. Set it globally in application.yml or per query with @QueryHints. When the timeout expires, LockTimeoutException is thrown.
yaml
# ── Global lock timeout (milliseconds) ───────────────────────────────
# application.yml
spring:
  jpa:
    properties:
      jakarta:
        persistence:
          lock:
            timeout: 5000      # 5 seconds — applies to all lock acquisitions

# ── PostgreSQL-specific: set_lock_timeout ─────────────────────────────
# spring.jpa.properties.hibernate.jdbc.time_zone: UTC
# For PostgreSQL, lock_timeout is set per-session via:
# SET lock_timeout = '5s';
# Hibernate can execute this via a connection provider customizer.

// ── Per-query timeout via @QueryHints ─────────────────────────────────
public interface OrderRepository
        extends JpaRepository<Order, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(
            name  = "jakarta.persistence.lock.timeout",
            value = "3000"      // 3s timeout for this query only
        )
    })
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForUpdateWithTimeout(@Param("id") Long id);

    // ── NOWAIT — fail immediately if row is locked ─────────────────────
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(
        name  = "jakarta.persistence.lock.timeout",
        value = "0"             // 0 = NOWAIT
    ))
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForUpdateNoWait(@Param("id") Long id);

    // ── SKIP_LOCKED — skip rows locked by other transactions ──────────
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(
        name  = "jakarta.persistence.lock.timeout",
        value = "-2"            // -2 = SKIP_LOCKED
    ))
    @Query("SELECT o FROM Order o WHERE o.status = 'PENDING'" +
           " ORDER BY o.createdAt")
    List<Order> claimPendingOrders(Pageable pageable);
}

// ── Handle LockTimeoutException ───────────────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(LockTimeoutException.class)
    public ResponseEntity<ErrorResponse> handleLockTimeout(
            LockTimeoutException ex, HttpServletRequest req) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict",
                "Resource is currently locked by another operation. " +
                "Please retry.", req.getRequestURI()));
    }
}

Deadlock Prevention

Deadlocks occur when two transactions each hold a lock the other needs. Prevent them by acquiring locks in a consistent order across all transactions, keeping transactions short, and using SKIP_LOCKED for queue-style workloads. When a deadlock occurs, the database chooses one transaction as the victim and rolls it back.
Java
// ── Consistent lock ordering prevents deadlocks ───────────────────────
// WRONG — tx1 locks account 1 then 2; tx2 locks account 2 then 1:
@Transactional
public void transferWrong(Long fromId, Long toId, BigDecimal amount) {
    Account from = accountRepo.findByIdForUpdate(fromId).orElseThrow();
    Account to   = accountRepo.findByIdForUpdate(toId).orElseThrow();
    // If two threads call this simultaneously with reversed IDs → deadlock
    from.debit(amount);
    to.credit(amount);
}

// CORRECT — always lock in ascending ID order:
@Transactional
public void transfer(Long fromId, Long toId, BigDecimal amount) {
    Long   firstId  = Math.min(fromId, toId);
    Long   secondId = Math.max(fromId, toId);

    Account first  = accountRepo.findByIdForUpdate(firstId).orElseThrow();
    Account second = accountRepo.findByIdForUpdate(secondId).orElseThrow();

    Account from = first.getId().equals(fromId)  ? first  : second;
    Account to   = first.getId().equals(toId)    ? first  : second;

    from.debit(amount);
    to.credit(amount);
}

// ── SKIP_LOCKED — queue-style work distribution ───────────────────────
// Multiple worker threads claim tasks without blocking each other:
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderWorker {

    private final OrderRepository orderRepo;

    @Transactional
    public List<Order> claimBatch(int batchSize) {
        // Each worker picks rows not locked by other workers
        List<Order> claimed = orderRepo.claimPendingOrders(
            PageRequest.of(0, batchSize));
        claimed.forEach(o -> o.setStatus(OrderStatus.PROCESSING));
        return orderRepo.saveAll(claimed);
    }
}

// ── Handle deadlock victim ────────────────────────────────────────────
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(CannotAcquireLockException.class)
    public ResponseEntity<ErrorResponse> handleDeadlock(
            CannotAcquireLockException ex, HttpServletRequest req) {
        log.warn("Deadlock detected at {}: {}",
            req.getRequestURI(), ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .body(ErrorResponse.of(409, "Conflict",
                "A deadlock was detected. Please retry the operation.",
                req.getRequestURI()));
    }
}

Optimistic vs Pessimistic — When to Use Each

Optimistic locking is the right default for most REST APIs — it carries no database overhead at read time and only detects conflicts at commit. Pessimistic locking is appropriate when conflicts are frequent, when the cost of retry is high, or when correctness demands that no other transaction can modify the row between read and write.
Java
// ── Decision guide ────────────────────────────────────────────────────
//
// Use OPTIMISTIC (@Version) when:
//   - Read-heavy workload; writes are infrequent
//   - Conflict probability is low
//   - Client can reload and retry cheaply (REST UI)
//   - Long-running read → modify → write flows (e.g. form editing)
//   - Distributed systems where DB locks are impractical
//
// Use PESSIMISTIC (@Lock) when:
//   - Write-heavy workload; conflicts are frequent
//   - Cost of retry is high (e.g. payment, inventory deduction)
//   - Operation must be atomic: read-check-write in one transaction
//   - Queue / task distribution (SKIP_LOCKED)
//   - Short, fast transactions where lock hold time is minimal

// ── Side-by-side comparison ───────────────────────────────────────────

// Optimistic — no DB lock, detect conflict at commit:
@Transactional
public ProductResponse updateOptimistic(Long id,
        UpdateProductRequest req, Integer version) {
    Product product = productRepo.findById(id).orElseThrow();
    if (!product.getVersion().equals(version)) {
        throw new ConflictException("Stale version");
    }
    product.setName(req.name());
    product.setPrice(req.price());
    try {
        return ProductResponse.from(productRepo.saveAndFlush(product));
    } catch (OptimisticLockException ex) {
        throw new ConflictException("Updated concurrently — please retry");
    }
}

// Pessimistic — DB lock held for duration of transaction:
@Transactional
public ProductResponse updatePessimistic(Long id,
        UpdateProductRequest req) {
    Product product = productRepo.findByIdForUpdate(id).orElseThrow();
    // No other transaction can modify product until this method returns
    product.setName(req.name());
    product.setPrice(req.price());
    return ProductResponse.from(productRepo.save(product));
}

// ── Combining both ────────────────────────────────────────────────────
// For payment processing: pessimistic lock to prevent double-charge,
// @Version to detect any schema-level concurrent modification:
@Entity
public class PaymentRecord {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;          // optimistic — catches unexpected changes

    @Enumerated(EnumType.STRING)
    @Column(nullable = false, length = 20)
    private PaymentStatus status;  // pessimistic lock guards status transitions
}