Optimistic Locking at Scale
Optimistic Locking at Scale
The Lie
Add @Version to your entity. Hibernate handles concurrent modifications. Conflicts throw OptimisticLockException. Catch it, retry, done.
The Reality
@Version adds a version column to your table. Every UPDATE includes a WHERE clause that checks the version. If the version in the database does not match the version Hibernate loaded, the UPDATE affects zero rows, and Hibernate throws OptimisticLockException.
This works perfectly at low contention. When two users edit the same order minutes apart, one saves first, the second gets an exception. Retry or show the user a conflict resolution screen.
At high contention, optimistic locking collapses. If 50 concurrent requests try to update the same row, 49 fail. Each retries, loading the new version, but by the time they retry, another request has already incremented the version. The retry loop becomes a thundering herd. Throughput drops. Latency spikes. Your retry logic may not even converge.
The left column traces three retry rounds for 50 concurrent requests against one row. Each round, one request succeeds and the rest fail. With a 3-retry limit, only 3 of 50 requests succeed. The right column compares the three alternative approaches: pessimistic locking queues all 50 requests and all succeed; atomic UPDATE handles all 50 with no version tracking at all; jittered backoff improves on naive retry but still wastes ~60 attempts to serve ~40 requests.
The Evidence
@Entity
public class InventoryItem {
@Id
private Long id;
@Version
private Long version;
private String sku;
private int quantity;
public void decrementQuantity(int amount) {
if (this.quantity < amount) {
throw new InsufficientStockException(sku, quantity, amount);
}
this.quantity -= amount;
}
}
// When Hibernate flushes this entity:
// Generated SQL:
// update inventory_items
// set quantity = ?, version = ?
// where id = ? and version = ?
//
// If the version matches: 1 row updated, version incremented.
// If the version does not match: 0 rows updated.
// Hibernate sees 0 rows and throws OptimisticLockException.
// BAD: Naive retry loop
@Transactional
public void decrementStock(Long itemId, int amount) {
InventoryItem item = inventoryRepository.findById(itemId)
.orElseThrow();
item.decrementQuantity(amount);
// Flush happens at commit. If version mismatch: exception.
}
// Caller with naive retry:
public void processOrder(Long itemId, int amount) {
int maxRetries = 3;
for (int attempt = 0; attempt < maxRetries; attempt++) {
try {
decrementStock(itemId, amount);
return;
} catch (OptimisticLockException e) {
// Retry immediately.
// Problem: all 49 failed threads retry at the same time.
// They all read version N+1. One succeeds. 48 fail again.
}
}
throw new StockUpdateFailedException(itemId);
}
At 50 concurrent requests for the same SKU:
- Round 1: 50 requests load version 1. One succeeds. 49 throw
OptimisticLockException. - Round 2: 49 requests load version 2. One succeeds. 48 throw
OptimisticLockException. - Round 3: 48 requests load version 3. One succeeds. 47 throw
OptimisticLockException. - With 3 retries: most requests exhaust retries and fail permanently.
Total UPDATE attempts: 50 + 49 + 48 = 147 (for 3 rounds). Only 3 succeed.
The Fix
1. Retry with Jittered Backoff
// BETTER: Retry with jitter
@Service
public class InventoryService {
private static final int MAX_RETRIES = 5;
private static final long BASE_DELAY_MS = 50;
@Autowired
private InventoryRepository inventoryRepository;
@Autowired
private TransactionTemplate transactionTemplate;
public void decrementStock(Long itemId, int amount) {
for (int attempt = 0; attempt < MAX_RETRIES; attempt++) {
try {
transactionTemplate.executeWithoutResult(status -> {
InventoryItem item = inventoryRepository
.findById(itemId).orElseThrow();
item.decrementQuantity(amount);
});
return; // success
} catch (OptimisticLockException e) {
if (attempt == MAX_RETRIES - 1) {
throw new StockUpdateFailedException(itemId, e);
}
long delay = BASE_DELAY_MS * (1L << attempt)
+ ThreadLocalRandom.current().nextLong(
BASE_DELAY_MS);
try {
Thread.sleep(delay);
} catch (InterruptedException ie) {
Thread.currentThread().interrupt();
throw new StockUpdateFailedException(itemId, ie);
}
}
}
}
}
The jitter spreads retries across time, reducing collision probability. The exponential backoff increases delay with each attempt: 50-100ms, 100-200ms, 200-400ms, 400-800ms, 800-1600ms.
Note: @Transactional on the method does not work for retries. The transaction is already marked for rollback when OptimisticLockException fires. You need a new transaction per attempt. TransactionTemplate or REQUIRES_NEW propagation handles this.
2. Pessimistic Locking for High Contention
When contention is predictable and high, pessimistic locking is more efficient than optimistic + retry.
// BETTER: Pessimistic locking for high-contention updates
public interface InventoryRepository
extends JpaRepository<InventoryItem, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT i FROM InventoryItem i WHERE i.id = :id")
Optional<InventoryItem> findByIdForUpdate(@Param("id") Long id);
}
// Generated SQL:
// select ... from inventory_items where id = ? for update
// Usage:
@Transactional
public void decrementStock(Long itemId, int amount) {
InventoryItem item = inventoryRepository.findByIdForUpdate(itemId)
.orElseThrow();
item.decrementQuantity(amount);
// No OptimisticLockException possible.
// The row is locked. Other transactions wait.
}
With 50 concurrent requests: all 50 queue at the database level. Each executes sequentially. Total UPDATE attempts: 50. All 50 succeed (assuming sufficient stock). Latency is higher per request (waiting for the lock), but throughput is higher overall (no wasted retry work).
3. Atomic Update (Skip Hibernate Entirely)
For counters and quantities where you do not need to load the entity:
// BETTER: Atomic update, no entity loading
public interface InventoryRepository
extends JpaRepository<InventoryItem, Long> {
@Modifying
@Query("""
UPDATE InventoryItem i
SET i.quantity = i.quantity - :amount,
i.version = i.version + 1
WHERE i.id = :id AND i.quantity >= :amount
""")
int decrementQuantity(@Param("id") Long id,
@Param("amount") int amount);
}
// Generated SQL:
// update inventory_items
// set quantity = quantity - ?, version = version + 1
// where id = ? and quantity >= ?
//
// Returns 1 if updated, 0 if insufficient stock.
// No entity loaded. No persistence context. No version conflict.
// The database handles atomicity.
// Usage:
@Transactional
public void decrementStock(Long itemId, int amount) {
int updated = inventoryRepository.decrementQuantity(itemId, amount);
if (updated == 0) {
throw new InsufficientStockException(itemId, amount);
}
}
This approach bypasses optimistic locking entirely. The database’s built-in row-level locking ensures atomicity. The quantity >= :amount check happens atomically with the decrement. No load-check-update race condition.
The Cost Model
| Approach | 50 Concurrent Updates | Total DB Operations | Success Rate | Avg Latency |
|---|---|---|---|---|
| Optimistic, no retry | 50 attempts | 50 | 2% (1 of 50) | Low |
| Optimistic, 3 retries | ~147 attempts | ~147 | ~6% per round | Low-Medium |
| Optimistic, jittered backoff (5 retries) | ~100 attempts | ~100 | ~80% | Medium |
| Pessimistic (FOR UPDATE) | 50 attempts | 50 | 100% | High (queued) |
| Atomic update | 50 attempts | 50 | 100% (if stock available) | Low |
Use optimistic locking when contention is rare (user editing a profile, updating a document). Use pessimistic locking when contention is predictable (inventory, seat reservation, auction bidding). Use atomic updates when the operation can be expressed as a single SQL statement without loading the entity.