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 earlierTwo 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.