Back to Locking: Optimistic & Pessimistic
Q1Question 1 of 4

Optimistic vs pessimistic locking?

Key Concepts
  • Optimistic locking uses a @Version column to detect conflicts at commit time without holding database locks.
  • Pessimistic locking acquires a database row lock (SELECT ... FOR UPDATE) at read time, blocking other transactions.
  • Optimistic locking throws OptimisticLockException when the version has changed since the entity was read.
  • Pessimistic locking prevents conflicts upfront but reduces concurrency and can cause deadlocks.
  • Choose optimistic for low-contention scenarios like savings accounts where conflicts are rare.
  • Choose pessimistic for high-contention hot rows like corporate pooled accounts or sequential counters.
  • Both approaches prevent lost updates: the choice depends on conflict frequency and tolerance for retries vs waiting.

Definition

Optimistic locking detects conflicts at commit time using a version column and retries on failure, while pessimistic locking prevents conflicts by acquiring a database row lock upfront and making others wait.
  • Optimistic locking -- assumes conflicts are rare, uses a @Version column to detect concurrent changes at commit time, and throws OptimisticLockException if the row was modified since it was read.
  • Pessimistic locking -- assumes conflicts are likely, acquires a database row lock (SELECT ... FOR UPDATE) at read time, blocking other transactions until the lock holder commits or rolls back.
  • @Version -- a field (int, long, or timestamp) that Hibernate increments on every update and includes in the WHERE clause to detect stale writes.
  • LockModeType.PESSIMISTIC_WRITE -- issues SELECT ... FOR UPDATE to acquire an exclusive row lock.
  • Lost update problem -- both approaches prevent the scenario where two concurrent transactions read the same value and one's write silently overwrites the other's.

At National Bank of India, imagine Rajesh's account passbook. Optimistic locking is like two clerks each taking a photocopy, making edits independently. The first clerk to file wins and the second is told "the record changed, redo your work." Nobody waited, but one must retry. Pessimistic locking is like the first clerk physically locking the account file in a drawer before editing. The second clerk must wait outside until the drawer is unlocked. No retries needed, but people queue up.

Understanding the Concepts

What is the lost update problem?

The lost update problem occurs when two transactions read the same row, both modify it, and the second commit silently overwrites the first's changes. Without any locking, a salary credit and a loan EMI debit arriving at the same time on Rajesh's account could result in one being lost.

JAVA
// Without locking:
// Thread A reads balance = 100000
// Thread B reads balance = 100000
// Thread A: balance + 50000 (salary) = 150000, saves
// Thread B: balance - 20000 (EMI) = 80000, saves
// Final balance: 80000 (Thread A's salary credit is LOST!)
// Correct balance should be: 130000
TEXT
Timeline without locking:
  T1: Thread A reads balance = 100000
  T2: Thread B reads balance = 100000
  T3: Thread A writes balance = 150000 (100000 + 50000)
  T4: Thread B writes balance = 80000  (100000 - 20000)
  Result: 80000 (salary credit lost!)
  Expected: 130000

How does optimistic locking work?

Optimistic locking uses a @Version field on the entity. Hibernate adds WHERE version = ? to every UPDATE and increments the version. If the version changed since the entity was read, the UPDATE matches zero rows, and Hibernate throws OptimisticLockException.

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "holder_name")
    private String holderName;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Version
    private Long version;  // Hibernate manages this completely

    // getters and setters
}
TEXT
Optimistic locking flow:
  T1: Thread A reads account SAV2001, balance=100000, version=5
  T2: Thread B reads account SAV2001, balance=100000, version=5
  T3: Thread A UPDATE SET balance=150000, version=6
      WHERE account_id='SAV2001' AND version=5
      -> 1 row affected. Commits successfully.
  T4: Thread B UPDATE SET balance=80000, version=6
      WHERE account_id='SAV2001' AND version=5
      -> 0 rows affected! Version is now 6, not 5.
      -> Hibernate throws OptimisticLockException
      -> Thread B must retry: reload, recompute, resave.

How does pessimistic locking work?

Pessimistic locking acquires a database row lock at read time using SELECT ... FOR UPDATE. Other transactions attempting to lock the same row must wait until the first transaction commits or rolls back.

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;
import java.math.BigDecimal;

@Service
public class PessimisticTransferService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void debit(String accountId, BigDecimal amount) {
        Account account = em.find(
            Account.class, accountId,
            LockModeType.PESSIMISTIC_WRITE
        );
        // SQL: SELECT * FROM accounts
        //      WHERE account_id = 'SAV2001' FOR UPDATE
        // Row is now LOCKED. Other transactions wait.

        account.setBalance(account.getBalance().subtract(amount));
        // On commit: UPDATE + release lock
        // Waiting transactions can now proceed
    }
}
TEXT
Pessimistic locking flow:
  T1: Thread A: SELECT ... FOR UPDATE (acquires lock)
  T2: Thread B: SELECT ... FOR UPDATE (BLOCKS, waiting for lock)
  T3: Thread A: UPDATE balance=150000, COMMIT (releases lock)
  T4: Thread B: lock acquired, reads balance=150000
  T5: Thread B: UPDATE balance=130000, COMMIT
  Result: 130000 (correct! no lost update)
  No retries needed. Transactions serialized.

How is optimistic locking different from pessimistic locking?

| Aspect | Optimistic Locking | Pessimistic Locking |
|---|---|---|
| Assumption | Conflicts are rare | Conflicts are likely |
| Mechanism | @Version column + WHERE clause | SELECT ... FOR UPDATE (DB lock) |
| When conflict detected | At commit time | At lock acquisition (blocks) |
| On conflict | Exception, must retry | Wait until lock released |
| Database locks held | None | Exclusive row lock |
| Concurrency | High (no locks held) | Lower (locks serialize access) |
| Deadlock risk | None | Yes (if lock ordering is wrong) |
| Retry needed | Yes (reload + reapply) | No (serialized access) |
| Best for | Low contention, read-heavy | High contention, hot rows |
| Spring exception | ObjectOptimisticLockingFailureException | PessimisticLockException (timeout) |

JAVA
package com.bank.service;

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

@Service
public class LockingComparisonService {

    private final AccountRepository repo;

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

    // Optimistic: detect conflict, retry
    public void optimisticDebit(String accountId, BigDecimal amount) {
        int retries = 3;
        while (retries > 0) {
            try {
                doDebit(accountId, amount);
                return;
            } catch (ObjectOptimisticLockingFailureException e) {
                retries--;
                if (retries == 0) throw e;
                // Retry: reload fresh data in new transaction
            }
        }
    }

    @Transactional
    public void doDebit(String accountId, BigDecimal amount) {
        Account account = repo.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        repo.save(account);
    }
}

When should you choose optimistic locking?

Choose optimistic locking when conflicts are rare and the system can tolerate occasional retries. This is the default choice for most banking operations because it holds no database locks, scales well, and avoids deadlocks.

JAVA
// Good candidates for optimistic locking:
// - Savings account balance updates (rare concurrent access)
// - Customer profile updates (one user at a time)
// - Loan application status changes (sequential workflow)
// - Interest rate configuration changes (admin-only, rare)
//
// Rajesh's savings account SAV2001:
// Salary credit and EMI debit rarely arrive at the same instant.
// If they do, one retry handles it cheaply.

When should you choose pessimistic locking?

Choose pessimistic locking when many transactions frequently target the same row, when retries would waste significant work, or when the business cannot tolerate any retry.

JAVA
// Good candidates for pessimistic locking:
// - Corporate pooled account (many tellers posting entries)
// - Cheque number counter (sequential, gap-free numbers)
// - Hot stock inventory (thousands of buy orders per second)
// - Daily settlement batch (one row updated repeatedly)
//
// Corporate account CORP001:
// 50 tellers posting entries throughout the day.
// Optimistic locking would cause constant retries.
// Pessimistic locking serializes cleanly.

What are the risks of each approach?

Optimistic locking risks retry storms under high contention. Pessimistic locking risks deadlocks and reduced throughput.

JAVA
// Optimistic risk: retry storm
// 100 threads read version=5
// 1 succeeds (version becomes 6)
// 99 fail and retry
// On retry, many read version=6, compete again
// Under extreme contention, this wastes CPU and DB resources

// Pessimistic risk: deadlock
// Thread A locks account SAV2001, then tries to lock SAV2002
// Thread B locks account SAV2002, then tries to lock SAV2001
// Both wait forever: DEADLOCK!
// Fix: always lock accounts in a consistent order (e.g., sorted by ID)
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;
import java.math.BigDecimal;
import java.util.Arrays;

@Service
public class SafeTransferService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void transfer(String fromId, String toId, BigDecimal amount) {
        // ALWAYS lock in consistent order to prevent deadlocks
        String[] sorted = new String[]{fromId, toId};
        Arrays.sort(sorted);

        Account first = em.find(Account.class, sorted[0],
            LockModeType.PESSIMISTIC_WRITE);
        Account second = em.find(Account.class, sorted[1],
            LockModeType.PESSIMISTIC_WRITE);

        Account from = fromId.equals(sorted[0]) ? first : second;
        Account to = fromId.equals(sorted[0]) ? second : first;

        from.setBalance(from.getBalance().subtract(amount));
        to.setBalance(to.getBalance().add(amount));
    }
}

What about hybrid approaches?

In practice, many banking systems use a hybrid approach: optimistic locking as the default, with pessimistic locking escalated for proven hot rows. Monitor the retry rate and switch strategies when contention exceeds a threshold.

JAVA
// Hybrid strategy for National Bank of India:
//
// 1. Default: optimistic locking (@Version on all entities)
// 2. Monitor: retry counter per account
// 3. If retry rate > threshold for a specific account:
//    Escalate to pessimistic locking for that account
// 4. Alternative: atomic conditional UPDATE
//    UPDATE accounts SET balance = balance - :amount
//    WHERE account_id = :id AND balance >= :amount
//    One SQL, no read-modify-write race

What are common interview follow-up questions?

  • How do you handle OptimisticLockException? Bounded retry with fresh reload.
  • Can optimistic and pessimistic locking coexist? Yes, different methods can use different strategies.
  • What is OPTIMISTIC_FORCE_INCREMENT? Bumps the version even on read, useful for aggregate root locking.
  • How do you set a pessimistic lock timeout? Use jakarta.persistence.lock.timeout query hint.
  • What database features power pessimistic locking? PostgreSQL FOR UPDATE, MySQL FOR UPDATE, Oracle FOR UPDATE WAIT.
JAVA
package com.bank.repository;

import com.bank.model.Account;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHints;
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, String> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({
        @jakarta.persistence.QueryHint(
            name = "jakarta.persistence.lock.timeout",
            value = "3000"  // 3 second timeout
        )
    })
    Optional<Account> findForUpdateByAccountId(String accountId);
}

Visual Overview

DIAGRAM
  Optimistic Locking:
  ┌──────────┐  read v=5  ┌──────────┐  read v=5  ┌──────────┐
  │ Thread A  │ ────────> │ Database  │ <──────── │ Thread B  │
  └─────┬────┘           └──────────┘           └─────┬────┘
        │                                             │
        │  UPDATE WHERE v=5                           │
        │  -> 1 row, v=6                              │
        ▼                                             │
  ┌──────────┐                                        │
  │ Success!  │                                        │
  └──────────┘                                        │
                              UPDATE WHERE v=5        │
                              -> 0 rows!              ▼
                                              ┌──────────────┐
                                              │ Optimistic    │
                                              │ LockException │
                                              │ -> RETRY      │
                                              └──────────────┘

  Pessimistic Locking:
  ┌──────────┐  FOR UPDATE ┌──────────┐               ┌──────────┐
  │ Thread A  │ ────────> │ Database  │               │ Thread B  │
  │ (locked)  │           │ row locked│ <── BLOCKED ──│ (waiting) │
  └─────┬────┘           └──────────┘               └──────────┘
        │                                                  │
        │  UPDATE + COMMIT                                 │
        │  (releases lock)                                 │
        ▼                                                  ▼
  ┌──────────┐                                    ┌──────────────┐
  │ Done!     │                                    │ Lock acquired │
  └──────────┘                                    │ Proceeds      │
                                                  └──────────────┘

Key Takeaway

Optimistic and pessimistic locking both solve the lost update problem but with opposite assumptions. Optimistic locking assumes conflicts are rare: it adds a @Version column, and Hibernate appends a WHERE version equals clause to every UPDATE. If someone changed the row since you read it, your update matches zero rows and Hibernate throws OptimisticLockException. You then retry by reloading the fresh state and reapplying the change. No database locks are held, so it scales well under low contention.

Pessimistic locking assumes conflicts are likely: it acquires a database row lock at read time with SELECT ... FOR UPDATE. Other transactions wanting the same row wait until you commit. There are no retries because access is serialized, but you are holding a real lock, which reduces concurrency and can cause deadlocks if lock ordering is inconsistent.

In our National Bank of India system, optimistic locking is the default for Rajesh's savings account because two writes landing on the same account at the same instant is rare. For the corporate pooled account where 50 tellers post entries all day, pessimistic locking serializes access cleanly and avoids constant retry storms. For transfers between two accounts, pessimistic locks are acquired in a consistent order (sorted by account ID) to prevent deadlocks.

Remember one sentence: optimistic locking detects conflicts at commit time using a version column and retries, while pessimistic locking prevents conflicts by locking the row upfront, and you choose based on how often two writers hit the same row.

---

Share
Use ← → arrow keys to navigate