Back to Locking: Optimistic & Pessimistic
Q2Question 2 of 4

Optimistic vs pessimistic by conflict frequency?

Key Concepts
  • The decision between optimistic and pessimistic locking is driven by contention frequency on specific rows.
  • High-contention hot rows cause optimistic retry storms where many transactions fail and retry repeatedly.
  • Pessimistic locking serializes access to hot rows, eliminating wasted work from retries.
  • Low-contention rows benefit from optimistic locking because locks are never held and concurrency is maximized.
  • An atomic conditional UPDATE can eliminate the read-modify-write race without application-level locks.
  • Monitor conflict rates in production with retry counters to guide strategy escalation.
  • For extreme hot keys, consider sharding the quantity across sub-rows or routing through a serializing queue.

Definition

When many concurrent writers target the same row (high contention), pessimistic locking avoids retry storms; when conflicts are rare (low contention), optimistic locking maximizes throughput without holding locks.
  • Contention -- the frequency at which multiple transactions attempt to modify the same row concurrently.
  • Retry storm -- under optimistic locking with high contention, many transactions read the same version, one succeeds, and the rest fail and retry, wasting CPU and database round-trips.
  • Hot row -- a single row targeted by many concurrent writers, such as a popular stock quantity counter or a shared pool account balance.
  • Atomic conditional UPDATE -- a single SQL statement that checks and modifies in one step (UPDATE ... SET qty = qty - :n WHERE qty >= :n), eliminating the read-modify-write race.
  • Crossover point -- the contention level where the cost of optimistic retries exceeds the cost of pessimistic serialized waiting.

At National Bank of India's brokerage, a hot IPO has only 10 units left and 5,000 investors click "buy" in the same second. If we use optimistic locking, everyone reads "10 units available," and 4,999 of them fail with version conflicts and retry, causing a storm of wasted work. If we use pessimistic locking, investors queue up neatly and each one gets a turn. For a sleepy government bond that almost no one buys concurrently, optimistic locking is perfect because conflicts essentially never happen and nobody waits for a lock.

Understanding the Concepts

Why does conflict frequency matter?

The choice between optimistic and pessimistic locking is fundamentally a contention question. When conflict probability is low, optimistic locking wins because no locks are held and the rare conflict costs just one cheap retry. When conflict probability is high, pessimistic locking wins because it serializes access without wasting work on failed retries.

JAVA
// Low contention example:
// Rajesh buys a rarely traded government bond.
// Almost no one else touches that row concurrently.
// Optimistic locking: no lock overhead, rare retry.
// Pessimistic locking: unnecessary lock, wasted concurrency.

// High contention example:
// 5,000 investors buy a hot IPO stock simultaneously.
// One row (stock quantity) is hit by 5,000 transactions.
// Optimistic locking: 4,999 retries, many retry again -> retry storm.
// Pessimistic locking: investors queue, each succeeds in turn.

What happens under high contention with optimistic locking?

With high contention, optimistic locking produces a cascade of failures. Many transactions read the same version, all try to decrement the stock quantity, one succeeds, and the rest fail. On retry, they conflict again because the rate of incoming requests exceeds the rate of successful commits.

JAVA
package com.bank.service;

import com.bank.model.StockHolding;
import com.bank.repository.StockRepository;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class OptimisticStockService {

    private final StockRepository stockRepo;

    public OptimisticStockService(StockRepository stockRepo) {
        this.stockRepo = stockRepo;
    }

    // Under high contention, this causes a retry storm
    public boolean buyStock(String symbol, int quantity) {
        int retries = 5;
        while (retries > 0) {
            try {
                return doAttemptBuy(symbol, quantity);
            } catch (ObjectOptimisticLockingFailureException e) {
                retries--;
            }
        }
        return false;
    }

    @Transactional
    public boolean doAttemptBuy(String symbol, int qty) {
        StockHolding stock = stockRepo.findBySymbol(symbol);
        if (stock.getAvailableQuantity() < qty) return false;
        stock.setAvailableQuantity(
            stock.getAvailableQuantity() - qty);
        stockRepo.save(stock);
        return true;
    }
}
TEXT
5,000 concurrent buy requests for symbol "ICICIIPL":
  Round 1: 5,000 read version=1. 1 succeeds. 4,999 fail.
  Round 2: 4,999 retry. Read version=2. 1 succeeds. 4,998 fail.
  Round 3: 4,998 retry. 1 succeeds. 4,997 fail.
  Total wasted transactions: ~25,000 failed attempts.
  This is a retry storm.

How does pessimistic locking handle hot rows?

Pessimistic locking serializes access to the hot row. Each transaction acquires an exclusive lock, decrements the quantity, and commits. No work is wasted because each transaction succeeds in its turn.

JAVA
package com.bank.service;

import com.bank.model.StockHolding;
import com.bank.repository.StockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class PessimisticStockService {

    private final StockRepository stockRepo;

    public PessimisticStockService(StockRepository stockRepo) {
        this.stockRepo = stockRepo;
    }

    @Transactional
    public boolean buyStock(String symbol, int quantity) {
        StockHolding stock = stockRepo.findForUpdateBySymbol(symbol);
        // SELECT ... FOR UPDATE. Row locked. Others wait.

        if (stock.getAvailableQuantity() < quantity) {
            return false;
        }
        stock.setAvailableQuantity(
            stock.getAvailableQuantity() - quantity);
        stockRepo.save(stock);
        return true;
    }
}
TEXT
5,000 concurrent buy requests with pessimistic locking:
  Thread 1: lock, decrement, commit. Success.
  Thread 2: lock (after T1), decrement, commit. Success.
  ...
  Thread 10: lock. Quantity=0. Return false.
  No wasted work. Each thread succeeds or reports sold-out.

What is an atomic conditional UPDATE?

An atomic conditional UPDATE pushes the check-and-modify logic into a single SQL statement, eliminating the read-modify-write race entirely without holding an application-level lock.

JAVA
package com.bank.repository;

import com.bank.model.StockHolding;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;

public interface StockRepository extends JpaRepository<StockHolding, Long> {

    @Modifying
    @Query("UPDATE StockHolding s "
         + "SET s.availableQuantity = s.availableQuantity - :qty "
         + "WHERE s.symbol = :symbol "
         + "AND s.availableQuantity >= :qty")
    int decrementStock(String symbol, int qty);
    // 1 = success. 0 = insufficient stock.
    // One SQL. No read-modify-write. No lock.
}
JAVA
package com.bank.service;

import com.bank.repository.StockRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AtomicStockService {

    private final StockRepository stockRepo;

    public AtomicStockService(StockRepository stockRepo) {
        this.stockRepo = stockRepo;
    }

    @Transactional
    public boolean buyStock(String symbol, int quantity) {
        int updated = stockRepo.decrementStock(symbol, quantity);
        return updated > 0;
    }
}
TEXT
SQL: UPDATE stock_holdings
     SET available_quantity = available_quantity - 1
     WHERE symbol = 'ICICIIPL' AND available_quantity >= 1

If quantity was 10: 1 row updated. available_quantity = 9.
If quantity was 0:  0 rows updated. Insufficient.
No read-modify-write race. No version conflict. No lock.

How do you decide which strategy to use?

The decision framework is based on contention level, tolerance for retries, and whether the operation can be expressed as an atomic SQL statement.

| Contention | Strategy | Reason |
|---|---|---|
| Low (rare conflicts) | Optimistic | No lock overhead, rare cheap retry |
| Medium (occasional) | Optimistic + bounded retry | Retry handles occasional clashes |
| High (frequent) | Pessimistic | Avoids retry storms, serialized |
| Very high (hot key) | Atomic conditional UPDATE | One SQL, no read-modify-write |
| Extreme (millions/sec) | Shard + queue | Spread contention across sub-rows |

JAVA
// Decision code pattern:
//
// Step 1: Default to optimistic locking (@Version on entity)
// Step 2: Monitor retry rate per entity/account in production
// Step 3: If retry rate > threshold for a specific row:
//         Escalate to pessimistic or atomic conditional UPDATE
// Step 4: For extreme hot keys:
//         Shard the quantity across N sub-rows
//         Or route orders through a serializing queue

What about sharding for extreme contention?

For extremely hot rows where even pessimistic locking becomes a bottleneck, shard the quantity across multiple sub-rows to spread contention.

JAVA
package com.bank.service;

import com.bank.repository.StockShardRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.concurrent.ThreadLocalRandom;

@Service
public class ShardedStockService {

    private static final int SHARD_COUNT = 10;
    private final StockShardRepository shardRepo;

    public ShardedStockService(StockShardRepository shardRepo) {
        this.shardRepo = shardRepo;
    }

    @Transactional
    public boolean buyStock(String symbol, int quantity) {
        int shard = ThreadLocalRandom.current().nextInt(SHARD_COUNT);
        int updated = shardRepo.decrementShard(symbol, shard, quantity);
        if (updated > 0) return true;

        for (int i = 0; i < SHARD_COUNT; i++) {
            if (i == shard) continue;
            updated = shardRepo.decrementShard(symbol, i, quantity);
            if (updated > 0) return true;
        }
        return false;
    }
}
TEXT
Stock sharding:
  stock_shards table:
  symbol='ICICIIPL', shard=0, quantity=1
  symbol='ICICIIPL', shard=1, quantity=1
  ...
  symbol='ICICIIPL', shard=9, quantity=1
  Total: 10 units across 10 shards.
  5,000 requests spread across 10 shards.
  Contention per row reduced by 10x.

What should a senior engineer know about contention analysis?

Senior engineers should measure contention in production rather than guessing. Monitor the optimistic retry rate per entity and per specific row. If a particular row consistently triggers retries, that is the signal to escalate from optimistic to pessimistic or atomic conditional UPDATE.

JAVA
// Monitoring approach for National Bank of India:
//
// 1. Counter: jpa.optimistic.retries (tagged by entity and ID)
//    Increment on every ObjectOptimisticLockingFailureException
//
// 2. Timer: jpa.pessimistic.lockwait (tagged by entity)
//    Measure time spent waiting for pessimistic locks
//
// 3. Alert: if retry rate for a specific account > 10/min
//    Escalate that account to pessimistic locking
//
// 4. Dashboard: top-10 most contended rows
//    Helps identify candidates for sharding or queue routing

What are common mistakes in locking strategy selection?

The most common mistakes involve using the wrong strategy for the contention level.

JAVA
// WRONG: pessimistic locking on a cold row
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Account> findByAccountId(String id);
// Rajesh's savings: rarely accessed concurrently.
// Pessimistic locking wastes concurrency for no benefit.

// CORRECT: optimistic locking for cold rows
// Just use @Version on the entity. No @Lock needed.

// WRONG: optimistic locking on a known hot row
@Version private Long version;
// Corporate pooled account with 50 concurrent tellers:
// Constant retry storms. Throughput tanks.

// CORRECT: pessimistic or atomic conditional for hot rows
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Account> findForUpdateByAccountId(String id);

Visual Overview

DIAGRAM
  Contention spectrum:

  Low contention                              High contention
  ◄──────────────────────────────────────────────────────►
  │                    │                    │              │
  Optimistic           Optimistic           Pessimistic    Shard/Queue
  (no locks)           + retry              (FOR UPDATE)   (extreme)
  │                    │                    │              │
  Rajesh's savings     Loan processing      Corporate      IPO stock
  (rare conflicts)     (occasional)         pooled account (5000/sec)

  Atomic conditional UPDATE:
  ┌──────────────────────────────────────────────────────┐
  │  UPDATE stock SET qty = qty - 1                       │
  │  WHERE symbol = 'X' AND qty >= 1                      │
  │                                                       │
  │  One SQL. No read-modify-write. No lock.              │
  │  Works across the entire contention spectrum.          │
  └──────────────────────────────────────────────────────┘

Key Takeaway

This is fundamentally a contention question, and the answer always starts with "it depends on how often two writers hit the same row." When many concurrent buyers target one hot row, optimistic locking backfires: everyone reads the same version, one succeeds, and the rest fail and retry, creating a retry storm that wastes CPU and database round-trips. For hot rows, pessimistic locking serializes access so each transaction waits its turn and no work is wasted.

When writers rarely collide, optimistic locking is the clear winner. It holds no locks, scales well, and the rare conflict costs just one cheap retry. Holding a pessimistic lock on a cold row would only reduce concurrency for no benefit. An atomic conditional UPDATE offers the best of both worlds by pushing the check-and-modify into a single SQL statement that eliminates the read-modify-write race entirely.

In our National Bank of India brokerage, a hot IPO with ten units and thousands of simultaneous buyers uses an atomic conditional UPDATE that decrements quantity with a WHERE qty >= :requested guard. The database handles atomicity in one statement, and zero rows updated means stock is insufficient. For the thinly traded government bond, optimistic locking is used because conflicts essentially never happen.

Remember one sentence: default to optimistic locking because it scales, measure conflict rates per row in production, and escalate proven hot rows to pessimistic locking or atomic conditional UPDATE to avoid retry storms.

---

Share
Use ← → arrow keys to navigate