Spring Boot

Pessimistic Locking

Pessimistic locking acquires a database lock when a row is read, so other transactions cannot modify it until the lock is released. This entry covers Spring Data JPA's @Lock and LockModeType, what a lock does and does not block, lock timeouts that actually work on PostgreSQL and MySQL, NOWAIT and SKIP LOCKED, deadlock prevention with retries, atomic updates as a simpler alternative, optimistic versus pessimistic locking, and how to test concurrency.

Lock Types

JPA defines three pessimistic lock modes. - PESSIMISTIC_WRITE takes an exclusive lock. Other transactions cannot update or delete the row, or lock it themselves, until you finish. It is by far the most common mode. - PESSIMISTIC_READ takes a shared lock. Other transactions can also take a shared lock, but nobody can modify the row. Not every database has shared row locks: Oracle and H2 fall back to an exclusive lock. - PESSIMISTIC_FORCE_INCREMENT takes a write lock and also increments the @Version field. Use it to signal a change to optimistic-locking callers even if no column changed. The entity must have a @Version field, otherwise Hibernate throws an exception. Hibernate translates each mode into the locking clause of your database. The SQL differs, so check the generated statement when you change databases.
Java
// ── Lock mode constants ───────────────────────────────────────────────
LockModeType.PESSIMISTIC_WRITE            // exclusive lock
LockModeType.PESSIMISTIC_READ             // shared lock
LockModeType.PESSIMISTIC_FORCE_INCREMENT  // exclusive lock + version bump

// ── SQL generated by Hibernate 6, by database ─────────────────────────
// PostgreSQL:
//   PESSIMISTIC_WRITE           → ... FOR UPDATE
//   PESSIMISTIC_READ            → ... FOR SHARE
//   PESSIMISTIC_FORCE_INCREMENT → ... FOR UPDATE (plus an UPDATE of the version column)
//
// MySQL 8 / MariaDB:
//   PESSIMISTIC_WRITE           → ... FOR UPDATE
//   PESSIMISTIC_READ            → ... FOR SHARE   (older MySQL: LOCK IN SHARE MODE)
//
// Oracle:
//   both modes                  → ... FOR UPDATE  (Oracle has no shared row locks)
//
// H2 (tests):
//   both modes                  → ... FOR UPDATE
//
// SQL Server uses table hints instead (UPDLOCK / HOLDLOCK), not a FOR UPDATE clause.

What a Lock Blocks, and What It Does Not

A pessimistic lock is a row lock held until the transaction ends. Understanding exactly what it blocks avoids false assumptions. It blocks other transactions that try to update or delete the row, and other transactions that try to lock it (SELECT ... FOR UPDATE or FOR SHARE). It does not, in most databases, block ordinary reads. PostgreSQL and MySQL InnoDB use MVCC, so a plain SELECT sees the last committed version without waiting. If a reader must wait for your change, it has to take a lock too. Do not describe PESSIMISTIC_WRITE as "nobody can read the row". It protects only rows that exist. Locking a query that matches nothing locks nothing, so two transactions can both see "no row" and both insert. Prevent that with a unique constraint, not a lock. It only locks what the query touches, and how the database finds those rows matters. Lock by primary key whenever you can. On MySQL InnoDB, a locking query on a non-indexed or range condition can lock many rows or gaps and block unrelated inserts. On any database, an outer join or fetch join combined with FOR UPDATE can fail or lock more than you intended, so lock the root entity alone.

@Lock on Repository Methods

Annotate a repository method with @Lock to apply a pessimistic lock to its query. The lock is held until the enclosing transaction ends, not until the repository method returns. A locked query called without a transaction fails with a TransactionRequiredException, because there is nothing to hold the lock. Two transaction details trip people up. First, class-level @Transactional(readOnly = true) is a common default, and PostgreSQL rejects SELECT ... FOR UPDATE inside a read-only transaction. Any method that locks must use a normal, read-write @Transactional. Second, keep the transaction short: every statement and any slow call (an HTTP request, a message send) made while the lock is held extends the time other transactions wait. Always lock a bounded set of rows. A query like findByStatusForUpdate with no limit locks every matching row for the whole transaction, so the example takes a Pageable and orders by id, which also keeps the lock order consistent between transactions (see the deadlock section).
Java
// ── Repository with pessimistic lock methods ─────────────────────────
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Exclusive lock on one row
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForUpdate(@Param("id") Long id);

    // Shared lock — others may also share-lock, nobody may modify
    @Lock(LockModeType.PESSIMISTIC_READ)
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForShare(@Param("id") Long id);

    // Lock a bounded set of rows, always in the same order
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT o FROM Order o WHERE o.status = :status ORDER BY o.id")
    List<Order> findByStatusForUpdate(@Param("status") OrderStatus status,
                                      Pageable pageable);
}

// ── Service — a normal read-write transaction ────────────────────────
@Service
@RequiredArgsConstructor
@Slf4j
public class OrderService {

    private final OrderRepository orderRepo;

    @Transactional   // not readOnly = true: FOR UPDATE is rejected there
    public OrderResponse ship(Long orderId) {
        // The row is locked from this point until the method returns and commits
        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(Instant.now());
        return OrderResponse.from(order);   // dirty checking writes the change at commit
    }
}

EntityManager Lock

Use the EntityManager when you need finer control: locking at load time with find(), passing lock hints, or locking an entity that is already loaded. Locking an already-loaded entity has a trap. em.lock() locks the row in the database but does not reload the entity, so the object in memory may still hold stale values read before the lock was taken. Computing a new stock level from that stale value reintroduces the lost update the lock was meant to prevent. Either lock at load time (em.find with a lock mode, or a repository method with @Lock), or call em.refresh(entity, lockMode), which locks the row and reloads its state. If your lock code lives in a plain @Service and calls the EntityManager directly, exceptions are not translated to Spring's DataAccessException hierarchy (only @Repository beans and Spring Data repositories are), so you will see raw JPA exceptions such as LockTimeoutException. The exception handler in the timeout section covers both forms.
Java
@Service
@RequiredArgsConstructor
public class InventoryService {

    private final EntityManager       em;
    private final InventoryRepository inventoryRepo;

    // ── Lock at load time — the safest and simplest form ──────────────
    @Transactional
    public void reserveStock(Long itemId, int quantity) {
        Map<String, Object> hints = Map.of(
            "jakarta.persistence.lock.timeout", 5000   // honoured only if the dialect supports it
        );

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

        if (item == null) {
            throw new ResourceNotFoundException("InventoryItem", itemId);
        }
        if (item.getStockLevel() < quantity) {
            throw new InsufficientStockException(itemId, quantity);
        }

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

    // ── Lock an entity that was already loaded: refresh, don't just lock
    @Transactional
    public void adjustStock(Long productId, int delta) {
        InventoryItem item = inventoryRepo.findByProductId(productId).orElseThrow();

        // em.lock(item, ...) alone would keep the stale in-memory stockLevel.
        // refresh() takes the lock AND reloads the current row.
        em.refresh(item, LockModeType.PESSIMISTIC_WRITE);

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

    // ── PESSIMISTIC_FORCE_INCREMENT (entity needs a @Version field) ────
    @Transactional
    public void touchWithVersionBump(Long itemId) {
        em.find(InventoryItem.class, itemId, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
        // The version is incremented even though no field changed
    }
}

Atomic Updates: Often Better Than a Lock

For counters and stock levels, you often do not need an explicit lock at all. A single conditional UPDATE reads, checks and writes in one atomic statement, and the database serializes concurrent updates to the row for you. There is no read-then-write gap, no lock held across your Java code, and no deadlock from lock ordering, because the row lock lasts only as long as the statement. The number of affected rows tells you whether the condition held. This is usually the best choice when the rule is a simple check on one row. Reach for pessimistic locking when the logic between the read and the write is too complex to express in one statement, or touches several rows or tables. Two cautions: a JPQL bulk update bypasses the persistence context, so entities already loaded in the same transaction are stale afterwards (clearAutomatically handles that), and it does not increment a @Version column unless you do it explicitly in the statement.
Java
public interface InventoryRepository extends JpaRepository<InventoryItem, Long> {

    // Decrement only if enough stock exists — one atomic statement
    @Modifying(flushAutomatically = true, clearAutomatically = true)
    @Query("""
        UPDATE InventoryItem i
           SET i.stockLevel = i.stockLevel - :qty
         WHERE i.id = :id
           AND i.stockLevel >= :qty
        """)
    int decrementIfAvailable(@Param("id") Long id, @Param("qty") int qty);
}

@Transactional
public void reserveStockAtomically(Long itemId, int quantity) {
    int updated = inventoryRepo.decrementIfAvailable(itemId, quantity);
    if (updated == 0) {
        // either the item does not exist or there is not enough stock
        throw new InsufficientStockException(itemId, quantity);
    }
}

Lock Timeouts: What Actually Works

A lock makes competing transactions wait, so you want a limit on how long. JPA's standard hint, jakarta.persistence.lock.timeout, is passed to Hibernate, but Hibernate can only apply it when the database syntax supports a lock wait. The result depends on the database: - 0 (NOWAIT) and -2 (SKIP LOCKED) are supported on PostgreSQL, MySQL 8 and Oracle. - A positive timeout in milliseconds is applied by Oracle, which supports FOR UPDATE WAIT n. On PostgreSQL and MySQL, Hibernate emits a plain FOR UPDATE and the timeout is ignored, so a blocked transaction can wait indefinitely or for the database's own default. So on PostgreSQL and MySQL, set the wait limit in the database session instead. PostgreSQL uses lock_timeout, MySQL uses innodb_lock_wait_timeout (the default is 50 seconds), and SQL Server uses SET LOCK_TIMEOUT. Set it for every connection in the pool, or per transaction when only some operations need a short limit. When the wait expires, the statement fails and the transaction should be treated as failed.
yaml
# ── application.yml — choose ONE approach ─────────────────────────────
spring:
  datasource:
    # PostgreSQL, option 1: via the JDBC URL (applies to every connection)
    url: jdbc:postgresql://localhost:5432/app?options=-c%20lock_timeout=5000
    hikari:
      # PostgreSQL, option 2: run a statement on every new connection
      # connection-init-sql: SET lock_timeout = '5s'
      # MySQL equivalent:
      # connection-init-sql: SET SESSION innodb_lock_wait_timeout = 5

  jpa:
    properties:
      jakarta:
        persistence:
          lock:
            timeout: 5000   # effective on Oracle; ignored for positive values on PostgreSQL/MySQL

Per-Transaction Timeouts, NOWAIT and SKIP LOCKED

For a limit on one operation, set it inside the transaction. On PostgreSQL, SET LOCAL lock_timeout lasts until the transaction ends, so it cannot leak to other users of the pooled connection. NOWAIT fails immediately if the row is locked, which suits "try, and tell the user it is busy". SKIP LOCKED ignores locked rows, which suits work queues. Mind the exception type. Through a Spring Data repository, lock failures are translated: a timeout becomes CannotAcquireLockException and a deadlock becomes a DeadlockLoserDataAccessException or CannotAcquireLockException, all subclasses of PessimisticLockingFailureException. A raw EntityManager used in a plain @Service throws the JPA exceptions instead (LockTimeoutException or PessimisticLockException). One handler can cover all of them. The earlier habit of catching only LockTimeoutException misses the translated form.
Java
public interface OrderRepository extends JpaRepository<Order, Long> {

    // Per-query hint: honoured by Oracle for a positive value; ignored on PostgreSQL/MySQL
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
    @Query("SELECT o FROM Order o WHERE o.id = :id")
    Optional<Order> findByIdForUpdateWithTimeout(@Param("id") Long id);

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

// ── PostgreSQL: a wait limit for one transaction only ─────────────────
@Transactional
public void importantUpdate(Long id) {
    em.createNativeQuery("SET LOCAL lock_timeout = '3s'").executeUpdate();
    Order order = orderRepo.findByIdForUpdate(id).orElseThrow();
    // ...
}

// ── One handler for every kind of lock failure ────────────────────────
@RestControllerAdvice
@Slf4j
public class LockExceptionHandler {

    @ExceptionHandler({
        PessimisticLockingFailureException.class,   // Spring-translated: timeout, deadlock, serialization failure
        LockTimeoutException.class,                 // raw JPA, from EntityManager in a non-@Repository bean
        PessimisticLockException.class              // raw JPA
    })
    public ResponseEntity<ErrorResponse> handleLockFailure(Exception ex,
                                                           HttpServletRequest req) {
        log.warn("Lock failure at {}: {}", req.getRequestURI(), ex.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT)
            .header(HttpHeaders.RETRY_AFTER, "1")
            .body(ErrorResponse.of(409, "Conflict",
                "The resource is busy. Please retry.", req.getRequestURI()));
    }
}

Deadlock Prevention and Retries

A deadlock happens when two transactions each hold a lock the other needs. The database detects it and rolls one transaction back as the victim. You prevent most deadlocks by always acquiring locks in the same order, keeping transactions short, and locking as few rows as possible. Deadlocks can still occur, so retry. A rolled-back transaction cannot continue: the retry must start a new transaction, which means the retry loop must sit outside the @Transactional method, in a different bean so the call goes through the proxy. Retry only operations that are safe to repeat, limit the attempts, and add a small randomized delay so the competing transactions do not collide again in lockstep.
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();
    // Two threads calling this with reversed ids can deadlock
    from.debit(amount);
    to.credit(amount);
}

// CORRECT — always lock in ascending id order:
@Service
@RequiredArgsConstructor
public class TransferService {

    private final AccountRepository accountRepo;

    @Transactional
    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        if (fromId.equals(toId)) {
            throw new IllegalArgumentException("Cannot transfer to the same account");
        }

        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);
    }
}

// ── Retry OUTSIDE the transaction, in a separate bean ─────────────────
@Service
@RequiredArgsConstructor
public class TransferFacade {   // no @Transactional here

    private static final int MAX_ATTEMPTS = 3;

    private final TransferService transferService;

    public void transfer(Long fromId, Long toId, BigDecimal amount) {
        for (int attempt = 1; ; attempt++) {
            try {
                transferService.transfer(fromId, toId, amount);   // each attempt is a new transaction
                return;
            } catch (PessimisticLockingFailureException ex) {
                if (attempt >= MAX_ATTEMPTS) {
                    throw ex;
                }
                sleepQuietly(50L * attempt + ThreadLocalRandom.current().nextLong(50));
            }
        }
    }

    private void sleepQuietly(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException("Interrupted while retrying", e);
        }
    }
}

Queue Processing with SKIP LOCKED

SKIP LOCKED lets several workers pull rows from the same table without waiting on each other: each worker locks the rows it claims and skips the ones already claimed. It is supported on PostgreSQL 9.5+, MySQL 8+ and Oracle, but not on H2, so test it against the real database. Keep the claim transaction tiny. Select and lock the batch, mark it PROCESSING, and commit; do the slow work afterwards, outside the transaction, so locks are not held while you process. Always include an ORDER BY so workers take rows in a predictable order. And plan for worker crashes: a row stuck in PROCESSING needs a timestamp and a recovery job that returns it to PENDING after a timeout.
Java
public interface OrderRepository extends JpaRepository<Order, Long> {

    @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 = :status ORDER BY o.createdAt, o.id")
    List<Order> claimByStatus(@Param("status") OrderStatus status, Pageable pageable);
}

@Service
@RequiredArgsConstructor
@Slf4j
public class OrderWorker {

    private final OrderRepository orderRepo;

    // Short transaction: claim, mark, commit
    @Transactional
    public List<Order> claimBatch(int batchSize) {
        List<Order> claimed = orderRepo.claimByStatus(
            OrderStatus.PENDING, PageRequest.of(0, batchSize));
        Instant now = Instant.now();
        claimed.forEach(o -> {
            o.setStatus(OrderStatus.PROCESSING);
            o.setClaimedAt(now);            // lets a recovery job find abandoned rows
        });
        return claimed;                     // changes are flushed at commit
    }
}

// Caller (not transactional): process after the claim has committed
// List<Order> batch = worker.claimBatch(20);
// batch.forEach(processor::process);

Optimistic vs Pessimistic — When to Use Each

Optimistic locking (@Version) is the right default for most REST APIs. It holds no database lock while a user is thinking, and it detects a conflict only at commit. Pessimistic locking is appropriate when conflicts are frequent, when a retry is expensive or not possible, or when a read-check-write sequence must be atomic. For a simple counter or stock check, an atomic conditional UPDATE (shown earlier) often beats both. Stricter isolation levels such as SERIALIZABLE are another option, but they push the retry burden onto your code in a different way. With optimistic locking through Spring Data, a conflict surfaces as ObjectOptimisticLockingFailureException, not as the raw jakarta.persistence.OptimisticLockException, because repository exceptions are translated. Catch the Spring type. Optimistic failures should usually be turned into a 409 so the client can reload and retry. A combination is also valid: take the pessimistic lock to serialize the critical operation, and keep @Version as a safety net against writers that never take the lock, such as batch jobs, another service, or manual SQL.
Java
// ── Decision guide ────────────────────────────────────────────────────
//
// Use OPTIMISTIC (@Version) when:
//   - Reads dominate; conflicts are rare
//   - A client can reload and retry cheaply (editing a form)
//   - The read → modify → write flow spans several HTTP requests
//
// Use an ATOMIC UPDATE when:
//   - The rule is a single check on one row (stock >= qty, balance >= amount)
//
// Use PESSIMISTIC (@Lock) when:
//   - Conflicts are frequent on the same rows ("hot rows")
//   - Retrying is costly or impossible (payment, external side effects)
//   - The logic between read and write is too complex for one statement
//   - You distribute queue work (SKIP LOCKED)
//   - Transactions are short, so lock hold time stays small

// ── Optimistic: detect the conflict, do not block ─────────────────────
@Transactional
public ProductResponse updateOptimistic(Long id, UpdateProductRequest req, Long version) {
    Product product = productRepo.findById(id).orElseThrow();

    // Catches a stale edit made across HTTP requests
    if (!product.getVersion().equals(version)) {
        throw new ConflictException("The product was changed by someone else — reload and retry");
    }

    product.setName(req.name());
    product.setPrice(req.price());
    try {
        // @Version also makes the UPDATE fail if another transaction committed meanwhile
        return ProductResponse.from(productRepo.saveAndFlush(product));
    } catch (ObjectOptimisticLockingFailureException ex) {
        throw new ConflictException("Updated concurrently — please retry");
    }
}

// ── Pessimistic: block competing writers for the duration of the tx ───
@Transactional
public ProductResponse updatePessimistic(Long id, UpdateProductRequest req) {
    Product product = productRepo.findByIdForUpdate(id).orElseThrow();
    product.setName(req.name());
    product.setPrice(req.price());
    return ProductResponse.from(product);
}

// ── Both: lock to serialize, @Version as a safety net ─────────────────
@Entity
public class PaymentRecord {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Version
    private Long version;          // catches writers that did not take the lock

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

Testing Lock Behaviour

Locking is a concurrency property, so test it with real concurrent transactions against the same kind of database you run in production. H2 locks differently (no SKIP LOCKED in older versions, a different shared-lock behaviour), so use Testcontainers with PostgreSQL or your production database. The test below starts many threads at the same instant and checks the invariant: with 10 units in stock and 20 buyers, exactly 10 succeed and the stock ends at zero. Do not mark the test @Transactional, since each worker thread needs its own committed transaction. Keep the thread count modest, since the connection pool bounds how many transactions can run at once.
Java
@SpringBootTest
@Testcontainers
class InventoryConcurrencyTest {

    @Container
    @ServiceConnection
    static PostgreSQLContainer<?> postgres = new PostgreSQLContainer<>("postgres:16-alpine");

    @Autowired private InventoryService inventoryService;
    @Autowired private InventoryRepository inventoryRepo;

    @Test
    void concurrentReservations_neverOversell() throws Exception {
        InventoryItem item = inventoryRepo.save(newItemWithStock(10));   // 10 units in stock

        int buyers = 20;
        ExecutorService pool = Executors.newFixedThreadPool(buyers);
        CountDownLatch startSignal = new CountDownLatch(1);

        List<Future<Boolean>> results = new ArrayList<>();
        for (int i = 0; i < buyers; i++) {
            results.add(pool.submit(() -> {
                startSignal.await();                     // all threads start together
                try {
                    inventoryService.reserveStock(item.getId(), 1);
                    return true;
                } catch (InsufficientStockException e) {
                    return false;
                }
            }));
        }

        startSignal.countDown();

        long succeeded = 0;
        for (Future<Boolean> result : results) {
            if (result.get(30, TimeUnit.SECONDS)) {
                succeeded++;
            }
        }
        pool.shutdown();

        assertThat(succeeded).isEqualTo(10);
        assertThat(inventoryRepo.findById(item.getId()).orElseThrow().getStockLevel()).isZero();
    }
}

Common Mistakes

1. Believing PESSIMISTIC_WRITE blocks plain reads. In PostgreSQL and MySQL it does not. It blocks writers and other locking reads. 2. Calling a locked query without @Transactional, or from a readOnly = true transaction. The first throws TransactionRequiredException, and PostgreSQL rejects FOR UPDATE in the second. 3. Using em.lock() on an already-loaded entity and then computing from its fields. The in-memory values may be stale. Lock at load time or use em.refresh with a lock mode. 4. Expecting a positive lock.timeout hint to work on PostgreSQL or MySQL. It is ignored there. Use lock_timeout or innodb_lock_wait_timeout. 5. Catching only LockTimeoutException. Repository calls throw Spring's PessimisticLockingFailureException family instead. 6. Locking an unbounded result set. Every matching row stays locked until the transaction ends. 7. Locking rows in inconsistent order across transactions, which is the usual cause of deadlocks. Always ORDER BY id and lock in ascending order. 8. Retrying inside the same transaction. A deadlock victim's transaction is gone; retry from outside with a new transaction. 9. Holding locks while calling slow external services. Do the call before or after the transaction. 10. Locking where an atomic conditional UPDATE would do. 11. Catching jakarta.persistence.OptimisticLockException around a Spring Data call. The exception arrives translated as ObjectOptimisticLockingFailureException. 12. Testing locking only on H2. Use the production database in a Testcontainers test.

FAQ

What is the difference between PESSIMISTIC_READ and PESSIMISTIC_WRITE? READ takes a shared lock: other transactions can share-lock the row but cannot modify it. WRITE takes an exclusive lock: nobody else can modify or lock it. On databases without shared row locks, such as Oracle, READ behaves like WRITE. Does @Lock block other readers? Not plain reads on PostgreSQL or MySQL InnoDB, which read the last committed version. It blocks writers and other locking reads. When is the lock released? When the transaction commits or rolls back, not when the repository method returns. How do I set a lock timeout in Spring Boot with PostgreSQL? Hibernate ignores positive timeout hints on PostgreSQL. Set lock_timeout on the connection (JDBC URL option or Hikari connection-init-sql) or per transaction with SET LOCAL lock_timeout. How do I avoid deadlocks? Lock rows in a consistent order, keep transactions short, lock only what you need, and retry the whole transaction when a deadlock victim exception occurs. What does SKIP LOCKED do? It makes a locking query ignore rows already locked by other transactions, so parallel workers can each claim different rows without waiting. Should I use pessimistic or optimistic locking? Start with optimistic locking. Use an atomic UPDATE for simple counters, and pessimistic locking for frequent conflicts or critical read-check-write sequences where a retry is costly.