Optimistic Locking with @Version
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:
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:
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.
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:
@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.
@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:
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.
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
INTcolumn 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.