Optimistic Locking with @Version

Stop two users silently overwriting each other's changes by adding a @Version column and turning conflicts into 409 responses.

Syntax@Version private Long version; // conflict at flush/commit catch (OptimisticLockingFailureException e) { /* 409 */ }

Two people open the same product in an admin screen. Both change it and press save. Without any locking, the second save overwrites the first and nobody is told. This is the lost update problem, and transactions alone do not solve it, because the two saves happen in separate requests.

How @Version works

Add a numeric field annotated with jakarta.persistence.Version to the entity. Hibernate then adds the version to every update's WHERE clause and increments it:

update product set name = ?, price = ?, version = 4
where id = ? and version = 3

If another transaction has already moved the version to 4, the update matches zero rows. Hibernate raises an optimistic lock failure, Spring translates it to ObjectOptimisticLockingFailureException (a subclass of org.springframework.dao.OptimisticLockingFailureException), and the transaction rolls back. Depending on where the failure is translated you can also get JpaOptimisticLockingFailureException, so handle the parent type. No database locks are held between reading and writing, which is why it is called optimistic: it assumes conflicts are rare and detects them instead of preventing them.

Carrying the version through the API

The check only protects you if the version the user saw is the one compared. Return the version to the client, either in the response body or as an ETag header, and require it back on update. In the service, compare the incoming version with the loaded entity's and fail with a conflict if they differ. Setting the version field on a freshly loaded, managed entity does not work: Hibernate compares against the version it loaded, not the value you assign.

Handling the conflict

  • For a user editing a form, answer 409 Conflict (or 412 Precondition Failed when you used If-Match) and let them reload. Retrying automatically would just overwrite the other person's change.
  • For a background job, such as decrementing stock, retry the whole transaction a few times. The retry must start a fresh transaction, so it has to wrap the transactional method from the outside.

The exception is often thrown at flush or commit, after your method's last line, so a try/catch inside the transactional method will not see it. Catch it in the caller or in a @RestControllerAdvice.

Use Long or Integer for the version and never set it yourself on new entities. When conflicts are frequent on one hot row, optimistic locking turns into a stream of retries; a pessimistic lock with @Lock(LockModeType.PESSIMISTIC_WRITE), or an atomic UPDATE ... SET quantity = quantity - 1, fits better there.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;

@Entity
class Product {
    @Id
    private Long id;
    private String name;
    private BigDecimal price;

    @Version
    private Long version;

    protected Product() {
    }

    Long getVersion() {
        return version;
    }

    void update(String name, BigDecimal price) {
        this.name = name;
        this.price = price;
    }
}

interface ProductRepository extends JpaRepository<Product, Long> {
}

record UpdateProduct(String name, BigDecimal price, long version) {
}

class StaleVersionException extends RuntimeException {
    StaleVersionException(long id) {
        super("Product " + id + " was changed by someone else");
    }
}

@Service
class ProductService {

    private final ProductRepository products;

    ProductService(ProductRepository products) {
        this.products = products;
    }

    @Transactional
    public void update(long id, UpdateProduct request) {
        Product product = products.findById(id).orElseThrow();
        // Protects against a stale form: the user edited an old copy.
        if (product.getVersion() != request.version()) {
            throw new StaleVersionException(id);
        }
        // Hibernate's own check protects against a concurrent commit after this read.
        product.update(request.name(), request.price());
    }
}

@RestControllerAdvice
public class ConflictHandler {

    @ExceptionHandler({StaleVersionException.class, OptimisticLockingFailureException.class})
    ProblemDetail conflict(RuntimeException e) {
        ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.CONFLICT);
        problem.setTitle("Edit conflict");
        problem.setDetail("This record was changed by someone else. Reload it and try again.");
        return problem;
    }
}

When to use it

  • Stop two administrators editing the same product from silently overwriting each other's price change.
  • Reject a PUT carrying a stale If-Match header with 412 so a mobile client reloads before saving.
  • Retry a stock reservation job a few times when a concurrent order changed the same item.

More examples

ETag and If-Match on a REST endpoint

The version travels as an HTTP <code>ETag</code>, so the DTO stays free of persistence details and any HTTP client already knows how to send it back in <code>If-Match</code>. A mismatch is answered with <code>412 Precondition Failed</code>, the status HTTP defines for a failed conditional request. In a larger codebase the transactional part belongs in a service; it is on the controller here only to keep the example in one block.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Entity
class Article {
    @Id
    private Long id;
    private String body;

    @Version
    private Long version;

    protected Article() {
    }

    Long getId() { return id; }
    String getBody() { return body; }
    Long getVersion() { return version; }
    void setBody(String body) { this.body = body; }
}

interface ArticleRepository extends JpaRepository<Article, Long> {
}

record ArticleDto(Long id, String body) {
}

@RestController
@RequestMapping("/articles")
public class ArticleController {

    private final ArticleRepository articles;

    ArticleController(ArticleRepository articles) {
        this.articles = articles;
    }

    @GetMapping("/{id}")
    ResponseEntity<ArticleDto> get(@PathVariable long id) {
        Article a = articles.findById(id).orElseThrow();
        return ResponseEntity.ok()
                .eTag("\"" + a.getVersion() + "\"")
                .body(new ArticleDto(a.getId(), a.getBody()));
    }

    @PutMapping("/{id}")
    @Transactional
    public ResponseEntity<Void> update(@PathVariable long id,
                                       @RequestHeader("If-Match") String ifMatch,
                                       @RequestBody ArticleDto dto) {
        Article a = articles.findById(id).orElseThrow();
        if (!ifMatch.equals("\"" + a.getVersion() + "\"")) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
        }
        a.setBody(dto.body());
        return ResponseEntity.noContent().build();
    }
}

Retrying a background update with @Retryable

Spring Framework 7's <code>@Retryable</code> from <code>org.springframework.resilience.annotation</code> re-invokes the method when one of the listed exceptions is thrown. The retry sits on a different bean from the transaction on purpose: retrying inside a transaction that has already failed cannot succeed. The jitter spreads competing retries apart so they do not collide again on the same version.

Example · java
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Configuration
@EnableResilientMethods
class ResilienceConfig {
}

@Service
class StockWriter {

    @Transactional
    public void reserve(String sku, int quantity) {
        // load the versioned StockItem, check availability, subtract quantity
    }
}

@Service
public class StockReservations {

    private final StockWriter writer;

    StockReservations(StockWriter writer) {
        this.writer = writer;
    }

    // Each attempt calls through StockWriter's proxy, so each one is a fresh transaction.
    @Retryable(includes = OptimisticLockingFailureException.class,
               maxRetries = 3, delay = 50, jitter = 25, multiplier = 2.0)
    public void reserve(String sku, int quantity) {
        writer.reserve(sku, quantity);
    }
}

Pessimistic locking for a hot row

When hundreds of buyers hit the same row in the same second, optimistic locking means most of them fail and retry. <code>PESSIMISTIC_WRITE</code> issues <code>SELECT ... FOR UPDATE</code>, so buyers queue on the row lock and each one sees the true remaining count. Keep the transaction tiny and set a timeout, because every waiter holds a pooled connection while it queues.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.LockModeType;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class TicketPool {
    @Id
    private Long eventId;
    private int remaining;

    protected TicketPool() {
    }

    void take(int count) {
        if (remaining < count) {
            throw new IllegalStateException("Sold out");
        }
        remaining -= count;
    }
}

interface TicketPoolRepository extends JpaRepository<TicketPool, Long> {

    // select ... for update: other transactions wait instead of failing later
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select t from TicketPool t where t.eventId = :eventId")
    Optional<TicketPool> findForUpdate(Long eventId);
}

@Service
public class TicketService {

    private final TicketPoolRepository pools;

    TicketService(TicketPoolRepository pools) {
        this.pools = pools;
    }

    @Transactional(timeout = 5)
    public void buy(long eventId, int count) {
        pools.findForUpdate(eventId).orElseThrow().take(count);
    }
}

Discussion

  • Be the first to comment on this lesson.