Transactions, Caching & Performance

Pessimistic Locking

18 min Lesson 6 of 13

Pessimistic Locking

In the previous lesson you learned how optimistic locking lets multiple transactions read the same row freely and detects conflicts only at commit time using a @Version column. That model is excellent when conflicts are rare. But what happens when conflicts are expected — when business logic demands that you own a row exclusively for the entire duration of a transaction before you even start doing work with it? That is the domain of pessimistic locking.

With pessimistic locking you tell the database: "I am going to modify this row — lock it now, before I do anything else." Other transactions that also request a lock on the same row will block (wait) until you commit or roll back. There are no version columns, no retry loops, and no OptimisticLockException at the end — you either get the lock and proceed, or you wait.

When Pessimistic Locking Is the Right Choice

Consider an airline seat reservation system. Two agents simultaneously load the last available seat. With optimistic locking one of them will fail at commit time and need to retry — but by then the seat is gone and you must show an error. For a seat reservation that is acceptable. For a financial transaction where the failure message would read "we tried to debit your account twice," it is not. Pessimistic locking suits scenarios like these:

  • Deducting inventory or balance where concurrent over-reads are dangerous.
  • Workflows where a record must be "checked out" exclusively (job queues, approval pipelines).
  • Short, contention-heavy transactions where an immediate lock is cheaper than a retry loop.
  • Any situation where the cost of a rollback and user-facing retry is unacceptable.

JPA Lock Modes for Pessimistic Locking

JPA defines three pessimistic lock modes in jakarta.persistence.LockModeType:

  • PESSIMISTIC_READ — acquires a shared lock. Other transactions can also read (and acquire their own shared locks) but nobody can write until all shared locks are released. Use this when you need a consistent read that must not change under you but you are not necessarily going to write.
  • PESSIMISTIC_WRITE — acquires an exclusive lock (SELECT ... FOR UPDATE in most databases). No other transaction may read with a lock or write to the row until yours commits. This is by far the most common choice for any "read then modify" pattern.
  • PESSIMISTIC_FORCE_INCREMENT — exclusive lock plus it increments the entity's @Version field even if no field changes. Useful for coordinating aggregate roots in mixed optimistic/pessimistic scenarios.

Acquiring a Pessimistic Lock with EntityManager

You can request a lock when you load an entity, or you can lock an entity you have already loaded.

import jakarta.persistence.EntityManager; import jakarta.persistence.LockModeType; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class InventoryService { private final EntityManager em; public InventoryService(EntityManager em) { this.em = em; } @Transactional public void reserveStock(Long productId, int qty) { // Lock the row immediately on load — no one else can write to it Product product = em.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE); if (product.getStock() < qty) { throw new InsufficientStockException("Not enough stock for product " + productId); } product.setStock(product.getStock() - qty); // em.merge() not needed — entity is managed; Hibernate flushes at commit } }

Hibernate translates PESSIMISTIC_WRITE to a database-level exclusive lock. On PostgreSQL this becomes:

SELECT id, stock, version FROM products WHERE id = ? FOR UPDATE

Any other transaction trying to lock the same row will be suspended by the database until the first transaction commits or rolls back.

Using Pessimistic Locks in Spring Data JPA Repositories

You can annotate repository methods with @Lock to apply a lock mode declaratively. Combine it with @QueryHints to set a timeout so the application does not hang indefinitely when contention is high.

import jakarta.persistence.LockModeType; import jakarta.persistence.QueryHint; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.QueryHints; import java.util.Optional; public interface AccountRepository extends JpaRepository<Account, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) Optional<Account> findById(Long id); }
Lock timeout hint: The jakarta.persistence.lock.timeout hint is interpreted by the underlying database driver. A value of 0 means "fail immediately if the lock cannot be acquired" (no waiting). A value of 3000 means wait up to 3 seconds. If the timeout expires, JPA throws a LockTimeoutException, which is a subclass of PessimisticLockException. Always set a timeout in production to prevent thread starvation.

Locking with JPQL Queries

You can also apply a pessimistic lock to a query that returns multiple entities, which is useful for batch operations or queue-style processing.

@Transactional public List<WorkItem> claimNextBatch(int batchSize) { return em.createQuery( "SELECT w FROM WorkItem w WHERE w.status = 'PENDING' ORDER BY w.createdAt", WorkItem.class) .setMaxResults(batchSize) .setLockMode(LockModeType.PESSIMISTIC_WRITE) .setHint("jakarta.persistence.lock.timeout", 0) // skip-locked semantics where supported .getResultList(); }
SKIP LOCKED for queue processing: PostgreSQL and MySQL 8+ support FOR UPDATE SKIP LOCKED, which skips rows already locked by other transactions instead of waiting. Hibernate activates this when you pass timeout -2 (or use the PESSIMISTIC_SKIP_LOCKED hint on supported providers). This is the backbone of efficient multi-threaded job queues at the database level.

Pessimistic Locking with Named Queries

For Spring Data repository methods that use @Query, add @Lock on the same method — Spring Data wires them together automatically:

@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT o FROM Order o WHERE o.id = :id AND o.status = 'OPEN'") Optional<Order> findOpenOrderForUpdate(@Param("id") Long id);

Performance Trade-offs and Pitfalls

Pessimistic locking is a serialization mechanism at the database level. Every lock held extends the time other transactions must wait. Keep the following in mind:

  • Keep transactions short. A pessimistic lock is held for the lifetime of the transaction. A long-running transaction holding an exclusive lock on a popular row is a bottleneck for every other operation that touches it.
  • Lock at the last responsible moment. If you need to do read-only validation before modifying data, do the read without a lock, then re-fetch with a lock immediately before the write to minimize lock hold time.
  • Deadlocks are possible. Transaction A locks row 1 then tries to lock row 2; Transaction B locks row 2 then tries to lock row 1. The database will detect this cycle and roll back one transaction with a DeadlockException. Always acquire locks in a consistent order across transactions to prevent deadlocks.
  • Do not hold locks across user think-time. Never acquire a pessimistic lock, then wait for a user to click a button before committing. That lock will be held for seconds or minutes and will cripple throughput.
Pessimistic locking does not survive across HTTP requests. A JPA transaction (and its locks) lives entirely within a single service call. If you need "check-out" semantics across multiple HTTP requests — e.g., a CMS where an editor locks an article while editing — you must implement application-level locking: store a locked_by / locked_until column in the database and enforce it in your service layer.

Choosing Between Optimistic and Pessimistic Locking

There is no universally correct answer. Use the following as a starting point:

  • Use optimistic when conflicts are rare, the retry cost is low, and read throughput is more important than write throughput.
  • Use pessimistic when conflicts are frequent or expected, the cost of a retry is high, or correctness requirements demand exclusive ownership before proceeding.
  • Consider both together: a system might use optimistic locking for most entities and pessimistic locking only for high-contention aggregates like inventory counters or account balances.

Summary

Pessimistic locking acquires database-level locks at read time, eliminating any chance of a mid-transaction conflict. JPA exposes it through LockModeType.PESSIMISTIC_READ, PESSIMISTIC_WRITE, and PESSIMISTIC_FORCE_INCREMENT. In Spring Data you apply it with @Lock on repository methods. Always pair pessimistic locks with a timeout hint, keep transactions short, acquire locks in a consistent order to avoid deadlocks, and never hold a lock across user-facing operations. The next lesson explores the second-level cache — a complementary strategy for reducing database load without any locking overhead.