💾 Section 16 · Question #17

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

  1. During Read: The entity is loaded alongside its current version number (SELECT id, balance, version FROM bank_accounts WHERE id = 1). Assume version = 1.
  2. In-Memory Modification: The application modifies the account balance in JVM heap memory.
  3. During Persistence (flush / commit): Hibernate generates an SQL UPDATE statement that includes the initial version in the WHERE clause while incrementing the version by 1:
    UPDATE bank_accounts 
    SET balance = ?, version = version + 1 
    WHERE id = ? AND version = ?;
    
  4. Concurrent Conflict Detection: If a concurrent transaction committed changes first, the database table already holds version = 2. The subsequent statement with WHERE id = 1 AND version = 1 updates 0 rows (updateCount == 0).
  5. Hibernate’s Response: Detecting an update count of 0, Hibernate immediately throws jakarta.persistence.OptimisticLockException (wrapped by Spring as ObjectOptimisticLockingFailureException), 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 SQL WHERE clause:
    UPDATE legacy_contracts 
    SET amount = ? 
    WHERE id = ? AND amount = ?; -- Verifies original value from loadedState snapshot
    
  • OptimisticLockType.ALL: Appends all mapped columns into the WHERE clause (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:

  1. By default, modifying a @OneToMany collection DOES NOT increment the parent entity’s @Version.
  2. The addition only alters the child table order_items via an SQL INSERT. Because no columns in the orders table are modified, Hibernate generates no SQL UPDATE for Order.
  3. This creates a concurrency hazard: two concurrent transactions can simultaneously add distinct items to the same order without either triggering an OptimisticLockException.
  4. The Solution: Load the parent entity using LockModeType.OPTIMISTIC_FORCE_INCREMENT or explicitly call entityManager.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:

  1. Clock Precision Inconsistencies: Different relational database engines store timestamps with varying precision (some truncate to milliseconds, while JVM instances store nanoseconds).
  2. 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.
  3. NTP Clock Skew: Virtualized servers synchronizing clocks via NTP can occasionally step time backwards.
  4. Standard Practice: Always use integer numeric types (Long or Integer) 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:

  1. Categorically NO. The OptimisticLockException is thrown during the persistence context flush() phase.
  2. When a flush fails, the active PersistenceContext is left in an uncoordinated, corrupted state, and the active transaction is marked as Rollback-Only.
  3. Any subsequent attempt to query or persist entities within that same method will fail with: TransactionSystemException: Transaction marked as rollback-only.
  4. Architectural Rule: Optimistic lock handling and retry logic must always reside outside the transactional method (e.g., using Spring’s @Retryable or an outer service orchestrator).

4. How does optimistic locking interact with JDBC Batching (hibernate.jdbc.batch_size)?

Answer:

  1. During JDBC batching, the driver executes grouped statements via PreparedStatement.executeBatch(), which returns an array of updated row counts: int[] updateCounts.
  2. Hibernate iterates through this array: if any entry equals 0 (or fails to match expected update counts), Hibernate immediately aborts the batch and throws StaleObjectStateException / OptimisticLockException.
  3. However, with GenerationType.IDENTITY, batching is disabled by default for inserts, so using GenerationType.SEQUENCE is 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 @Version annotation on a numeric field (Long or Integer). On every SQL UPDATE, Hibernate appends WHERE id = ? AND version = ? and increments the version number. If another transaction updated the row first, 0 rows are affected, causing Hibernate to throw an OptimisticLockException. 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 via OPTIMISTIC_FORCE_INCREMENT, and stateless web requests are protected via HTTP ETag and If-Match headers.”

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 @Retryable operations 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 Timestamp for @Version because it tracks the time of modification.” — Millisecond collision risks make timestamps unreliable for versioning; use Long or Integer.
  • ❌ “Catching OptimisticLockException inside @Transactional allows you to retry in the same method.” — The session is flagged as Rollback-Only; retries must happen in a fresh transaction.
  • ❌ “Adding an element to a @OneToMany collection automatically increments the parent @Version.” — Only child tables are inserted; parent versions require explicit OPTIMISTIC_FORCE_INCREMENT.