How to Implement Optimistic Locking in JPA
To activate optimistic locking in JPA, add a version property annotated with @jakarta.persistence.Version to the entity class:
Optimistic Locking is a concurrency control pattern based on the premise that conflicting concurrent writes are relatively infrequent. Rather than acquiring heavy physical row-level locks in the database upon reading (SELECT ... FOR UPDATE), optimistic locking allows multiple transactions to read and modify data concurrently, deferring conflict detection to the persistence phase via entity version validation.
🟢 Junior Level
How Optimistic Locking Works
To activate optimistic locking in JPA, add a version property annotated with @jakarta.persistence.Version to the entity class:
@Entity
@Table(name = "bank_accounts")
public class BankAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private BigDecimal balance;
@Version
private Long version; // Hibernate automatically manages and increments this field
}
SQL Execution Mechanics and Version Verification
- During Read: The entity is loaded alongside its current version number (
SELECT id, balance, version FROM bank_accounts WHERE id = 1). Assumeversion = 1. - In-Memory Modification: The application modifies the account balance in JVM heap memory.
- During Persistence (
flush/commit): Hibernate generates an SQLUPDATEstatement that includes the initial version in theWHEREclause while incrementing the version by 1:UPDATE bank_accounts SET balance = ?, version = version + 1 WHERE id = ? AND version = ?; - Concurrent Conflict Detection: If a concurrent transaction committed changes first, the database table already holds
version = 2. The subsequent statement withWHERE id = 1 AND version = 1updates 0 rows (updateCount == 0). - Hibernate’s Response: Detecting an update count of 0, Hibernate immediately throws
jakarta.persistence.OptimisticLockException(wrapped by Spring asObjectOptimisticLockingFailureException), preventing the Lost Update anomaly.
CONCURRENT VERSION CONFLICT TIMELINE
Transaction A (Thread 1) Transaction B (Thread 2)
│ │
SELECT version = 1 SELECT version = 1
balance = 100 balance = 100
│ │
balance = 150 balance = 200
│ │
UPDATE WHERE version = 1 │
(Success! DB version becomes 2) │
COMMIT │
UPDATE WHERE version = 1
(0 rows updated!)
│
❌ OptimisticLockException
🟡 Middle Level
Locking Modes: LockModeType.OPTIMISTIC vs OPTIMISTIC_FORCE_INCREMENT
In JPA and Spring Data JPA, optimistic locking behavior can be explicitly managed via entityManager.lock() or the @Lock annotation:
public interface AccountRepository extends JpaRepository<BankAccount, Long> {
@Lock(LockModeType.OPTIMISTIC)
Optional<BankAccount> findWithOptimisticLockById(Long id);
@Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT)
@Query("SELECT a FROM BankAccount a WHERE a.id = :id")
Optional<BankAccount> findAndForceIncrement(@Param("id") Long id);
}
| Lock Mode | Operational Behavior | Target Production Scenario |
|---|---|---|
OPTIMISTIC (formerly READ) |
Executes a verification query at commit time (SELECT version WHERE id = ?) to ensure the row was not altered by another transaction, even if the current transaction did not modify the entity. |
Guarding against dirty or non-repeatable reads when making critical decisions based on read-only reference data. |
OPTIMISTIC_FORCE_INCREMENT (formerly WRITE) |
Forces an unconditional version increment (version = version + 1) during transaction commit, even if scalar properties of the entity were untouched. |
Aggregate Root patterns where mutations occur inside child collections (@OneToMany), requiring the root entity’s version to be bumped. |
Automated Retry Blueprint (Spring Retry Pattern)
Because optimistic locking failures represent standard concurrent race conditions, applications should gracefully retry failed operations using fresh database state:
@Service
@RequiredArgsConstructor
public class PaymentService {
private final AccountRepository accountRepository;
@Retryable(
retryFor = { OptimisticLockException.class, ObjectOptimisticLockingFailureException.class },
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2)
)
@Transactional
public void deposit(Long accountId, BigDecimal amount) {
BankAccount account = accountRepository.findById(accountId)
.orElseThrow(() -> new EntityNotFoundException("Account not found: " + accountId));
account.setBalance(account.getBalance().add(amount));
// On conflict, Spring rolls back the failed transaction and transparently retries with fresh state
}
@Recover
public void handleConflict(OptimisticLockException ex, Long accountId, BigDecimal amount) {
log.error("Failed to deposit to account {} after 3 attempts due to contention", accountId);
throw new BusinessConflictException("Concurrent account update conflict. Please try again.");
}
}
🔴 Senior Level
Version-less Optimistic Locking (@OptimisticLocking)
In legacy databases where table schemas cannot be modified to introduce a dedicated version column, Hibernate provides a proprietary alternative using @OptimisticLocking paired with @DynamicUpdate:
@Entity
@Table(name = "legacy_contracts")
@org.hibernate.annotations.OptimisticLocking(type = OptimisticLockType.DIRTY)
@org.hibernate.annotations.DynamicUpdate
public class LegacyContract {
@Id
private Long id;
private String terms;
private BigDecimal amount;
}
OptimisticLockType.DIRTY: Injects the baseline values of only modified columns into the SQLWHEREclause:UPDATE legacy_contracts SET amount = ? WHERE id = ? AND amount = ?; -- Verifies original value from loadedState snapshotOptimisticLockType.ALL: Appends all mapped columns into theWHEREclause (WHERE id = ? AND terms = ? AND amount = ? ...), validating the entire table row.
Optimistic Locking Across REST APIs (User Think Time and ETag)
In modern distributed web systems, database transactions cannot remain open while a human user reviews a web form. To prevent Lost Updates across stateless HTTP requests, combine @Version with HTTP ETag and If-Match headers:
[ Web Browser Client ] [ REST API Backend Controller ]
│ │
│── GET /api/orders/1 ───────────────────────────────────>│
│<── 200 OK + Body { id: 1, ... } + ETag: "W/\"v3\"" ─────│ (ETag matches entity version 3)
│ │
[ User Thinks / Edits ] │
│ │
│── PUT /api/orders/1 + Header If-Match: "W/\"v3\"" ─────>│
│ │
│ Verify: if (!db.version.equals(clientVer))
│ throw PreconditionFailedException;
│<── 412 Precondition Failed ────────────────────────────│
@PutMapping("/orders/{id}")
@Transactional
public ResponseEntity<OrderDto> updateOrder(
@PathVariable Long id,
@RequestHeader(HttpHeaders.IF_MATCH) String ifMatch,
@RequestBody UpdateOrderRequest request) {
Long clientVersion = parseVersionFromEtag(ifMatch);
Order order = orderRepository.findById(id).orElseThrow();
if (!order.getVersion().equals(clientVersion)) {
// Detects the edit conflict BEFORE attempting any database mutations!
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
order.updateFrom(request);
return ResponseEntity.ok()
.eTag("\"v" + (order.getVersion() + 1) + "\"")
.body(OrderDto.from(order));
}
4 Tricky Questions
1. If we modify a child collection: order.getItems().add(newItem), will the @Version field on the parent Order increment or remain unchanged?
Answer:
- By default, modifying a
@OneToManycollection DOES NOT increment the parent entity’s@Version. - The addition only alters the child table
order_itemsvia an SQLINSERT. Because no columns in theorderstable are modified, Hibernate generates no SQLUPDATEforOrder. - This creates a concurrency hazard: two concurrent transactions can simultaneously add distinct items to the same order without either triggering an
OptimisticLockException. - The Solution: Load the parent entity using
LockModeType.OPTIMISTIC_FORCE_INCREMENTor explicitly callentityManager.lock(order, LockModeType.OPTIMISTIC_FORCE_INCREMENT)to guarantee the parent’s version increments on commit.
2. Why is using java.time.Instant or java.sql.Timestamp for @Version considered a dangerous anti-pattern in high-throughput systems?
Answer:
- Clock Precision Inconsistencies: Different relational database engines store timestamps with varying precision (some truncate to milliseconds, while JVM instances store nanoseconds).
- Collision Blindspots: If two concurrent transactions read, modify, and commit the entity within the exact same millisecond window, the timestamp will not change between commits. The second transaction will successfully overwrite the first, silently losing data without triggering an
OptimisticLockException. - NTP Clock Skew: Virtualized servers synchronizing clocks via NTP can occasionally step time backwards.
- Standard Practice: Always use integer numeric types (
LongorInteger) for@Version.
3. Can you catch OptimisticLockException in a try-catch block inside the @Transactional method and immediately retry the write operation in the same session?
Answer:
- Categorically NO. The
OptimisticLockExceptionis thrown during the persistence contextflush()phase. - When a flush fails, the active
PersistenceContextis left in an uncoordinated, corrupted state, and the active transaction is marked asRollback-Only. - Any subsequent attempt to query or persist entities within that same method will fail with:
TransactionSystemException: Transaction marked as rollback-only. - Architectural Rule: Optimistic lock handling and retry logic must always reside outside the transactional method (e.g., using Spring’s
@Retryableor an outer service orchestrator).
4. How does optimistic locking interact with JDBC Batching (hibernate.jdbc.batch_size)?
Answer:
- During JDBC batching, the driver executes grouped statements via
PreparedStatement.executeBatch(), which returns an array of updated row counts:int[] updateCounts. - Hibernate iterates through this array: if any entry equals
0(or fails to match expected update counts), Hibernate immediately aborts the batch and throwsStaleObjectStateException/OptimisticLockException. - However, with
GenerationType.IDENTITY, batching is disabled by default for inserts, so usingGenerationType.SEQUENCEis strongly recommended for high-performance batch updates with optimistic locking.
🎯 Interview Cheat Sheet
30-Second Elevator Pitch
“Optimistic Locking in JPA is implemented via the
@Versionannotation on a numeric field (LongorInteger). On every SQLUPDATE, Hibernate appendsWHERE id = ? AND version = ?and increments the version number. If another transaction updated the row first, 0 rows are affected, causing Hibernate to throw anOptimisticLockException. Unlike pessimistic locking, optimistic locking avoids holding physical locks during read operations, ensuring maximum throughput. In production, collisions are resolved using the Retry Pattern (Spring@Retryable), child collection mutations are guarded viaOPTIMISTIC_FORCE_INCREMENT, and stateless web requests are protected via HTTPETagandIf-Matchheaders.”
Key Production Metrics & Health Checks
- OptimisticLockException Rate: Percentage of transactions failing due to version conflicts (rates exceeding 1–2% suggest excessive contention that may require architectural partitioning or pessimistic locking).
- Retry Success Rate: Tracks the efficacy of
@Retryableoperations in resolving transient concurrency collisions.
Red Flags to Avoid
- ❌ “Optimistic locking issues an SQL
SELECT ... FOR UPDATE.” — That is pessimistic locking. Optimistic locking places zero physical database locks upon reading. - ❌ “I use
Timestampfor@Versionbecause it tracks the time of modification.” — Millisecond collision risks make timestamps unreliable for versioning; useLongorInteger. - ❌ “Catching
OptimisticLockExceptioninside@Transactionalallows you to retry in the same method.” — The session is flagged asRollback-Only; retries must happen in a fresh transaction. - ❌ “Adding an element to a
@OneToManycollection automatically increments the parent@Version.” — Only child tables are inserted; parent versions require explicitOPTIMISTIC_FORCE_INCREMENT.
Related Topics
- How to Implement Pessimistic Locking in JPA — Pessimistic locking comparison
- What is @Version and Why is It Needed — Version annotation mechanics
- How Does the Flush Mechanism Work in Hibernate — Flush and action queue
- What Does the refresh() Method Do — State reload