Stock reservation and balance updates that “update based on a value you read” are tricky when they run concurrently. If two requests read the same stock at the same time, each subtracts and writes back, one of the updates gets lost.

You could use optimistic locking with retries, but for high-contention stock or payment scenarios, you sometimes just want to “serialize it at the DB level in the first place.” That’s where pessimistic locking comes in. In this article, we’ll walk through real code — from applying PESSIMISTIC_WRITE with Spring Data JPA’s @Lock, to verifying FOR UPDATE, handling timeouts, and avoiding deadlocks.

What Is Pessimistic Locking — How It Differs from Optimistic Locking

Pessimistic locking makes the DB acquire an exclusive lock at the moment you read a row, forcing other transactions to wait until your transaction finishes. Instead of detecting contention after it occurs, it makes others wait in line from the start and prevents contention itself.

Optimistic locking, by contrast, uses a @Version column to detect contention at update time, throwing an exception on a collision so you can retry. If contention is rare, there’s no locking cost and it’s fast, but if contention is frequent, you end up drowning in retries.

In other words, cases like stock, balances, and payments where you want to “process exactly one at a time” are suited to pessimistic locking, while cases where updates rarely collide are suited to optimistic locking. The implementation details of optimistic locking are covered in Implementing Optimistic Locking (@Version) in Spring Boot, so reading them together will clarify when to use which.

@Lock and LockModeType

Pessimistic locking is enabled simply by adding @Lock to a Repository method. Let’s use a stock entity Stock as our example.

public interface StockRepository extends JpaRepository<Stock, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select s from Stock s where s.id = :id")
    Optional<Stock> findByIdForUpdate(@Param("id") Long id);
}

PESSIMISTIC_WRITE is an exclusive lock that makes reads and writes from other transactions wait. Use this for locks that assume you’ll update the row.

PESSIMISTIC_READ, on the other hand, is a shared lock. Use it when you only read but want to make others’ updates wait, while still allowing others to read. In most use cases you read in order to update, so choosing PESSIMISTIC_WRITE will keep you out of trouble.

You could also override findById directly and add @Lock, but since we want to clearly separate the locking version from the non-locking version, giving it a dedicated method name as above makes the code more readable.

Why the @Transactional Boundary Is Mandatory

This is the biggest pitfall. A pessimistic lock is held only while the transaction is open. Without @Transactional, the transaction closes immediately after you acquire the lock, the lock is released right away, and it accomplishes nothing at all.

Make sure everything from the read to the update is contained within the same transaction.

@Service
public class StockService {

    private final StockRepository stockRepository;

    public StockService(StockRepository stockRepository) {
        this.stockRepository = stockRepository;
    }

    @Transactional
    public void consume(Long stockId, int quantity) {
        Stock stock = stockRepository.findByIdForUpdate(stockId)
                .orElseThrow(() -> new IllegalArgumentException("在庫が見つかりません"));

        if (stock.getQuantity() < quantity) {
            throw new IllegalStateException("在庫不足です");
        }
        stock.setQuantity(stock.getQuantity() - quantity);
    }
}

Because everything from acquiring the lock with findByIdForUpdate to decrementing the stock is within the same transaction, no other request can touch the same row during this window. As a result, the stock subtractions are serialized one at a time. If you’re uncertain about the behavior of transaction boundaries themselves, see Transaction Management in Spring Boot as well.

Verifying the Issued SQL in the Logs

Let’s confirm in the logs that the lock is actually taking effect by checking the issued SQL. Enable SQL logging in application.properties.

spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
logging.level.org.hibernate.SQL=DEBUG

With PESSIMISTIC_WRITE, PostgreSQL produces output like this.

select s.id, s.quantity from stock s where s.id = ? for update

MySQL likewise appends for update. For PESSIMISTIC_READ, it varies by DB: PostgreSQL uses for share, while MySQL uses for share (8.0 and later) or lock in share mode.

Whether the lock actually works is easiest to observe with two transactions. Acquire the lock in one and pause processing, then read the same ID with findByIdForUpdate from the other, and the latter will block until the former commits. This is the essence of serialization.

Controlling Lock Waits with jakarta.persistence.lock.timeout

If you leave lock waits unbounded, a stuck request will keep holding a connection. Control the wait time with jakarta.persistence.lock.timeout. Specify it with @QueryHints.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints({
    @QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")
})
@Query("select s from Stock s where s.id = :id")
Optional<Stock> findByIdForUpdateWithTimeout(@Param("id") Long id);

The values mean the following. A positive value is the wait in milliseconds (3 seconds in the example above), 0 is NOWAIT which fails immediately without waiting, and -1 is an infinite wait.

On timeout, a jakarta.persistence.LockTimeoutException or PessimisticLockException is thrown. Whether you retry or return an error here depends on your business requirements.

Be aware that support for this hint is DB-dependent. PostgreSQL doesn’t support specifying a wait in milliseconds — only 0 for NOWAIT works, and the wait time is controlled via the lock_timeout parameter instead. MySQL is primarily governed by innodb_lock_wait_timeout, and it too has quirks in how it handles numeric values. Always verify the logs and actual behavior on your target DB.

How Deadlocks Arise and How to Avoid Them

Once you start locking multiple rows, deadlocks rear their head. Consider a scenario where a transfer from account A to account B and a transfer in the reverse direction run at the same time.

  • Transaction 1 locks A, then goes to acquire B
  • Transaction 2 locks B, then goes to acquire A

Each waits for the lock the other holds, and neither ever proceeds. This is a deadlock. The DB detects it and forcibly fails one of them.

The standard way to avoid this is to always acquire locks in the same order. For example, if you always acquire them in ascending ID order, the reverse-order waiting situation itself can never happen.

@Transactional
public void transfer(Long fromId, Long toId, int amount) {
    // 常にID昇順でロックを取得してデッドロックを防ぐ
    List<Long> ids = Stream.of(fromId, toId).sorted().toList();
    Map<Long, Account> locked = new HashMap<>();
    for (Long id : ids) {
        Account account = accountRepository.findByIdForUpdate(id)
                .orElseThrow(() -> new IllegalArgumentException("口座なし"));
        locked.put(id, account);
    }

    Account from = locked.get(fromId);
    Account to = locked.get(toId);
    from.withdraw(amount);
    to.deposit(amount);
}

Whether it’s a transfer or a stock movement, when touching multiple rows it’s safest to consistently “always sort, then lock in order.” Even so, deadlocks won’t necessarily drop to zero, so making it robust by catching DeadlockLoserDataAccessException and retrying a few times is a good safeguard to add.

Criteria for Choosing Between Optimistic and Pessimistic Locking

Finally, let’s organize the guidelines for choosing between them.

If contention is frequent and you want to process reliably even at the cost of some waiting, go with pessimistic locking. It’s effective in situations where retries tend to come up empty, such as reserving stock for a popular product or updating balances.

Conversely, if contention is rare, you prioritize throughput, and you can tolerate the occasional retry, go with optimistic locking. Since there’s no waiting due to locks, concurrency is higher.

For processes like payments and stock reservation where you “don’t want to miss even a single one,” serializing with pessimistic locking is the more straightforward approach. On the other hand, don’t rely on locks alone for guarding against duplicate requests from the outside — combining them with an idempotency key implementation gives you peace of mind. Also, since more transactions waiting on locks consume connections, design your wait times together with HikariCP pool tuning.

Summary

The basic form of pessimistic locking is to add @Lock(LockModeType.PESSIMISTIC_WRITE) to a Repository method and perform everything from the read to the update within @Transactional. You can verify the issued FOR UPDATE in the SQL logs, control waits with jakarta.persistence.lock.timeout, and avoid deadlocks by locking multiple rows in ID order. Use pessimistic and optimistic locking selectively based on the frequency of contention and your tolerance for retries.