Transactions, Caching & Performance

Optimistic Locking with @Version

18 min Lesson 5 of 13

Optimistic Locking with @Version

Every multi-user application eventually faces the lost-update problem: two transactions read the same row, both modify it independently, and the second write silently overwrites the first. Optimistic locking is the lightweight, scalable answer for workloads where conflicts are infrequent. Rather than acquiring a database lock when data is read, it detects collisions at commit time and lets the application decide what to do.

The Core Idea: Version Columns

Optimistic locking works by stamping each row with a version token. JPA / Hibernate manages this automatically when you annotate a field with @Version:

import jakarta.persistence.*; @Entity @Table(name = "orders") public class Order { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Version private int version; // managed entirely by Hibernate private String status; private BigDecimal total; // getters / setters ... }

You never read or write the version field yourself. Hibernate reads its value on SELECT, and when it issues the UPDATE it adds a predicate on the version:

-- what Hibernate generates for a save: UPDATE orders SET status = 'SHIPPED', version = 2 -- increment by 1 WHERE id = 42 AND version = 1; -- fail-safe: was it still 1?

If the WHERE clause matches zero rows — because another transaction already bumped the version to 2 — Hibernate throws jakarta.persistence.OptimisticLockException, which Spring wraps as org.springframework.orm.ObjectOptimisticLockingFailureException.

Choosing the Right Version Field Type

The @Version annotation supports several Java types:

  • int / Integer — increments by 1; simplest choice for most entities.
  • long / Long — use when an entity is updated very frequently and you worry about integer overflow (unlikely but possible over decades).
  • short / Short — rarely used; small range.
  • java.sql.Timestamp — Hibernate sets it to the current DB timestamp on each update. Avoid this on clustered or cloud deployments where clock skew between nodes can cause false conflicts or missed conflicts.
Prefer numeric versions. Integer or Long are deterministic, database-agnostic, and immune to clock drift. Use Long if the entity is a hot row updated many times per second; otherwise int is fine.

Handling OptimisticLockException in a Service

The standard pattern is to catch the conflict exception and retry the operation a bounded number of times:

import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service public class OrderService { private final OrderRepository orderRepository; public OrderService(OrderRepository orderRepository) { this.orderRepository = orderRepository; } @Retryable( retryFor = OptimisticLockingFailureException.class, maxAttempts = 3, backoff = @Backoff(delay = 50, multiplier = 2) ) @Transactional public void shipOrder(Long orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new EntityNotFoundException("Order " + orderId)); if (!order.getStatus().equals("PAID")) { throw new IllegalStateException("Cannot ship unpaid order"); } order.setStatus("SHIPPED"); // Hibernate flushes here; version mismatch throws OptimisticLockingFailureException } }

@Retryable comes from Spring Retry (spring-retry on the classpath + @EnableRetry on a config class). The exponential backoff — 50 ms, 100 ms, 200 ms — reduces thundering-herd when many threads collide on the same row.

Re-read inside the retry. The entire @Transactional method reruns, which means the entity is re-fetched with the latest version. Never cache the entity outside the transactional boundary and reuse it across retries — that defeats the whole mechanism.

Optimistic Locking in Spring Data JPA

Spring Data REST and custom repository methods respect @Version automatically. For query methods you may also declare @Lock with the optimistic mode:

import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import jakarta.persistence.LockModeType; public interface OrderRepository extends JpaRepository<Order, Long> { // Default save() already honours @Version — no extra annotation needed. // Explicit optimistic lock — useful if you want to be explicit in a query method: @Lock(LockModeType.OPTIMISTIC) Optional<Order> findWithLockById(Long id); // OPTIMISTIC_FORCE_INCREMENT bumps the version even on a read, // protecting aggregate roots from child-entity phantom writes: @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) Optional<Order> findForAggregateUpdate(Long id); }

OPTIMISTIC_FORCE_INCREMENT is the right choice when an entity acts as an aggregate root: loading a parent and modifying only a child entity would not normally update the parent's version, leaving the aggregate unprotected. Force-incrementing the root ensures any concurrent modification of the aggregate is detected.

What Optimistic Locking Does NOT Protect Against

Optimistic locking prevents the lost update anomaly within a single JPA unit of work. It does not:

  • Prevent two transactions from reading stale data simultaneously — the conflict is only detected at commit time.
  • Help when a row is modified outside JPA (raw SQL, another service) unless those tools also update the version column.
  • Solve contention problems in high-conflict scenarios. If the same row is updated dozens of times per second, most retries will fail and the overhead increases. Use pessimistic locking (lesson 6) for hot rows with guaranteed conflicts.
Never expose the version field in a public API without a strategy. If a REST client submits a stale version, it will get a 409 Conflict or a 500 unless your controller maps OptimisticLockingFailureException to the appropriate HTTP response. Always add a global exception handler or @ExceptionHandler method for this exception.

Performance Characteristics

Optimistic locking is extremely cheap when conflicts are rare:

  • No lock held between read and write — the row is never blocked, so readers and writers can proceed concurrently without waiting on each other.
  • Extra WHERE clause — the only overhead is one additional integer comparison in the UPDATE predicate, which is negligible.
  • Extra column — a 4-byte INT column per table; add an index only if you ever query by version (rarely useful).
  • Retry cost — if conflicts are frequent, retry loops add round-trips. Profile your conflict rate; a collision rate above ~5% is a signal to reconsider the locking strategy.

Summary

Add @Version to any entity that might be updated concurrently. Hibernate handles the version increment and collision detection automatically — your only responsibility is to catch OptimisticLockingFailureException and retry or surface a meaningful error to the caller. For aggregate roots, use OPTIMISTIC_FORCE_INCREMENT to guard child-entity changes. Optimistic locking is the right default for most CRUD workloads; switch to pessimistic locks only when profiling reveals that conflicts are so frequent that retries are hurting throughput.