Optimistic and Pessimistic Locking: Handling Concurrent Updates
The Lost Update Problem
Two users edit the same product simultaneously:
Time User A User B
T1 Load product (price=999) Load product (price=999)
T2 Change price to 899
T3 Change price to 799
T4 Save → UPDATE price=899
T5 Save → UPDATE price=799
User A’s change is silently overwritten. This is the lost update problem. Both JPA locking strategies prevent it — in different ways.
Optimistic Locking with @Version
Optimistic locking assumes conflicts are rare. It uses a version counter to detect concurrent modifications:
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private BigDecimal price;
@Version
private Long version; // Hibernate manages this automatically
}
How It Works
When Hibernate generates an UPDATE, it includes the version in the WHERE clause:
UPDATE products
SET name=?, price=?, version=2
WHERE id=? AND version=1 -- checks the version hasn't changed
If the row’s version in the database no longer matches (another transaction updated it), the UPDATE affects 0 rows. Hibernate detects this and throws OptimisticLockException.
User A loads product (version=1)
User B loads product (version=1)
User A saves → UPDATE ... WHERE version=1 → affects 1 row → success, version becomes 2
User B saves → UPDATE ... WHERE version=1 → affects 0 rows → OptimisticLockException
User B gets an exception instead of silently overwriting User A’s change.
@Version Field Types
@Version
private Long version; // long counter — most common
@Version
private Integer version; // int counter
@Version
private Timestamp version; // timestamp — less reliable (millisecond collisions)
Use Long or Integer. Avoid Timestamp — two transactions completing within the same millisecond would not be detected.
Handling OptimisticLockException
@Service
public class ProductService {
@Retryable(
retryFor = OptimisticLockingFailureException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public Product updatePrice(Long productId, BigDecimal newPrice) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
product.setPrice(newPrice);
return productRepository.save(product);
}
}
@Retryable (from Spring Retry) automatically retries on OptimisticLockingFailureException (Spring’s wrapper around JPA’s OptimisticLockException). Each retry re-reads the entity with the latest version.
Enable Spring Retry:
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>
@Configuration
@EnableRetry
public class RetryConfig {}
REST API Pattern with Optimistic Locking
Return the version field in API responses and require it on update requests:
public record ProductUpdateRequest(
String name,
BigDecimal price,
Long version // client sends the version it read
) {}
@PutMapping("/{id}")
public ProductDto updateProduct(
@PathVariable Long id,
@RequestBody ProductUpdateRequest request
) {
try {
return productService.update(id, request);
} catch (OptimisticLockingFailureException e) {
throw new ResponseStatusException(
HttpStatus.CONFLICT,
"Product was modified by another user. Please reload and try again."
);
}
}
The client receives a 409 Conflict and can show a “reload and try again” message.
Pessimistic Locking
Pessimistic locking acquires a database-level lock when reading, preventing other transactions from modifying the row until the lock is released:
@Repository
public interface ProductRepository extends JpaRepository<Product, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findById(Long id);
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForRead(@Param("id") Long id);
}
-- PESSIMISTIC_WRITE generates:
SELECT * FROM products WHERE id = ? FOR UPDATE
-- PESSIMISTIC_READ generates:
SELECT * FROM products WHERE id = ? FOR SHARE -- (MySQL) or LOCK IN SHARE MODE
Lock Mode Types
| LockModeType | SQL | Behavior |
|---|---|---|
PESSIMISTIC_WRITE | FOR UPDATE | Exclusive lock — no other reads or writes |
PESSIMISTIC_READ | FOR SHARE | Shared lock — other reads allowed, writes blocked |
PESSIMISTIC_FORCE_INCREMENT | FOR UPDATE | Exclusive lock + increments @Version |
PESSIMISTIC_WRITE is the most common — use it when you’re about to modify the row and want no concurrent modifications.
Using Pessimistic Locking in a Service
@Service
public class InventoryService {
@Transactional
public void reserveStock(Long productId, int quantity) {
// Lock the row before reading stock
Product product = productRepository.findById(productId) // uses @Lock(PESSIMISTIC_WRITE)
.orElseThrow(() -> new EntityNotFoundException("Product not found"));
if (product.getStockQuantity() < quantity) {
throw new InsufficientStockException("Not enough stock");
}
product.setStockQuantity(product.getStockQuantity() - quantity);
// Lock released when transaction commits
}
}
Only one transaction can hold the FOR UPDATE lock at a time. Concurrent calls to reserveStock for the same product are serialized — no race condition.
Lock Timeout
By default, a FOR UPDATE blocks indefinitely if another transaction holds the lock. Set a timeout:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")) // ms
Optional<Product> findById(Long id);
Or globally:
spring:
jpa:
properties:
jakarta:
persistence:
lock:
timeout: 3000
If the lock is not acquired within the timeout, LockTimeoutException is thrown.
Optimistic vs Pessimistic: When to Use Each
| Optimistic | Pessimistic | |
|---|---|---|
| Assumption | Conflicts are rare | Conflicts are common |
| How | Version check at commit | Lock at read time |
| Concurrency | High — no locking overhead | Lower — blocks concurrent reads/writes |
| Deadlocks | Not possible | Possible |
| Transaction length | Works for long transactions | Bad for long transactions (holds lock) |
| Use when | Read-heavy, occasional updates | Inventory, seat booking, financial transfers |
Decision Guide
Use optimistic locking when:
- Users edit records in a UI (long think time between load and save)
- Conflicts are expected to be rare (most edits don’t collide)
- You want maximum concurrency
Use pessimistic locking when:
- Race conditions cannot be tolerated (stock reservation, seat booking, bank transfer)
- Conflicts are common (many workers competing for the same rows)
- Transaction is short (lock held only briefly)
Deadlock Prevention with Pessimistic Locking
When multiple resources are locked in different orders, deadlocks occur:
Transaction A locks Product 1, then tries to lock Product 2
Transaction B locks Product 2, then tries to lock Product 1
→ Deadlock
Fix: always acquire locks in a consistent order:
@Transactional
public void transfer(Long fromProductId, Long toProductId, int quantity) {
// Always lock lower ID first — prevents deadlock
Long firstId = Math.min(fromProductId, toProductId);
Long secondId = Math.max(fromProductId, toProductId);
Product first = productRepository.findById(firstId).orElseThrow(); // locks
Product second = productRepository.findById(secondId).orElseThrow(); // locks
Product from = fromProductId.equals(firstId) ? first : second;
Product to = fromProductId.equals(firstId) ? second : first;
from.setStockQuantity(from.getStockQuantity() - quantity);
to.setStockQuantity(to.getStockQuantity() + quantity);
}
Combining @Version with Explicit Locking
Use both together when you want optimistic locking as the default but need explicit locking for high-contention operations:
@Entity
public class Product {
@Version
private Long version; // optimistic locking for normal operations
}
// Normal update — optimistic locking (no SQL lock)
@Transactional
public Product updateDescription(Long id, String description) {
Product p = productRepository.findById(id).orElseThrow();
p.setDescription(description);
return p; // version increment on commit
}
// Stock operation — pessimistic locking (SQL FOR UPDATE)
@Transactional
public void deductStock(Long id, int qty) {
Product p = productRepository.findById(id) // @Lock(PESSIMISTIC_WRITE)
.orElseThrow();
if (p.getStockQuantity() < qty) throw new InsufficientStockException();
p.setStockQuantity(p.getStockQuantity() - qty);
}
PESSIMISTIC_FORCE_INCREMENT is useful here too — it acquires a pessimistic lock AND increments the version, so that any concurrent optimistic-locking transaction on the same entity will also detect the conflict.
Testing Concurrent Locking Behaviour
@SpringBootTest
class ConcurrencyTest {
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Test
void optimisticLock_shouldPreventLostUpdate() throws Exception {
Product product = productRepository.save(createProduct("Laptop", new BigDecimal("999")));
Long id = product.getId();
// Simulate two concurrent loads
Product userA = productRepository.findById(id).orElseThrow();
Product userB = productRepository.findById(id).orElseThrow();
// Both read version=0
assertThat(userA.getVersion()).isEqualTo(userB.getVersion());
// User A saves first
userA.setPrice(new BigDecimal("899"));
productRepository.save(userA); // version becomes 1
// User B tries to save with stale version=0
userB.setPrice(new BigDecimal("799"));
assertThatThrownBy(() -> productRepository.saveAndFlush(userB))
.isInstanceOf(ObjectOptimisticLockingFailureException.class);
}
}
Summary
- Optimistic locking (
@Version): Hibernate addsAND version=?to UPDATE statements. ThrowsOptimisticLockExceptionon conflict. Use for user-facing edits where conflicts are rare. Retry on conflict. - Pessimistic locking (
@Lock(PESSIMISTIC_WRITE)): AcquiresFOR UPDATEat read time. Concurrent transactions block. Use for inventory, bookings, financial operations where conflicts cannot be tolerated. - Set a lock timeout to prevent indefinite blocking in pessimistic scenarios.
- Prevent deadlocks by always acquiring locks in a consistent order.
- Return the
versionfield in REST APIs so clients can detect and report conflicts cleanly. - Combine both: use
@Versionas the default and pessimistic locking for high-contention code paths.
Next: Article 30 — the final article — covers testing Spring Data JPA with @DataJpaTest, Testcontainers, and test strategies for repositories, services, and transactional behaviour.