Back to Locking: Optimistic & Pessimistic
Q3Question 3 of 4

How does @Version optimistic locking work internally?

Key Concepts
  • @Version stores a version number that Hibernate increments on every update and includes in the UPDATE WHERE clause.
  • Zero affected rows from the UPDATE means a concurrent modification occurred, triggering OptimisticLockException.
  • Spring wraps JPA's OptimisticLockException as ObjectOptimisticLockingFailureException.
  • Correct retry means starting a fresh transaction, reloading the entity, reapplying the change, and committing.
  • The retry wrapper must sit OUTSIDE the transactional boundary so each attempt gets a new transaction.
  • A numeric version is safer than a timestamp version for money because timestamps can miss same-millisecond conflicts.
  • OPTIMISTIC_FORCE_INCREMENT bumps the version even on read, useful for aggregate root locking when a child changes.

Definition

@Version adds a WHERE version=? predicate to every UPDATE so that zero rows affected means a concurrent change, and the standard remedy is a bounded retry that reloads the entity in a fresh transaction.
  • @Version -- a field (int, long, short, or timestamp) on the entity that Hibernate manages automatically, incrementing it on every update.
  • Version check -- Hibernate appends WHERE version = :oldVersion to every UPDATE and sets the new version to old + 1.
  • Zero affected rows -- if the UPDATE matches no rows, the row was modified concurrently and Hibernate throws OptimisticLockException.
  • ObjectOptimisticLockingFailureException -- Spring's wrapper around JPA's OptimisticLockException, used in Spring Data applications.
  • Bounded retry -- the standard recovery pattern: reload the entity in a fresh transaction, reapply the business change, and commit again, up to a maximum number of attempts.
  • OPTIMISTIC_FORCE_INCREMENT -- a lock mode that bumps the version even when the entity is only read, useful for protecting aggregate roots.

The @Version number is like a tamper seal on Rajesh's account file at National Bank of India. Every time anyone updates the file, they must break the current seal and apply a new one with the next number. When a clerk goes to save, the system checks "is the seal still the one you started with?" If someone else already resealed it, the save is rejected with "the file changed, start over." The clerk reloads the fresh file, redoes the edit, and saves again.

Understanding the Concepts

What is @Version and how does it work?

You declare @Version private Long version; on the entity. Hibernate manages it entirely: you never set it manually. On insert it starts at 0. On every update that changes a non-version field, Hibernate generates an UPDATE with the version in both the SET clause (new version) and the WHERE clause (old version).

JAVA
package com.bank.model;

import jakarta.persistence.*;
import java.math.BigDecimal;

@Entity
@Table(name = "accounts")
public class Account {

    @Id
    @Column(name = "account_id")
    private String accountId;

    @Column(name = "holder_name")
    private String holderName;

    @Column(precision = 15, scale = 2)
    private BigDecimal balance;

    @Version
    private Long version;  // starts at 0, auto-incremented

    // getters and setters
    // NEVER set version manually!
}
TEXT
INSERT:
  INSERT INTO accounts (account_id, holder_name, balance, version)
  VALUES ('SAV2001', 'Rajesh Kumar', 50000.00, 0)

UPDATE:
  UPDATE accounts
  SET balance = 55000.00, version = 6
  WHERE account_id = 'SAV2001' AND version = 5
  -- If 1 row affected: success, version is now 6
  -- If 0 rows affected: stale write, throw OptimisticLockException

What happens when two transactions conflict?

When two transactions read the same version and both try to update, only one succeeds. The other's UPDATE matches zero rows because the version has already been incremented.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class ConflictDemoService {

    private final AccountRepository repo;

    public ConflictDemoService(AccountRepository repo) {
        this.repo = repo;
    }

    @Transactional
    public void creditSalary(String accountId) {
        Account account = repo.findById(accountId).orElseThrow();
        // Reads version=5, balance=100000
        account.setBalance(account.getBalance()
            .add(new BigDecimal("50000.00")));
        repo.save(account);
        // UPDATE SET balance=150000, version=6
        //   WHERE account_id='SAV2001' AND version=5
    }

    @Transactional
    public void debitEMI(String accountId) {
        Account account = repo.findById(accountId).orElseThrow();
        // Also reads version=5, balance=100000
        account.setBalance(account.getBalance()
            .subtract(new BigDecimal("20000.00")));
        repo.save(account);
        // UPDATE SET balance=80000, version=6
        //   WHERE account_id='SAV2001' AND version=5
        // If salary committed first: 0 rows affected -> exception!
    }
}
TEXT
Conflict timeline:
  T1: creditSalary reads version=5, balance=100000
  T2: debitEMI reads version=5, balance=100000
  T3: creditSalary UPDATE WHERE version=5 -> 1 row. Version=6.
  T4: debitEMI UPDATE WHERE version=5 -> 0 rows!
      Hibernate throws OptimisticLockException
      Spring wraps as ObjectOptimisticLockingFailureException

The EMI debit must be retried:
  T5: Reload: balance=150000 (salary applied), version=6
  T6: Apply EMI: 150000 - 20000 = 130000
  T7: UPDATE WHERE version=6 -> 1 row. Version=7.
  Final balance: 130000 (correct!)

How do you implement the retry pattern?

The retry wrapper must sit OUTSIDE the transactional boundary. Each retry attempt must run in a fresh transaction because once a transaction has rolled back, you cannot continue working inside it. The retry method reloads the entity (getting the current version), reapplies the business logic, and commits.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class RetryableDebitService {

    private final AccountRepository repo;

    public RetryableDebitService(AccountRepository repo) {
        this.repo = repo;
    }

    // Retry wrapper: NOT transactional (sits outside TX boundary)
    public void debitWithRetry(String accountId, BigDecimal amount) {
        int maxRetries = 3;
        for (int attempt = 1; attempt <= maxRetries; attempt++) {
            try {
                doDebit(accountId, amount);
                return; // success
            } catch (ObjectOptimisticLockingFailureException e) {
                if (attempt == maxRetries) {
                    throw new RuntimeException(
                        "Failed after " + maxRetries + " retries", e);
                }
                // Retry: next iteration starts a fresh transaction
            }
        }
    }

    // Each attempt runs in its own transaction
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void doDebit(String accountId, BigDecimal amount) {
        Account account = repo.findById(accountId).orElseThrow();
        if (account.getBalance().compareTo(amount) < 0) {
            throw new IllegalStateException("Insufficient balance");
        }
        account.setBalance(account.getBalance().subtract(amount));
        repo.save(account);
    }
}
TEXT
Retry flow:
  Attempt 1: doDebit() starts new TX, reads version=5
    -> version conflict! TX rolls back.
  Attempt 2: doDebit() starts new TX, reads version=6 (fresh)
    -> success! Balance updated, version=7.

How do you use Spring Retry for cleaner retry logic?

Spring Retry provides @Retryable for declarative retry handling, which is cleaner than a manual loop. The retry method must be in a separate bean or the same bean called through the proxy.

JAVA
package com.bank.service;

import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;

@Service
public class SpringRetryDebitService {

    private final AccountRepository repo;

    public SpringRetryDebitService(AccountRepository repo) {
        this.repo = repo;
    }

    @Retryable(
        retryFor = ObjectOptimisticLockingFailureException.class,
        maxAttempts = 3,
        backoff = @Backoff(delay = 100)
    )
    @Transactional
    public void debit(String accountId, BigDecimal amount) {
        Account account = repo.findById(accountId).orElseThrow();
        account.setBalance(account.getBalance().subtract(amount));
        repo.save(account);
    }
    // Spring Retry catches the exception and retries up to 3 times
    // with 100ms delay between attempts.
    // Each retry opens a fresh transaction (TX rolled back on exception).
}

What are common mistakes with @Version retries?

The most common mistakes involve retrying inside the same transaction and reusing the stale entity object.

JAVA
// WRONG: retry inside the same transaction
@Transactional
public void debitWithRetry(String accountId, BigDecimal amount) {
    int retries = 3;
    while (retries > 0) {
        try {
            Account account = repo.findById(accountId).orElseThrow();
            account.setBalance(account.getBalance().subtract(amount));
            repo.save(account);
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            retries--;
            // Still inside the SAME transaction!
            // Transaction is already marked for rollback!
            // The retry will fail with TransactionRequiredException
        }
    }
}

// CORRECT: retry wrapper outside the transaction
public void debitWithRetry(String accountId, BigDecimal amount) {
    // Non-transactional wrapper
    for (int i = 0; i < 3; i++) {
        try {
            doDebit(accountId, amount); // @Transactional(REQUIRES_NEW)
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            if (i == 2) throw e;
        }
    }
}
JAVA
// WRONG: reusing the stale entity on retry
Account stale = repo.findById(accountId).orElseThrow();
for (int i = 0; i < 3; i++) {
    try {
        stale.setBalance(stale.getBalance().subtract(amount));
        repo.save(stale);
        break;
    } catch (ObjectOptimisticLockingFailureException e) {
        // stale still has version=5! Same conflict on every retry!
    }
}

// CORRECT: reload fresh entity on each retry
for (int i = 0; i < 3; i++) {
    try {
        Account fresh = repo.findById(accountId).orElseThrow();
        fresh.setBalance(fresh.getBalance().subtract(amount));
        repo.save(fresh);
        break;
    } catch (ObjectOptimisticLockingFailureException e) {
        if (i == 2) throw e;
    }
}

What about numeric version vs timestamp version?

A numeric @Version (int, long) is safer than a timestamp @Version for financial operations. Timestamps have limited resolution and can miss same-millisecond conflicts.

JAVA
// Numeric version (RECOMMENDED for banking):
@Version
private Long version;  // 0, 1, 2, 3, ...
// Every update increments by exactly 1.
// Two updates in the same millisecond: version changes from 5 to 6.
// The second update sees version=6, not 5. Conflict detected.

// Timestamp version (AVOID for money):
@Version
private Instant lastModified;
// Resolution: millisecond (or microsecond on some DBs).
// Two updates in the same millisecond may produce the same timestamp.
// Both read lastModified='2024-01-15T10:30:00.123Z'
// Both write lastModified='2024-01-15T10:30:00.456Z'
// One succeeds, but if clocks are not perfectly synchronized,
// the second may not detect the conflict.

What is OPTIMISTIC_FORCE_INCREMENT?

OPTIMISTIC_FORCE_INCREMENT bumps the version even when the entity is only read, not modified. This is useful for aggregate root locking: when a child entity changes, you want stale reads of the parent to fail.

JAVA
package com.bank.service;

import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.LockModeType;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Service
public class AggregateRootLockService {

    @PersistenceContext
    private EntityManager em;

    @Transactional
    public void addBeneficiary(String accountId, String beneficiary) {
        Account account = em.find(Account.class, accountId,
            LockModeType.OPTIMISTIC_FORCE_INCREMENT);
        // Bumps account.version even though Account itself is not modified
        // Purpose: if another transaction read this Account with version=5
        // and we bump to version=6, the other transaction's save fails.
        // Protects the aggregate (Account + Beneficiaries) as a whole.

        // Add beneficiary to child table...
    }
}
TEXT
OPTIMISTIC_FORCE_INCREMENT:
  Transaction A reads Account version=5 (for display)
  Transaction B adds a beneficiary, force-increments version to 6
  Transaction A tries to save: WHERE version=5 -> 0 rows!
  Conflict detected even though Account fields did not change.
  The child (beneficiary) change protected the whole aggregate.

What should a senior engineer know?

Senior engineers should understand the monitoring, tuning, and escalation aspects of optimistic locking in production.

  • Monitor the retry rate per entity or per account. A sustained high retry rate signals the need to escalate to pessimistic locking.
  • Cap retries to prevent infinite loops. Three retries is a common default.
  • Emit metrics (e.g., Micrometer counter) on each OptimisticLockException to track contention.
  • Consider escalating to pessimistic locking or atomic conditional UPDATE for rows that consistently cause retries.
  • @Version is not suitable for read-only queries. Use @Transactional(readOnly = true) for reads.
JAVA
// Monitoring optimistic lock retries:
private final Counter retryCounter = Metrics.counter(
    "jpa.optimistic.retries", "entity", "Account");

public void debitWithRetry(String accountId, BigDecimal amount) {
    for (int i = 0; i < 3; i++) {
        try {
            doDebit(accountId, amount);
            return;
        } catch (ObjectOptimisticLockingFailureException e) {
            retryCounter.increment();
            if (i == 2) throw e;
        }
    }
}
// Alert when retryCounter.rate() > threshold
// -> escalate that account to pessimistic locking

Visual Overview

DIAGRAM
  @Version UPDATE flow:
  ┌─────────────────────────────────────────────────────────┐
  │  Entity loaded: version = 5                              │
  │                                                          │
  │  Hibernate generates:                                    │
  │  UPDATE accounts                                         │
  │  SET balance = 150000.00, version = 6                    │
  │  WHERE account_id = 'SAV2001' AND version = 5            │
  │                                                          │
  │  ┌──────────────────────────┐                            │
  │  │ Rows affected = 1       │ -> Success! Version = 6     │
  │  │ Rows affected = 0       │ -> OptimisticLockException  │
  │  └──────────────────────────┘                            │
  └─────────────────────────────────────────────────────────┘

  Retry flow (correct):
  ┌──────────────┐
  │ Retry wrapper │  (NOT transactional)
  │ (3 attempts)  │
  └──────┬───────┘
         │
         ├── Attempt 1: @Transactional(REQUIRES_NEW)
         │   Load version=5 -> conflict -> rollback
         │
         ├── Attempt 2: @Transactional(REQUIRES_NEW)
         │   Load version=6 -> success -> commit
         │
         └── (done)

  Retry flow (WRONG):
  ┌─────────────────────────────┐
  │ @Transactional              │
  │ Retry inside same TX        │
  │ -> TX marked for rollback   │
  │ -> retry CANNOT work        │
  └─────────────────────────────┘

Key Takeaway

The @Version field is how JPA implements optimistic locking, and Hibernate manages it completely without any manual intervention. On insert it starts at zero, and on every update Hibernate generates an UPDATE whose WHERE clause includes the version originally read, while setting the new version to old plus one. If a concurrent transaction already changed the row, the UPDATE matches zero rows and Hibernate throws OptimisticLockException, which Spring surfaces as ObjectOptimisticLockingFailureException.

Handling the exception correctly means a bounded retry that reloads the fresh state and reapplies the change. The critical detail that trips developers is transaction boundaries: the retry wrapper must sit outside the transactional method, and each attempt must run in a new transaction, because once a transaction rolls back you cannot keep working inside it. Each retry reloads the entity with the current version and reapplies the business logic.

In our National Bank of India system, when Rajesh's salary credit and loan EMI debit arrive at the same instant, both read the same version. The first commits and bumps the version. The second fails with a version conflict and rolls back. The retry transparently reloads the current balance and version, reapplies the debit, and commits, so both operations are ultimately applied and nothing is silently lost. A cap of three retries prevents infinite loops on permanently hot rows.

Remember one sentence: @Version adds WHERE version=? to every UPDATE so zero rows affected means a concurrent change, and the correct retry reloads the entity in a fresh transaction because the rolled-back transaction cannot be reused.

---

Share
Use ← → arrow keys to navigate