Optimistic Locking with @Version

Add a Version Column

Add a field annotated with @Version. Hibernate increments it on every update and adds it to the WHERE clause. If another transaction changed the row first, the update matches 0 rows and Hibernate throws an exception instead of overwriting.

@Entity
@Table(name = "products")
public class Product {
    @Id @GeneratedValue(strategy = GenerationType.SEQUENCE)
    private Long id;
    private String name;
    private BigDecimal price;
    private int stock;

    @Version
    private Long version;      // Long/Integer/Short or a timestamp; never set it yourself
}
update products
   set name=?, price=?, stock=?, version=?      -- version = old + 1
 where id=? and version=?                       -- old version read earlier

Two Concurrent Updates

Example

Two threads read the same row (version 0). Asha writes first. Ravi's write fails the version check, so he re-reads and retries:

import java.util.concurrent.CountDownLatch;

public class Main {
    public static void main(String[] args) throws Exception {
        ProductTable table = new ProductTable(10);
        CountDownLatch bothRead = new CountDownLatch(2);
        CountDownLatch ashaWrote = new CountDownLatch(1);
        String[] log = new String[2];

        Thread asha = new Thread(() -> {
            Row r = table.read(); bothRead.countDown(); await(bothRead);
            log[0] = "Asha buys 1: " + table.update(r.stock() - 1, r.version());
            ashaWrote.countDown();
        });
        Thread ravi = new Thread(() -> {
            Row r = table.read(); bothRead.countDown(); await(bothRead); await(ashaWrote);
            String result = table.update(r.stock() - 2, r.version());
            if (result.startsWith("FAILED")) {
                Row fresh = table.read();                       // reload and try again
                result += "\n             retry: " + table.update(fresh.stock() - 2, fresh.version());
            }
            log[1] = "Ravi buys 2: " + result;
        });
        asha.start(); ravi.start(); asha.join(); ravi.join();
        System.out.println(log[0]);
        System.out.println(log[1]);
        System.out.println("final row: " + table.read());
    }

    static void await(CountDownLatch l) {
        try { l.await(); } catch (InterruptedException e) { throw new RuntimeException(e); }
    }
}

record Row(int stock, long version) {}

class ProductTable {
    private int stock;
    private long version = 0;
    ProductTable(int stock) { this.stock = stock; }

    synchronized Row read() { return new Row(stock, version); }

    // UPDATE products SET stock=?, version=version+1 WHERE id=1 AND version=?
    synchronized String update(int newStock, long expectedVersion) {
        if (version != expectedVersion) {
            return "FAILED, 0 rows updated (expected version " + expectedVersion + ", row has " + version + ")";
        }
        stock = newStock;
        version++;
        return "OK, stock=" + stock + " version=" + version;
    }
}

Output

Asha buys 1: OK, stock=9 version=1
Ravi buys 2: FAILED, 0 rows updated (expected version 0, row has 1)
             retry: OK, stock=7 version=2
final row: Row[stock=7, version=2]

What You See in Spring

Hibernate throws OptimisticLockException (JPA) / StaleObjectStateException; Spring translates it to ObjectOptimisticLockingFailureException. Map it to 409 Conflict or retry the whole transaction.

A Conflict

curl -X PATCH localhost:8080/api/products/7/stock -H "Content-Type: application/json" -d '{"delta": -2}'

Example output

Hibernate: update products set name=?,price=?,stock=?,version=? where id=? and version=?
WARN  ... ObjectOptimisticLockingFailureException: Row was updated or deleted by another
      transaction (or unsaved-value mapping was incorrect) : [com.shopease.product.Product#7]

HTTP/1.1 409 Conflict
{"title":"Conflict","status":409,"detail":"Product 7 was changed by someone else. Please retry."}

Versions and REST Clients

For edit screens (admin edits a product price), send the version to the client and back. The client's version is checked on save, so an admin cannot overwrite a change made by another admin minutes ago.

public record ProductUpdate(String name, BigDecimal price, Long version) {}

@Transactional
public ProductDto update(Long id, ProductUpdate in) {
    Product p = products.findById(id).orElseThrow();
    if (!p.getVersion().equals(in.version())) {
        throw new ObjectOptimisticLockingFailureException(Product.class, id);
    }
    p.setName(in.name());
    p.setPrice(in.price());
    return ProductDto.from(p);     // version is bumped on flush
}

Tip: HTTP has this built in: return the version as an ETag header and require If-Match on PUT/PATCH. A mismatch is 412 Precondition Failed.