Back to Locking: Optimistic & Pessimistic
Q4Question 4 of 4

What is @Lock and LockModeType?

Key Concepts
  • @Lock annotation on a Spring Data repository method declares the LockModeType for that query.
  • PESSIMISTIC_WRITE issues SELECT ... FOR UPDATE to acquire an exclusive row lock.
  • PESSIMISTIC_READ takes a shared lock that allows concurrent readers but blocks writers.
  • OPTIMISTIC_FORCE_INCREMENT bumps the @Version even on read, protecting aggregate roots.
  • Lock timeout can be configured via @QueryHints with jakarta.persistence.lock.timeout.
  • Choose pessimistic over optimistic when contention is high, retries waste work, or the business cannot tolerate conflicts.
  • Always lock rows in a consistent order (e.g., sorted by ID) to prevent deadlocks.

Definition

@Lock on a Spring Data repository method sets the LockModeType per query, with PESSIMISTIC_WRITE for exclusive row locks and PESSIMISTIC_READ for shared locks, chosen when contention is high enough that optimistic retries waste more than pessimistic waiting.
  • @Lock -- Spring Data annotation placed on repository methods to declare the LockModeType for the query generated by that method.
  • LockModeType.PESSIMISTIC_WRITE -- issues SELECT ... FOR UPDATE for an exclusive row lock, blocking both readers-for-update and writers.
  • LockModeType.PESSIMISTIC_READ -- takes a shared lock that allows concurrent shared reads but blocks exclusive writes.
  • LockModeType.OPTIMISTIC -- forces a version check at commit time even for read-only operations.
  • LockModeType.OPTIMISTIC_FORCE_INCREMENT -- bumps the @Version even on read, protecting aggregate integrity when a child changes.
  • Lock timeout -- configurable via @QueryHints with jakarta.persistence.lock.timeout to prevent indefinite blocking.

The LockModeType is the kind of lock the clerk at National Bank of India puts on Rajesh's account file. PESSIMISTIC_WRITE is a padlock that only you hold: nobody else can update or lock for update until you finish. PESSIMISTIC_READ is a "do not change" sign that lets others read but prevents edits. OPTIMISTIC is just checking the tamper seal at the end. You reach for the padlock (pessimistic) when you know many people grab the same file at once and you would rather they queue than all fail and retry.

Understanding the Concepts

What is @Lock in Spring Data JPA?

@Lock is placed on a Spring Data repository method to declare the LockModeType for that specific query. When the method executes inside a transaction, Hibernate adds the appropriate locking clause to the generated SQL.

JAVA
package com.bank.repository;

import com.bank.model.Account;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    // Pessimistic write lock
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT a FROM Account a WHERE a.accountId = :id")
    Optional<Account> findForUpdate(String id);
    // SQL: SELECT * FROM accounts
    //      WHERE account_id = ? FOR UPDATE

    // Pessimistic read lock
    @Lock(LockModeType.PESSIMISTIC_READ)
    Optional<Account> findPessimisticReadByAccountId(String accountId);
    // SQL: SELECT * FROM accounts
    //      WHERE account_id = ? FOR SHARE

    // Optimistic force increment
    @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
    Optional<Account> findOptimisticByAccountId(String accountId);
    // Bumps version even on read
}

How does PESSIMISTIC_WRITE work?

PESSIMISTIC_WRITE issues SELECT ... FOR UPDATE, acquiring an exclusive row lock. No other transaction can lock or modify that row until the holding transaction commits or rolls back. This is the standard choice for debit and credit operations on hot rows.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class DebitService {

    private final AccountRepository repo;

    public DebitService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void debit(String accountId, BigDecimal amount) {
        Account account = repo.findForUpdate(accountId)
            .orElseThrow();
        // Row locked. Other transactions calling findForUpdate
        // on the same row will BLOCK until this TX commits.

        if (account.getBalance().compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient balance");
        }
        account.setBalance(account.getBalance().subtract(amount));
        repo.save(account);
        // On commit: UPDATE executes + lock released
    }
}
TEXT
Thread A: findForUpdate("SAV2001") -> lock acquired
Thread B: findForUpdate("SAV2001") -> BLOCKED (waiting for lock)
Thread A: UPDATE + COMMIT -> lock released
Thread B: lock acquired -> reads fresh balance -> proceeds

How does PESSIMISTIC_READ work?

PESSIMISTIC_READ takes a shared lock. Multiple transactions can hold shared locks on the same row simultaneously, but no transaction can acquire an exclusive (WRITE) lock until all shared locks are released. Use it when you need to guarantee the row does not change while you compute something, but you allow other readers.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class ReportService {

    private final AccountRepository repo;

    public ReportService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional(readOnly = true)
    public BigDecimal getStableBalance(String accountId) {
        Account account = repo.findPessimisticReadByAccountId(accountId)
            .orElseThrow();
        // Shared lock: other readers can also hold shared locks
        // But writers are BLOCKED until all readers release

        // Do complex calculation...
        return account.getBalance();
        // On commit: shared lock released
    }
}
TEXT
PESSIMISTIC_READ vs PESSIMISTIC_WRITE:
  READ (shared):  Thread A locks, Thread B can also read-lock.
                  Thread C wants to WRITE -> BLOCKED.
  WRITE (exclusive): Thread A locks, Thread B cannot lock at all.
                     Thread B is BLOCKED even for read-lock.

How do you configure lock timeout?

Without a timeout, a pessimistic lock can block a transaction indefinitely if the lock holder is slow. Use @QueryHints to set a timeout in milliseconds.

JAVA
package com.bank.repository;

import com.bank.model.Account;
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.Query;
import org.springframework.data.jpa.repository.QueryHints;
import java.util.Optional;

public interface AccountRepository extends JpaRepository<Account, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(
            name = "jakarta.persistence.lock.timeout",
            value = "3000"  // 3 seconds
        )
    })
    @Query("SELECT a FROM Account a WHERE a.accountId = :id")
    Optional<Account> findForUpdateWithTimeout(String id);
    // If lock cannot be acquired within 3 seconds:
    // PessimisticLockException is thrown
    // Transaction can handle the timeout gracefully
}
JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import jakarta.persistence.PessimisticLockException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class TimeoutAwareDebitService {

    private final AccountRepository repo;

    public TimeoutAwareDebitService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public boolean debit(String accountId, BigDecimal amount) {
        try {
            Account account = repo.findForUpdateWithTimeout(accountId)
                .orElseThrow();
            account.setBalance(account.getBalance().subtract(amount));
            repo.save(account);
            return true;
        } catch (PessimisticLockException e) {
            // Lock timeout: account is busy
            return false;
        }
    }
}

How is @Lock different from EntityManager lock methods?

@Lock is a Spring Data convenience that applies to repository query methods. The EntityManager offers equivalent programmatic locking for more control.

| Approach | Usage | When to use |
|---|---|---|
| @Lock on repository | Declarative, per-method | Simple cases, Spring Data |
| em.find(Class, id, LockMode) | Programmatic, per-call | Dynamic lock decisions |
| em.lock(entity, LockMode) | Lock an already-loaded entity | Upgrade lock mid-transaction |
| @NamedQuery with lockMode | Static, per-query | Named queries |

JAVA
package com.bank.service;

import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class ProgrammaticLockService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void debitWithProgrammaticLock(String accountId) {
        // Option 1: lock at find time
        Account account = em.find(Account.class, accountId,
            LockModeType.PESSIMISTIC_WRITE);

        // Option 2: upgrade lock on already-loaded entity
        Account loaded = em.find(Account.class, accountId);
        em.lock(loaded, LockModeType.PESSIMISTIC_WRITE);
        // SELECT ... FOR UPDATE issued at this point
    }
}

When should you choose pessimistic over optimistic?

Choose pessimistic locking in three situations: when contention is high enough that optimistic retries churn or livelock, when the critical section is short enough that holding a lock briefly is cheaper than retrying, and when the business cannot tolerate any retry.

JAVA
// Choose PESSIMISTIC when:
// 1. High contention: corporate pooled account, 50 tellers
//    Optimistic: constant retries. Pessimistic: clean queue.
// 2. Sequential numbering: cheque numbers (gap-free, unique)
//    Two callers must never receive the same number.
//    Pessimistic serializes access.
// 3. Short critical section: lock, decrement, commit
//    Lock-hold time < 10ms. Cost of holding lock < cost of retry.

// Choose OPTIMISTIC when:
// 1. Low contention: Rajesh's savings (rare concurrent access)
// 2. Read-heavy: most operations are reads, few writes
// 3. Idempotent: retry is cheap and has no side effects
// 4. Distributed: no shared DB row (microservices)

What are common mistakes with @Lock?

The most common mistakes involve using @Lock outside a transaction, applying locks unnecessarily, and forgetting deadlock prevention.

JAVA
// WRONG: @Lock without @Transactional
public void debit(String accountId) {
    Account account = repo.findForUpdate(accountId).orElseThrow();
    // No transaction! Lock is not held across operations.
    // The lock is acquired and immediately released.
    account.setBalance(account.getBalance().subtract(amount));
    repo.save(account);
    // Race condition: another thread can modify between find and save
}

// CORRECT: @Lock inside @Transactional
@Transactional
public void debit(String accountId) {
    Account account = repo.findForUpdate(accountId).orElseThrow();
    // Lock held until commit
    account.setBalance(account.getBalance().subtract(amount));
    repo.save(account);
}
JAVA
// WRONG: locking two rows in inconsistent order
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
    Account fromAcct = repo.findForUpdate(from).orElseThrow();
    Account toAcct = repo.findForUpdate(to).orElseThrow();
    // If another thread locks (to, from), DEADLOCK!
}

// CORRECT: always lock in consistent order
@Transactional
public void transfer(String from, String to, BigDecimal amount) {
    String first = from.compareTo(to) < 0 ? from : to;
    String second = from.compareTo(to) < 0 ? to : from;
    Account firstAcct = repo.findForUpdate(first).orElseThrow();
    Account secondAcct = repo.findForUpdate(second).orElseThrow();
    // Deterministic order prevents deadlocks
}

What about PESSIMISTIC_WRITE with SKIP LOCKED?

Some databases support SKIP LOCKED, which skips rows that are already locked instead of waiting. This is useful for job queues where multiple workers pick up the next available item.

JAVA
package com.bank.repository;

import com.bank.model.PaymentJob;
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.Query;
import org.springframework.data.jpa.repository.QueryHints;
import java.util.List;

public interface PaymentJobRepository extends JpaRepository<PaymentJob, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @QueryHint(
            name = "jakarta.persistence.lock.timeout",
            value = "-2"  // SKIP_LOCKED (Hibernate-specific)
        )
    })
    @Query("SELECT j FROM PaymentJob j WHERE j.status = 'PENDING' "
         + "ORDER BY j.createdAt ASC")
    List<PaymentJob> findNextPendingJobs();
    // Workers call this: each gets a different row
    // Already-locked rows are skipped, not waited on
}
TEXT
SKIP LOCKED use case (payment processing queue):
  Worker A: SELECT ... FOR UPDATE SKIP LOCKED -> gets job 1
  Worker B: SELECT ... FOR UPDATE SKIP LOCKED -> skips job 1, gets job 2
  Worker C: SELECT ... FOR UPDATE SKIP LOCKED -> skips 1 and 2, gets job 3
  No contention. No waiting. Each worker gets a unique job.

Visual Overview

DIAGRAM
  LockModeType options:
  ┌────────────────────────────────────────────────────┐
  │  PESSIMISTIC_WRITE                                  │
  │  SQL: SELECT ... FOR UPDATE                         │
  │  Exclusive lock. No other thread can lock or write. │
  │  Use: debit/credit on hot rows                      │
  ├────────────────────────────────────────────────────┤
  │  PESSIMISTIC_READ                                   │
  │  SQL: SELECT ... FOR SHARE                          │
  │  Shared lock. Others can read, nobody can write.    │
  │  Use: stable reads during calculation               │
  ├────────────────────────────────────────────────────┤
  │  OPTIMISTIC                                         │
  │  No SQL lock. Version check at commit.              │
  │  Use: low-contention reads that must be consistent  │
  ├────────────────────────────────────────────────────┤
  │  OPTIMISTIC_FORCE_INCREMENT                         │
  │  No SQL lock. Bumps version even on read.           │
  │  Use: aggregate root protection when child changes  │
  └────────────────────────────────────────────────────┘

  Deadlock prevention:
  ┌─────────────────────────────────────────────┐
  │  Transfer SAV2001 -> SAV2002:                │
  │  Lock order: SAV2001, then SAV2002           │
  │                                              │
  │  Transfer SAV2002 -> SAV2001:                │
  │  Lock order: SAV2001, then SAV2002 (SAME!)   │
  │                                              │
  │  Consistent ordering prevents deadlocks.     │
  └─────────────────────────────────────────────┘

Key Takeaway

The @Lock annotation in Spring Data JPA attaches a LockModeType to a specific repository query method. PESSIMISTIC_WRITE issues SELECT ... FOR UPDATE for an exclusive lock, which is the standard choice for debit and credit operations on contended rows. PESSIMISTIC_READ takes a shared lock for stable reads during calculations. OPTIMISTIC_FORCE_INCREMENT bumps the version even on read, useful for protecting aggregate roots when only a child entity changes.

The decision to choose pessimistic over optimistic comes down to three situations: when contention is high enough that optimistic retries churn or livelock, when the critical section is short enough that holding a lock briefly is cheaper than retrying, and when the business requires gap-free sequential numbering where two callers must never receive the same value. Everywhere else, optimistic locking is the default because it holds no database locks and scales better.

In our National Bank of India system, the cheque-number counter is a single hot row locked with PESSIMISTIC_WRITE so every caller serializes cleanly and numbers are guaranteed unique and gap-free. For transfers between accounts, both rows are locked pessimistically but always in consistent order by sorting account IDs first, which prevents the deadlock that would occur if a simultaneous reverse transfer locked them in the opposite order. A lock timeout of 3 seconds ensures a stuck transaction cannot block others indefinitely.

Remember one sentence: @Lock sets the LockModeType per repository method, and you choose PESSIMISTIC_WRITE for high-contention hot rows, always with a lock timeout and consistent lock ordering to prevent deadlocks.

---

Share
Use ← → arrow keys to navigate