Basics 42 locked the product rows while an order took stock, after measuring what happens without the lock: of twenty parallel orders for five USB-C hubs, up to all twenty were accepted. That fixed one use case with one tool. The same race comes back in other shapes: two people editing one product, a batch job repricing the catalogue while someone edits it, several workers pulling from one queue, two transactions locking rows in opposite order. PESSIMISTIC_WRITE is the right answer to only some of them.
This article goes through the rest of the toolbox and measures each tool against the same database: optimistic locking with @Version from the SQL to the HTTP status, retrying a conflict without hiding it, the other pessimistic modes and their timeouts, SKIP LOCKED for a work queue, a real deadlock, and the lock-free atomic update with a CHECK constraint behind it. The examples use Spring Boot 4.1.1 and Java 21 against PostgreSQL 18, with the application on port 8207. Timings are medians or best-of runs, with the one-minute load average next to them: indicative, not a benchmark.
![]()
The first two sections set up the project and reproduce the lost update; each section after that adds one tool, and the last one compares them all on the same burst.
The project and the test harness
A product catalogue again, cut down to what the races need: a Product with a price, a description and a stock, a GET and a PUT, and later a reservation endpoint. Locking is database behaviour, so the project talks to PostgreSQL only, and Flyway owns the schema with ddl-auto=validate, as in Basics 31.
curl -s "https://start.spring.io/starter.zip?type=gradle-project&language=java&bootVersion=4.1.1&javaVersion=21&groupId=com.example&artifactId=demo&name=demo&packageName=com.example.demo&dependencies=web,data-jpa,postgresql,flyway,validation" -o demo.zipdocker run -d --name sba-a7-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5507:5432 postgres:18spring.application.name=demo
server.port=8207
spring.datasource.url=jdbc:postgresql://localhost:5507/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.hibernate.SQL=debugcreate table products (
id bigint generated by default as identity primary key,
sku varchar(40) not null unique,
name varchar(120) not null,
description varchar(1000),
price numeric(10, 2) not null,
stock integer not null
);
insert into products (sku, name, description, price, stock) values
('KB-01', 'Mechanical keyboard', 'Tenkeyless, brown switches', 89.90, 10),
('MS-01', 'Wireless mouse', 'Two-button, USB-C receiver', 24.50, 10),
('HUB-07', 'USB-C hub', 'Seven ports, 100 W pass-through', 39.00, 50);package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 40, unique = true)
private String sku;
@Column(nullable = false, length = 120)
private String name;
@Column(length = 1000)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
@Column(nullable = false)
private int stock;
protected Product() {
}
public void update(String name, String description, BigDecimal price) {
this.name = name;
this.description = description;
this.price = price;
}
public void decreaseStock(int quantity) {
if (quantity > stock) {
throw new InsufficientStockException(sku, stock, quantity);
}
stock -= quantity;
}
// getters for every field
}The service and the controller are the shape Basics 26 and 30 left them in: a read-only service class with one writing method, and a controller that maps DTOs.
package com.example.demo.product;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@Transactional(readOnly = true)
public class ProductService {
private final ProductRepository products;
public ProductService(ProductRepository products) {
this.products = products;
}
public Product findById(Long id) {
return products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
}
@Transactional
public Product update(Long id, UpdateProductRequest request) {
Product product = findById(id);
product.update(request.name(), request.description(), request.price());
return product;
}
}package com.example.demo.product;
import jakarta.validation.Valid;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService service;
public ProductController(ProductService service) {
this.service = service;
}
@GetMapping("/{id}")
public ProductResponse findById(@PathVariable Long id) {
return ProductResponse.from(service.findById(id));
}
@PutMapping("/{id}")
public ProductResponse update(@PathVariable Long id, @Valid @RequestBody UpdateProductRequest request) {
return ProductResponse.from(service.update(id, request));
}
}UpdateProductRequest is a record of name, description and price with the usual constraints, and ProductResponse returns every column. InsufficientStockException and ProductNotFoundException extend the series' ConflictException and NotFoundException, and GlobalExceptionHandler is Basics 20's ResponseEntityExceptionHandler subclass, as Basics 42 left it, cut down to the handlers this API needs: 404, 409, the 409 for DataIntegrityViolationException, the 422 field list and the catch-all.
Releasing threads at the same moment
A race that depends on luck proves nothing, so every race below is started the same way: N threads each block on a CountDownLatch, the test waits until all N are ready, then opens the latch once. Each thread records ok or the simple name of the exception it got, and the test prints the counts.
package com.example.demo;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
public final class Race {
public record Outcome(Map<String, Integer> counts, long millis) {
}
private Race() {
}
public static Outcome run(int threads, Runnable task) throws Exception {
CountDownLatch ready = new CountDownLatch(threads);
CountDownLatch start = new CountDownLatch(1);
List<Future<String>> results = new ArrayList<>();
try (ExecutorService pool = Executors.newFixedThreadPool(threads)) {
for (int i = 0; i < threads; i++) {
results.add(pool.submit(() -> {
ready.countDown();
start.await();
try {
task.run();
return "ok";
} catch (RuntimeException e) {
return e.getClass().getSimpleName();
}
}));
}
ready.await();
long begin = System.nanoTime();
start.countDown();
Map<String, Integer> counts = new TreeMap<>();
for (Future<String> result : results) {
counts.merge(result.get(), 1, Integer::sum);
}
return new Outcome(counts, (System.nanoTime() - begin) / 1_000_000);
}
}
}The tests are @SpringBootTest(webEnvironment = NONE) classes against the same PostgreSQL, so every transaction is a real one on a real connection. Spring Boot's HikariCP pool keeps its default of 10 connections, and the test JVM held 10 sessions in pg_stat_activity: of 100 threads, at most 10 are inside the database at once and the rest wait for a connection. The tests print with System.out, which Gradle only shows with one more block in build.gradle:
tasks.named('test') {
testLogging {
showStandardStreams = true
}
}./gradlew test --tests '*LostUpdateTest'The lost update: two users edit the same product
Alice lowers the keyboard's price. Bob, at the same time, adds "PBT keycaps" to its description. Both open the product first:
curl -s http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":89.90,"stock":10}Both got that same body. Alice saves her form:
curl -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90}' http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"stock":10}Then Bob saves his, which still holds the price he read:
curl -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90}' http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90,"stock":10}Both answered 200, and a final GET returned Bob's body: Alice's price is gone, and nobody was told. The SQL of each PUT explains why:
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock from products p1_0 where p1_0.id=?
update products set description=?,name=?,price=?,sku=?,stock=? where id=?Hibernate writes every column of a changed entity by default, and the where clause holds only the id. Whoever writes last decides every column, including the ones they never touched.
Over HTTP the two reads and the two writes are minutes apart. The same interleaving inside the server needs two transactions that have both read the row before either writes, which a CyclicBarrier of two guarantees:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE)
class LostUpdateTest {
@Autowired
ProductRepository products;
@Autowired
TransactionTemplate tx;
@Autowired
JdbcClient jdbc;
@Test
void twoEditorsOfOneProduct() throws Exception {
for (int round = 1; round <= 10; round++) {
jdbc.sql("update products set price = 89.90, description = 'Tenkeyless, brown switches' where id = 1").update();
CyclicBarrier bothLoaded = new CyclicBarrier(2);
String[] errors = new String[2];
Thread alice = new Thread(() -> errors[0] = edit(bothLoaded, p -> p.update(p.getName(), p.getDescription(), new BigDecimal("79.90"))));
Thread bob = new Thread(() -> errors[1] = edit(bothLoaded, p -> p.update(p.getName(), "Tenkeyless, brown switches, PBT keycaps", p.getPrice())));
alice.start();
bob.start();
alice.join();
bob.join();
// read price and description back with JdbcClient, count and print the round
}
}
private String edit(CyclicBarrier bothLoaded, Consumer<Product> change) {
try {
tx.executeWithoutResult(status -> {
Product product = products.findById(1L).orElseThrow();
await(bothLoaded); // both transactions have read the row
change.accept(product);
});
return null;
} catch (RuntimeException e) {
return e.getClass().getSimpleName();
}
}
}round 1: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob committed
round 2: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob committed
round 3: price 89.90, description 'Tenkeyless, brown switches, PBT keycaps', alice committed, bob committed
both kept 0, price lost 5, description lost 5, rounds with an exception 0Ten rounds, ten lost edits, no exception. Which one survived depended on which commit came second. This is the read-modify-write that oversold hubs in Basics 42; there the lost value was a stock count, here it is somebody's work.
Optimistic locking with @Version
Optimistic locking takes no lock when it reads. It adds a version column, and every UPDATE states the version it was based on; when another transaction got there first, the UPDATE matches no row and Hibernate throws. One migration and one field:
alter table products add column version bigint not null default 0;import jakarta.persistence.Table;
import jakarta.persistence.Version;
@Entity
@Table(name = "products")
public class Product {
// ...
@Column(nullable = false)
private int stock;
@Version
private Long version;
// ...
public Long getVersion() {
return version;
}
}The same test, unchanged, now sends this UPDATE:
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?Hibernate binds the new version to the set and the version it loaded to the where, and checks that exactly one row changed. The ten rounds:
round 1: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob ObjectOptimisticLockingFailureException
round 2: price 79.90, description 'Tenkeyless, brown switches', alice committed, bob ObjectOptimisticLockingFailureException
round 3: price 89.90, description 'Tenkeyless, brown switches, PBT keycaps', alice ObjectOptimisticLockingFailureException, bob committed
both kept 0, price lost 8, description lost 2, rounds with an exception 10One edit per round still does not survive, but it no longer disappears silently: in every round exactly one transaction committed and the other got an exception it can report. Nothing about the read changed; the version check is entirely in the write.
The losing UPDATE does not fail at once. A test that flushes A's UPDATE, holds A's transaction open for a second, and has B flush the same UPDATE 200 ms later printed:
A flushed at 27 ms, holding the row for 1000 ms
B flushes at 228 ms
A committed at 1037 ms
B threw ObjectOptimisticLockingFailureException at 1046 msB's UPDATE waited for A's row lock, and when A committed, PostgreSQL re-evaluated where id=? and version=? against the committed row, found version 4 instead of 3, and updated nothing.
What exception reaches your code
The loser's full cause chain, as the test printed it:
org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']
org.hibernate.StaleObjectStateException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']
org.hibernate.StaleStateException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?]The UPDATE ran at commit, when dirty checking flushed, so the exception came out of the transaction manager's commit and Spring had already translated it. jakarta.persistence.OptimisticLockException, the name the JPA specification uses, never appears in that chain. It does appear when you flush through the EntityManager yourself, which a second test tried with the row changed by another thread between the read and the flush:
| Who flushed | What your code catches | Its cause |
|---|---|---|
commit of a @Transactional method or TransactionTemplate | ObjectOptimisticLockingFailureException | org.hibernate.StaleObjectStateException |
productRepository.flush() or saveAndFlush | ObjectOptimisticLockingFailureException | org.hibernate.StaleObjectStateException |
the injected EntityManager.flush(), called outside a repository | jakarta.persistence.OptimisticLockException | org.hibernate.StaleObjectStateException |
Repositories and the transaction manager translate; the injected EntityManager used outside a repository, in a service or here in the test class, does not. Code that catches Spring's OptimisticLockingFailureException, the parent of ObjectOptimisticLockingFailureException, covers the first two rows only, which is one more reason to leave flushing to the repository.
@Version also protects saveAll and dirty checking
The test above never called save: dirty checking wrote the change, and the version was checked anyway. The other path is saveAll with entities loaded in an earlier transaction, the typical batch job that reads everything, works on it, and writes it back:
@Test
void saveAllOfStaleDetachedEntities() {
List<Product> all = products.findAll(); // detached once the repository call returns
productService.update(2L, new UpdateProductRequest("Wireless mouse", "Two-button, USB-C receiver",
new BigDecimal("22.00"), 0L));
all.forEach(p -> p.update(p.getName(), p.getDescription(), p.getPrice().multiply(new BigDecimal("1.10"))));
products.saveAll(all); // wrapped in try/catch and printed in the real test
}after the edit: [{id=1, price=89.90, version=0}, {id=2, price=22.00, version=1}, {id=3, price=39.00, version=0}]
>>> saveAll
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
saveAll threw
chain: org.springframework.orm.ObjectOptimisticLockingFailureException: Row was already updated or deleted by another transaction for entity [com.example.demo.product.Product with id '2']
chain: org.hibernate.StaleObjectStateException: Row was already updated or deleted by another transaction for entity [com.example.demo.product.Product with id '2']
after saveAll: [{id=1, price=89.90, version=0}, {id=2, price=22.00, version=1}, {id=3, price=39.00, version=0}]saveAll merges each detached entity, and a merge loads the row and compares versions before any UPDATE is sent. Product 2's version had moved from 0 to 1, so the merge threw with a different message, "Row was already updated or deleted by another transaction", and because saveAll is one transaction, product 1's new price was not written either.
Bulk @Modifying updates bypass the version
A JPQL update runs in the database without loading an entity, so Hibernate has no version to compare. Two repository methods, the second using Hibernate's update versioned:
@Modifying
@Query("update Product p set p.price = p.price * :factor")
int repriceAll(BigDecimal factor);
@Modifying
@Query("update versioned Product p set p.price = p.price * :factor")
int repriceAllVersioned(BigDecimal factor);The test loads product 1 in an editor's transaction, runs a 10 % reprice of the whole catalogue in its own transaction, then lets the editor change the description and commit:
>>> bulk update, versioned=false
update products p1_0 set price=(p1_0.price*?)
bulk update changed 3 rows
after the bulk update: [{id=1, price=98.89, version=0}, {id=2, price=26.95, version=0}, {id=3, price=42.90, version=0}]
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor committed
after the editor: [{id=1, price=89.90, version=1}, {id=2, price=26.95, version=0}, {id=3, price=42.90, version=0}]The reprice left every version at 0, so the editor's where version=0 still matched, and its full-column UPDATE put the old price back: the increase on product 1 is gone without an error. With update versioned:
>>> bulk update, versioned=true
update products p1_0 set price=(p1_0.price*?),version=(p1_0.version+1)
bulk update changed 3 rows
after the bulk update: [{id=1, price=98.89, version=1}, {id=2, price=26.95, version=1}, {id=3, price=42.90, version=1}]
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor threw
chain: org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']update versioned is HQL, not JPQL, and Spring Data JPA 4.1.1 passed it through @Query unchanged.
The version over HTTP: 409 with ProblemDetail
Inside one transaction the version is checked for you. Across two requests, the GET that showed Alice the form and the PUT that saves it, the server has to be told which version the form was based on. Two conventions exist. HTTP's own puts the version in an ETag header on the GET and expects it back in If-Match on the PUT, answering 412 Precondition Failed when it does not match. The other puts version in the JSON and answers 409 Conflict.
This API uses the body and 409. The series already answers every conflict with 409, and there are two ways to lose this race, both of which should look the same to the client: a stale version caught by a check in the service, and two requests that both pass that check and collide at the UPDATE, which reaches the advice as ObjectOptimisticLockingFailureException. With version in the body both are a 409 with no special case. ETag and If-Match are the better choice when generic HTTP clients, caches or a body-less DELETE must take part.
package com.example.demo.product;
import java.math.BigDecimal;
public record ProductResponse(Long id, String sku, String name, String description, BigDecimal price, int stock,
Long version) {
static ProductResponse from(Product product) {
return new ProductResponse(product.getId(), product.getSku(), product.getName(),
product.getDescription(), product.getPrice(), product.getStock(), product.getVersion());
}
}package com.example.demo.product;
import java.math.BigDecimal;
import jakarta.validation.constraints.Digits;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Positive;
import jakarta.validation.constraints.Size;
public record UpdateProductRequest(
@NotBlank @Size(max = 120) String name,
@Size(max = 1000) String description,
@NotNull @Positive @Digits(integer = 8, fraction = 2) BigDecimal price,
@NotNull Long version) {
}package com.example.demo.product;
import com.example.demo.common.ConflictException;
public class StaleProductException extends ConflictException {
public StaleProductException(Long id, Long current, Long expected) {
super("Product " + id + " is at version " + current + ", the update was based on version " + expected);
}
} @Transactional
public Product update(Long id, UpdateProductRequest request) {
Product product = findById(id);
if (!Objects.equals(product.getVersion(), request.version())) {
throw new StaleProductException(id, product.getVersion(), request.version());
}
product.update(request.name(), request.description(), request.price());
return product;
}The check compares the client's version with the one just loaded; the where version=? of the UPDATE covers the gap between that load and the commit. The service never writes the entity's version field; that stays Hibernate's job. The advice gets one handler, next to the series' ConflictException handler:
@ExceptionHandler(ConflictException.class)
public ProblemDetail conflict(ConflictException ex) {
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT, ex.getMessage());
}
@ExceptionHandler(OptimisticLockingFailureException.class)
public ProblemDetail concurrentUpdate(OptimisticLockingFailureException ex) {
log.info("Optimistic lock conflict: {}", ex.getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The resource was changed by another request. Reload it and apply your change again.");
} The walk-through from the first section, with product 1 at version 3 (set directly in the table, so that the numbers match the diagram below). Both users read it:
curl -s http://localhost:8207/api/products/1{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":89.90,"stock":10,"version":3}Alice saves first:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"version":3}' http://localhost:8207/api/products/1HTTP/1.1 200
Content-Type: application/json
Content-Length: 131
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"id":1,"sku":"KB-01","name":"Mechanical keyboard","description":"Tenkeyless, brown switches","price":79.90,"stock":10,"version":4}The response already carries version 4: the controller maps the entity after the commit, and Hibernate increments the version field of the managed entity when it flushes. Bob saves with the version he read:
curl -i -s -X PUT -H 'Content-Type: application/json' -d '{"name":"Mechanical keyboard","description":"Tenkeyless, brown switches, PBT keycaps","price":89.90,"version":3}' http://localhost:8207/api/products/1HTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"detail":"Product 1 is at version 4, the update was based on version 3","instance":"/api/products/1","status":409,"title":"Conflict"}A client that leaves the version out gets the series' 422:
HTTP/1.1 422
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:04:15 GMT
{"detail":"Request has 1 invalid value(s).","instance":"/api/products/1","status":422,"title":"Unprocessable Content","errors":[{"field":"version","message":"must not be null"}]}Bob's client reloads, sees Alice's price, re-applies his description, and sends "version":4: 200, with "price":79.90, the new description and "version":5. Both changes survive, because a person merged them.
Sequential requests only ever meet the check in the service. The UPDATE's own check needs requests that pass the service check together, so ten PUTs with the same version were sent in parallel, each with its own description and its body saved in a put directory:
seq 1 10 | xargs -P 10 -I{} sh -c "curl -s -o put/{}.json -w '%{http_code}\n' -X PUT -H 'Content-Type: application/json' -d '{\"name\":\"Mechanical keyboard\",\"description\":\"Run 1, edit {}\",\"price\":79.90,\"version\":10}' http://localhost:8207/api/products/1" | sort | uniq -c 1 200
9 409cat put/*.json | jq -r '.detail // "200 OK"' | sort | uniq -c 1 200 OK
6 Product 1 is at version 11, the update was based on version 10
3 The resource was changed by another request. Reload it and apply your change again.Six requests read the row after the winner committed and failed the service check. Three had passed it and lost at the UPDATE, which the application log shows as Optimistic lock conflict: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '1']. Two more runs split the nine 409s 6/3 and 7/2, always with one 200. The descriptions differ on purpose: in an earlier run every request sent the same description, the one that already matched the stored row changed nothing, Hibernate sent no UPDATE and did not bump the version, and it answered 200 next to the real winner.

Retrying an optimistic conflict
A 409 hands the conflict to the user, which is right for an edit: only Bob can decide how his description combines with Alice's price. Some operations need no user. Reserving one hub is a relative change: read the stock, check it, subtract one. Whatever another transaction did in between, applying the same operation to the new state is still correct, so the server can retry it. With @Version on Product, this read-modify-write is optimistic without a line changed:
@Service
public class StockService {
private final ProductRepository products;
public StockService(ProductRepository products) {
this.products = products;
}
@Transactional
public void reserve(Long id, int quantity) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(quantity);
}
}The retry must start a new transaction on every attempt, so it lives in another bean and calls reserve through its proxy:
package com.example.demo.product;
import java.util.concurrent.ThreadLocalRandom;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.stereotype.Service;
@Service
public class StockReservations {
private static final Logger log = LoggerFactory.getLogger(StockReservations.class);
private static final int MAX_ATTEMPTS = 5;
private final StockService stock;
public StockReservations(StockService stock) {
this.stock = stock;
}
public void reserve(Long id, int quantity) {
for (int attempt = 1; ; attempt++) {
try {
stock.reserve(id, quantity);
return;
} catch (OptimisticLockingFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw e;
}
log.debug("Conflict on product {}, attempt {} of {}", id, attempt, MAX_ATTEMPTS);
backOff(attempt);
}
}
}
private static void backOff(int attempt) {
try {
Thread.sleep(ThreadLocalRandom.current().nextLong(1, 10L * attempt));
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException("Interrupted while retrying", e);
}
}
}Each attempt is a fresh StockService.reserve call: a new transaction, a new persistence context, a new select that reads the committed stock and version. The test sends 20 reservations at once for a hub with 50 in stock, three rounds per variant, and counts the retries from the DEBUG line:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {"logging.level.org.hibernate.SQL=info", "logging.level.com.example.demo.product.StockReservations=debug"})
class RetryTest {
// StockService stock, StockReservations reservations and JdbcClient jdbc are @Autowired
@Test
void retryPlacement() throws Exception {
run("no retry", () -> stock.reserve(HUB, 1));
run("retry around the transaction", () -> reservations.reserve(HUB, 1));
run("retry inside one transaction", () -> reservations.reserveInOneTransaction(HUB, 1));
}
private void run(String name, Runnable task) throws Exception {
for (int round = 1; round <= 3; round++) {
jdbc.sql("update products set stock = 50 where id = ?").param(HUB).update();
Race.Outcome outcome = Race.run(20, task);
// print outcome.counts() and the stock left
}
}
}| 20 reservations, 50 in stock, 3 rounds | Sold | Still failed | Retries logged |
|---|---|---|---|
| no retry | 2, 3, 3 | 18, 17, 17 | none |
retry, first version without backOff (load 3.0) | 10, 11, 12 | 10, 9, 8 | 59, 55, 51 |
retry with the jittered backOff above (load 4.1) | 18, 20, 20 | 2, 0, 0 | 41, 35, 45 |
@Transactional on the retrying method | 2, 2, 3 | 18, 18, 17 | none |
Without a retry, 17 or 18 of 20 customers were refused while 47 or 48 hubs sat in stock. Retrying straight away helped less than expected: the requests that lost together retried together and collided again, and 8 to 10 still ran out of attempts. A random sleep of up to 10 ms times the attempt number spread them out, and 58 of 60 reservations went through.
Retrying is correct only because reserve re-reads and re-applies. The PUT is the opposite case. A test that wrapped the same retry around a full-state edit, each user sending the whole form with the values they read, had one user retry in each of 10 rounds, and in each of those 10 rounds the other user's change was gone: the retry reloaded the new version, applied the stale form on top of it, and committed. That is the first section's lost update, reintroduced by the retry. A conflict whose resolution needs the user's intent goes back to the user as a 409.
Where the retry must sit
The last row of the table is the natural mistake: put @Transactional on the method that retries.
@Transactional
public void reserveInOneTransaction(Long id, int quantity) {
for (int attempt = 1; ; attempt++) {
try {
stock.reserve(id, quantity);
return;
} catch (OptimisticLockingFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw e;
}
log.debug("Conflict on product {}, attempt {} of {}", id, attempt, MAX_ATTEMPTS);
}
}
}It logged no retry at all. stock.reserve now joins the outer transaction, nothing is flushed inside the loop, and the UPDATE runs when the outer method has already returned. The stack trace of a loser shows where:
org.springframework.orm.ObjectOptimisticLockingFailureException: Unexpected row count (expected row count 1 but was 0) [update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?] for entity [com.example.demo.product.Product with id '3']
at org.springframework.transaction.support.AbstractPlatformTransactionManager.processCommit(AbstractPlatformTransactionManager.java:794)
at org.springframework.transaction.support.AbstractPlatformTransactionManager.commit(AbstractPlatformTransactionManager.java:757)
at org.springframework.transaction.interceptor.TransactionAspectSupport.commitTransactionAfterReturning(TransactionAspectSupport.java:687)
at org.springframework.transaction.interceptor.TransactionAspectSupport.invokeWithinTransaction(TransactionAspectSupport.java:408)
at org.springframework.transaction.interceptor.TransactionInterceptor.invoke(TransactionInterceptor.java:130)
at com.example.demo.product.StockReservations$$SpringCGLIB$$0.reserveInOneTransaction(<generated>)Flushing inside the loop does not rescue it. A variant that called saveAndFlush in each attempt, inside one transaction, with another thread committing a reservation between the read and the flush, printed:
attempt 1 read stock 50 version 108
attempt 1 caught ObjectOptimisticLockingFailureException
attempt 2 read stock 49 version 108
attempt 2 caught ObjectOptimisticLockingFailureException
attempt 3 read stock 48 version 108
attempt 3 caught ObjectOptimisticLockingFailureException
outer threw org.springframework.transaction.UnexpectedRollbackException: Transaction silently rolled back because it has been marked as rollback-onlyAttempts 2 and 3 sent no select: findById returned the same managed entity from the persistence context, still at version 108 and with the previous attempt's decrement applied. And saveAndFlush is a transactional repository method, so its proxy had marked the transaction rollback-only on the first failure, the trap from Basics 30. A retry works only around the whole transaction. Spring Framework 7 also ships a declarative @Retryable in org.springframework.resilience.annotation, enabled with @EnableResilientMethods; article 19 of this course covers it and its placement rules.
Pessimistic locks beyond PESSIMISTIC_WRITE
Basics 42 used @Lock(LockModeType.PESSIMISTIC_WRITE), which Hibernate 7.4.5 sends to PostgreSQL as for no key update, measured that it stops the oversell, and locked products in id order. JPA has a weaker mode and a timeout hint, and Hibernate maps one special timeout value to PostgreSQL's SKIP LOCKED:
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Product> findForUpdateById(Long id);
@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Product> findForShareById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000"))
Optional<Product> findForUpdateWithTimeoutById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "0"))
Optional<Product> findForUpdateNoWaitById(Long id);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2"))
Optional<Product> findForUpdateSkipLockedById(Long id);The hint's special values are constants of org.hibernate.Timeouts in 7.4.5: NO_WAIT_MILLI = 0, WAIT_FOREVER_MILLI = -1 and SKIP_LOCKED_MILLI = -2; any positive value is milliseconds. To see what a second transaction experiences, a test lets a first transaction take a lock and hold it for 1.5 s, starts the second one 100 ms later, and times it:
void contend(String label, Runnable holderLock, long holdMillis, Supplier<Object> contender) throws Exception {
CountDownLatch locked = new CountDownLatch(1);
Thread holder = new Thread(() -> tx.executeWithoutResult(s -> {
holderLock.run();
locked.countDown();
sleep(holdMillis);
}));
holder.start();
locked.await();
sleep(100);
long begin = System.nanoTime();
// tx.execute(s -> contender.get()), then print the result or the exception chain
// and the milliseconds since begin
}
@Test
void lockModes() throws Exception {
Runnable write = () -> products.findForUpdateById(3L).orElseThrow();
Runnable read = () -> products.findForShareById(3L).orElseThrow();
contend("WRITE held, WRITE requested", write, 1500, () -> products.findForUpdateById(3L).map(Product::getStock));
contend("WRITE held, READ requested", write, 1500, () -> products.findForShareById(3L).map(Product::getStock));
contend("WRITE held, plain read", write, 1500, () -> products.findById(3L).map(Product::getStock));
contend("READ held, READ requested", read, 1500, () -> products.findForShareById(3L).map(Product::getStock));
contend("READ held, WRITE requested", read, 1500, () -> products.findForUpdateById(3L).map(Product::getStock));
contend("WRITE held, NOWAIT", write, 1500, () -> products.findForUpdateNoWaitById(3L).map(Product::getStock));
contend("WRITE held, timeout 2000", write, 3000, () -> products.findForUpdateWithTimeoutById(3L).map(Product::getStock));
contend("WRITE held, SKIP LOCKED", write, 1500, () -> products.findForUpdateSkipLockedById(3L).map(Product::getStock));
}With the one-minute load average at 3.9:
| First transaction holds | Second transaction asks for | Clause Hibernate appended | Second transaction got |
|---|---|---|---|
PESSIMISTIC_WRITE | PESSIMISTIC_WRITE | for no key update of p1_0 | the row after 1413 ms |
PESSIMISTIC_WRITE | PESSIMISTIC_READ | for share of p1_0 | the row after 1399 ms |
PESSIMISTIC_WRITE | plain findById | none | the last committed row after 7 ms |
PESSIMISTIC_READ | PESSIMISTIC_READ | for share of p1_0 | the row after 3 ms |
PESSIMISTIC_READ | PESSIMISTIC_WRITE | for no key update of p1_0 | the row after 1405 ms |
PESSIMISTIC_WRITE | write, timeout 0 | for no key update of p1_0 nowait | CannotAcquireLockException after 8 ms |
PESSIMISTIC_WRITE, held 3 s | write, timeout 2000 | for no key update of p1_0, after set local lock_timeout = 2000 | CannotAcquireLockException after 2015 ms |
PESSIMISTIC_WRITE | write, timeout -2 | for no key update of p1_0 skip locked | Optional.empty after 12 ms |
Three things stand out. The plain read did not wait for the row lock at all, so a lock only protects code paths that also lock. Two PESSIMISTIC_READs share the row, which is exactly what makes them dangerous for read-modify-write. And every failure is the same Spring type, CannotAcquireLockException, whatever PostgreSQL said.

PESSIMISTIC_READ and the lock upgrade
for share looks like the cheaper lock for "read the stock, then decrease it". Two transactions that both take it, wait for each other at a barrier, then both decrease the stock:
tx.executeWithoutResult(s -> {
Product product = products.findForShareById(3L).orElseThrow();
await(bothShared);
product.decreaseStock(1);
products.flush();
});shared round 1: committed after 1025 ms, CannotAcquireLockException after 1023 ms, stock 49
shared round 2: committed after 1011 ms, CannotAcquireLockException after 1011 ms, stock 49
shared round 3: committed after 1004 ms, CannotAcquireLockException after 1004 ms, stock 49
PESSIMISTIC_READ then update: rounds with a failure 10 of 10Each UPDATE needs the row exclusively and waits for the other transaction's share lock: a deadlock in every round, on a single row, reported as ERROR: deadlock detected while updating tuple (15,3) in relation "products" on the UPDATE. PESSIMISTIC_READ keeps a row from changing while you only read it; a value you are going to write needs PESSIMISTIC_WRITE from the start.
Lock timeouts: NOWAIT and jakarta.persistence.lock.timeout
Without a hint, a pessimistic lock waits as long as the holder keeps it. Timeout 0 makes PostgreSQL refuse immediately:
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait
HHH000247: ErrorCode: 0, SQLState: 55P03
ERROR: could not obtain lock on row in relation "products"
chain: org.springframework.dao.CannotAcquireLockException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "products"] [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]; SQL [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]
chain: org.hibernate.exception.LockTimeoutException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "products"] [select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=? for no key update of p1_0 nowait]
chain: org.postgresql.util.PSQLException: ERROR: could not obtain lock on row in relation "products"PostgreSQL has no wait N clause, so for a positive timeout Hibernate 7.4.5 changes the session setting around the query. The server's own statement log (log_statement = 'all') for the 2000 ms hint while another transaction held the row:
LOG: execute <unnamed>: select current_setting('lock_timeout', true)
LOG: execute <unnamed>: set local lock_timeout = 2000
LOG: execute <unnamed>: select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=$1 for no key update of p1_0
ERROR: canceling statement due to lock timeout
CONTEXT: while locking tuple (15,2) in relation "products"
LOG: execute S_2: ROLLBACKThe error is the same SQLSTATE 55P03 and reaches Java through the same three classes, CannotAcquireLockException caused by org.hibernate.exception.LockTimeoutException caused by PSQLException: ERROR: canceling statement due to lock timeout, 2015 ms after the query started. When the lock is granted in time, Hibernate puts the old value back right after the query, so the timeout does not leak into the rest of the transaction:
select current_setting('lock_timeout', true)
set local lock_timeout = 2000
set local lock_timeout = 0
lock_timeout after the hinted query: 0That is two statements before the query and one after it, on every call. In the 100-thread burst of the last section, reserveWithLock with the hint took a median of 57 ms against 34 ms for the same lock without it (load 2.4 to 3.1): the extra round trips run while other transactions queue for the row.
A lock timeout belongs in any request path, because a request that waits forever holds a thread and a connection forever. The reservation endpoint uses the hinted method:
@Transactional
public void reserveWithLock(Long id, int quantity) {
Product product = products.findForUpdateWithTimeoutById(id).orElseThrow(() -> new ProductNotFoundException(id));
product.decreaseStock(quantity);
} @PostMapping("/{id}/reservations")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void reserve(@PathVariable Long id, @Valid @RequestBody ReservationRequest request) {
stock.reserveWithLock(id, request.quantity());
} ReservationRequest is a record with one @NotNull @Positive Integer quantity. The advice maps the pessimistic failures to the series' 409 as well, with a detail that tells the client a retry makes sense:
@ExceptionHandler(PessimisticLockingFailureException.class)
public ProblemDetail lockNotAcquired(PessimisticLockingFailureException ex) {
log.info("Lock not acquired: {}", ex.getMostSpecificCause().getMessage());
return ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The resource is locked by another request. Try again.");
} To hold the row from outside the application, psql takes the lock and sleeps 5 s in one transaction:
docker exec sba-a7-pg psql -U demo -d demo -c "begin" -c "select id, stock from products where id = 3 for update" -c "select pg_sleep(5)" -c "commit"curl -i -s -H 'Content-Type: application/json' -d '{"quantity":1}' -w 'time %{time_total}s\n' http://localhost:8207/api/products/3/reservationsHTTP/1.1 409
Content-Type: application/problem+json
Transfer-Encoding: chunked
Date: Fri, 18 Sep 2026 05:05:04 GMT
{"detail":"The resource is locked by another request. Try again.","instance":"/api/products/3/reservations","status":409,"title":"Conflict"}time 2.037096sThe same request without the psql lock answered HTTP/1.1 204 in 0.045 s.
A work queue with SKIP LOCKED
The pattern SKIP LOCKED exists for is several workers taking jobs from one table, each job done once, no worker waiting for another. The jobs table, and a job_runs table where every processing leaves a row, standing in for the side effect that must not happen twice, an email or a payment call:
create table jobs (
id bigint generated by default as identity primary key,
payload varchar(200) not null,
status varchar(20) not null,
processed_by varchar(40),
processed_at timestamp with time zone
);
create index jobs_status_id_idx on jobs (status, id);
create table job_runs (
id bigint generated by default as identity primary key,
job_id bigint not null references jobs (id),
worker varchar(40) not null
);Job is an entity with a JobStatus of NEW or DONE and a complete(worker) method that sets the status, the worker and the time. The repository has the same query three times, differing only in locking:
package com.example.demo.job;
import java.util.List;
import jakarta.persistence.LockModeType;
import jakarta.persistence.QueryHint;
import org.springframework.data.domain.Limit;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.jpa.repository.QueryHints;
public interface JobRepository extends JpaRepository<Job, Long> {
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNext(JobStatus status, Limit limit);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNextLocked(JobStatus status, Limit limit);
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "-2"))
@Query("select j from Job j where j.status = :status order by j.id")
List<Job> findNextSkippingLocked(JobStatus status, Limit limit);
}select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only
select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only for no key update of j1_0
select j1_0.id,j1_0.payload,j1_0.processed_at,j1_0.processed_by,j1_0.status from jobs j1_0 where j1_0.status=? order by j1_0.id fetch first ? rows only for no key update of j1_0 skip lockedSpring Data's Limit became fetch first ? rows only, and the lock clause came after it with no follow-on locking. A worker claims up to ten jobs, processes each one inside the same transaction so the rows stay locked until they are marked done, and commits:
package com.example.demo.job;
import java.util.List;
import org.springframework.data.domain.Limit;
import org.springframework.jdbc.core.simple.JdbcClient;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class JobProcessor {
private static final Limit BATCH = Limit.of(10);
private final JobRepository jobs;
private final JdbcClient jdbc;
public JobProcessor(JobRepository jobs, JdbcClient jdbc) {
this.jobs = jobs;
this.jdbc = jdbc;
}
@Transactional
public int processNextBatch(String worker, ClaimStrategy strategy) {
List<Job> batch = switch (strategy) {
case NO_LOCK -> jobs.findNext(JobStatus.NEW, BATCH);
case LOCK -> jobs.findNextLocked(JobStatus.NEW, BATCH);
case SKIP_LOCKED -> jobs.findNextSkippingLocked(JobStatus.NEW, BATCH);
};
for (Job job : batch) {
process(job, worker);
job.complete(worker);
}
return batch.size();
}
private void process(Job job, String worker) {
// the side effect that must happen once per job: an email, a payment call...
jdbc.sql("insert into job_runs (job_id, worker) values (?, ?)").params(job.getId(), worker).update();
try {
Thread.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}ClaimStrategy exists only so one test can compare the three queries; a real processor calls findNextSkippingLocked and nothing else. The test starts four workers on 1000 NEW jobs, released together by a latch, each calling processNextBatch until it returns 0, then counts job_runs:
batches.add(pool.submit(() -> {
start.await();
int n = 0;
long longest = 0;
while (true) {
long t0 = System.nanoTime();
int size = processor.processNextBatch(worker, strategy);
longest = Math.max(longest, (System.nanoTime() - t0) / 1_000_000);
if (size == 0) {
break;
}
n++;
}
// print the worker's batches, longest call and the jobs still NEW
return n;
}));Three rounds per query, load average 4.0 to 4.4:
| Claim query | Wall time | job_runs rows for 1000 jobs | Jobs processed more than once | Jobs per worker | Longest single call |
|---|---|---|---|---|---|
| no lock | 3091, 2956, 3199 ms | 4000, 4000, 3980 | 3000, 3000, 2980 | 990 to 1000 each | 35 to 105 ms |
for no key update | 2689, 2487, 2547 ms | 1000 | 0 | 110 to 400 | 280 to 1300 ms |
for no key update … skip locked | 720, 905, 883 ms | 1000 | 0 | 250 each | 40 to 53 ms |
Without a lock, the four workers read the same ten NEW rows, processed them all, and stayed in step for the whole run: nearly every job ran four times. With a plain lock no job ran twice, but the workers queued behind each other. A waiting worker, once the holder committed, found its rows DONE, moved on to the next rows, and usually found them locked by the next winner; one call waited 1.3 s, the split between workers ranged from 110 to 400 jobs, and in an earlier run of the same test one of the four workers processed no job at all in each of three rounds. With SKIP LOCKED each worker took the next ten unlocked rows at once: no duplicates, 250 jobs each, about three times faster.
One detail matters for a real worker. In one SKIP LOCKED round a worker stopped with 30 jobs still NEW: all of them were locked by the other three, so its query returned nothing. An empty batch means "nothing available right now", not "queue empty"; a scheduled poller that comes back later handles that for free.
A real deadlock, and the fix
Two transactions that lock the same two rows in opposite order: A locks product 1 then product 2, B locks product 2 then product 1, and a barrier makes sure both hold their first row before either asks for the second.
private String lockTwo(CyclicBarrier firstLocks, long first, long second) {
return run(() -> tx.executeWithoutResult(s -> {
products.findForUpdateById(first).orElseThrow();
await(firstLocks); // both hold their first row before asking for the second
products.findForUpdateById(second).orElseThrow();
}));
}With the load average at 4.8:
opposite order round 1: A CannotAcquireLockException after 1064 ms, B committed after 1058 ms
opposite order round 2: A committed after 1010 ms, B CannotAcquireLockException after 1010 ms
opposite order round 3: A CannotAcquireLockException after 1009 ms, B committed after 1011 ms
opposite order: rounds with a failure 10 of 10HHH000247: ErrorCode: 0, SQLState: 40P01
ERROR: deadlock detected
Detail: Process 290 waits for ShareLock on transaction 22060; blocked by process 291.
Process 291 waits for ShareLock on transaction 22061; blocked by process 290.
Hint: See server log for query details.
Where: while locking tuple (0,34) in relation "products"Ten rounds, ten deadlocks, each resolved after about one second, which is PostgreSQL's deadlock_timeout (show deadlock_timeout answered 1s): the server waits that long before it looks for a cycle, then cancels one transaction and lets the other finish. The victim's chain is CannotAcquireLockException caused by org.hibernate.exception.LockAcquisitionException caused by PSQLException: ERROR: deadlock detected, the same Spring type as a lock timeout, so the advice above answers it with the same 409.
The fix is the one Basics 42 applied to OrderService.place: every transaction locks rows in the same order. The same test with both transactions locking product 1 before product 2, started together and holding the first lock 100 ms:
same order round 1: A committed after 160 ms, B committed after 272 ms
same order round 2: A committed after 228 ms, B committed after 115 ms
same order: rounds with a failure 0 of 10The second transaction waits at its first lock instead of taking a second one, so no cycle can form. When a consistent order is impossible, the deadlock victim's transaction can be retried whole like the optimistic reservation, by adding PessimisticLockingFailureException to the retry's catch, provided the operation is safe to repeat.
Lock-free: an atomic conditional update
For a counter, the database can do the whole read-modify-write in one statement. The condition goes into the where, and the number of rows it changed says whether it applied:
@Modifying
@Query("update Product p set p.stock = p.stock - :quantity where p.id = :id and p.stock >= :quantity")
int decreaseStockIfAvailable(Long id, int quantity); @Transactional
public void reserveAtomically(Long id, int quantity) {
if (products.decreaseStockIfAvailable(id, quantity) == 0) {
Product product = products.findById(id).orElseThrow(() -> new ProductNotFoundException(id));
throw new InsufficientStockException(product.getSku(), product.getStock(), quantity);
}
}A reservation that fits sends one statement; one that does not sends the same statement, gets 0, and reads the row once to tell a missing product from an empty shelf:
update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=? and p1_0.stock>=?update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=? and p1_0.stock>=?
select p1_0.id,p1_0.description,p1_0.name,p1_0.price,p1_0.sku,p1_0.stock,p1_0.version from products p1_0 where p1_0.id=?
threw com.example.demo.product.InsufficientStockException: Only 0 of HUB-07 in stock, 1 requestedConcurrent UPDATEs of one row still queue on its row lock, but each one re-evaluates stock >= ? against the committed value when its turn comes, so the check and the write cannot be separated. In the 100-thread burst, every round sold exactly 50 and refused 50, stock 0, in a median of 29 ms: the fastest correct strategy measured here.
An atomic update and @Version together
A bulk update bypasses the version, as the reprice showed, and that matters here too. An editor loads the hub, ten reservations commit through reserveAtomically, and the editor saves a new price:
>>> plain atomic update: before the sales {stock=50, price=39.00, version=2803}
after 10 sales {stock=40, price=39.00, version=2803}
update products set description=?,name=?,price=?,sku=?,stock=?,version=? where id=? and version=?
editor committed
after the editor {stock=50, price=35.00, version=2804}Ten sold hubs came back into stock. The sales never changed the version, so the editor's where version=2803 matched, and its full-column UPDATE wrote the stock it had loaded. Two fixes, both run. Making the atomic update update versioned bumps the version with every sale, and the editor gets the 409 path:
update products p1_0 set stock=(p1_0.stock-?),version=(p1_0.version+1) where p1_0.id=? and p1_0.stock>=?
editor threw ObjectOptimisticLockingFailureException
after the editor {stock=40, price=39.00, version=2814}That is correct, but on a product that sells every second, an edit form almost never saves. The other fix stops the editor from writing columns it did not change, with Hibernate's @DynamicUpdate:
@Entity
@DynamicUpdate
@Table(name = "products")
public class Product {after 10 sales {stock=40, price=39.00, version=2815}
update products set price=?,version=? where id=? and version=?
editor committed
after the editor {stock=40, price=35.00, version=2816}The editor's UPDATE now sets only the price and the version, still checked against the version, and the stock stays at 40. This works as long as the stock is changed only by atomic updates: a stock change made through the entity would again write a value that another transaction's atomic update has made stale, with no version to catch it.
Keep the contended statement last
A row lock taken by an UPDATE, like one taken by for no key update, is held until the transaction ends. In a real order the stock change is followed by inserting the order and its lines, so the burst was repeated with 5 ms of other work in the same transaction, once after the stock change and once before it (load 2.4 to 3.1):
| Strategy | Stock change only | +5 ms after the stock change | +5 ms before the stock change |
|---|---|---|---|
PESSIMISTIC_WRITE, 2 s timeout | 57 ms | 512 ms | 90 ms |
| atomic update | 29 ms | 448 ms | 82 ms |
With the work after the lock, 50 transactions held the row for their 5 ms one after another, and the atomic update was no faster than the explicit lock. Moving the same work before the stock change cut both to under 100 ms. Optimistic locking has no such choice to make: its UPDATE is the flush at commit, always last.
A CHECK constraint as the last line of defence
Whatever the code does, stock below zero is never valid, and the database can say so:
alter table products add constraint products_stock_check check (stock >= 0);To see it fire, a method that drops the stock >= :quantity condition, update Product p set p.stock = p.stock - :quantity where p.id = :id, received the same burst three times:
unconditional round 1: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814}
unconditional round 2: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814}
unconditional round 3: {DataIntegrityViolationException=50, ok=50}, {stock=0, price=39.00, version=2814} chain: org.springframework.dao.DataIntegrityViolationException: JDBC exception executing SQL [ERROR: new row for relation "products" violates check constraint "products_stock_check"
Detail: Failing row contains (3, HUB-07, USB-C hub, Seven ports, 100 W pass-through, 39.00, -1, 2814).] [update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=?]; SQL [update products p1_0 set stock=(p1_0.stock-?) where p1_0.id=?]; constraint [products_stock_check]
chain: org.hibernate.exception.ConstraintViolationException: JDBC exception executing SQL [ERROR: new row for relation "products" violates check constraint "products_stock_check"
chain: org.postgresql.util.PSQLException: ERROR: new row for relation "products" violates check constraint "products_stock_check"Exactly 50 sold every round, SQLSTATE 23514, and a DataIntegrityViolationException that the series' handler already answers with 409. It is a backstop, not a strategy: the run also logged 151 WARN pairs from Hibernate's HHH000247, one per rejected statement (150 in the rounds, one in the single call that printed the chain above), and the constraint cannot see the bug it is usually expected to catch. With @Version removed and the constraint in place, the read-modify-write burst "sold" 100 of 100 in every round and left 37 to 42 in stock. A lost update writes a stale number, never a negative one, so the constraint had nothing to reject.
Choosing a strategy
The same burst for every strategy: 100 threads each reserving one hub, 50 in stock, 10 connections, a warm-up round and then five measured rounds, load average 2.4 to 3.1. withWorkBefore is withWorkAfter with the two lines swapped, and retrying is the loop of StockReservations.reserve wrapped around the transaction that includes the work:
@Test
void hundredReservationsForFiftyHubs() throws Exception {
measure("optimistic", () -> stock.reserve(HUB, 1));
measure("optimistic + retry", () -> reservations.reserve(HUB, 1));
measure("pessimistic", () -> stock.reserveWithLock(HUB, 1));
measure("pessimistic, no hint", () -> tx.executeWithoutResult(status ->
products.findForUpdateById(HUB).orElseThrow().decreaseStock(1)));
measure("atomic", () -> stock.reserveAtomically(HUB, 1));
measure("optimistic +5ms", () -> withWorkAfter(() -> stock.reserve(HUB, 1)));
measure("optimistic + retry +5ms", () -> retrying(() -> withWorkAfter(() -> stock.reserve(HUB, 1))));
measure("pessimistic +5ms", () -> withWorkAfter(() -> stock.reserveWithLock(HUB, 1)));
measure("atomic +5ms", () -> withWorkAfter(() -> stock.reserveAtomically(HUB, 1)));
measure("pessimistic 5ms+", () -> withWorkBefore(() -> stock.reserveWithLock(HUB, 1)));
measure("atomic 5ms+", () -> withWorkBefore(() -> stock.reserveAtomically(HUB, 1)));
}
// the rest of the order, inside the same transaction, after taking the stock
private void withWorkAfter(Runnable reserve) {
tx.executeWithoutResult(status -> {
reserve.run();
sleep(5);
});
}
private void measure(String name, Runnable task) throws Exception {
for (int round = 0; round <= 5; round++) { // round 0 warms up
jdbc.sql("update products set stock = 50 where id = ?").param(HUB).update();
Race.Outcome outcome = Race.run(100, task);
// print outcome.counts(), the stock left and outcome.millis(); then best and median of rounds 1-5
}
}The read-modify-write rows come from the same test run with @Version commented out.
| Strategy | Outcome per round | Stock left | Median, stock change only | Median, +5 ms after it |
|---|---|---|---|---|
| read-modify-write | 100 "sold" | 37 to 42 | 46 ms | 75 ms |
@Version, no retry | 11 to 13 sold, 87 to 89 conflicts | 37 to 39 | 34 ms | 77 ms |
@Version + StockReservations retry | 50 sold, 1 to 26 still conflicting after 5 attempts | 0 | 136 ms | 349 ms, 41 or 42 sold, 8 or 9 left |
PESSIMISTIC_WRITE, 2 s timeout | 50 sold, 50 out of stock | 0 | 57 ms | 512 ms |
PESSIMISTIC_WRITE, no hint | 50 sold, 50 out of stock | 0 | 34 ms | not run |
| atomic update | 50 sold, 50 out of stock | 0 | 29 ms | 448 ms |

| Strategy | What it prevents | Cost under contention (measured above) | Failure mode | Typical use |
|---|---|---|---|---|
@Version, conflict to the client | lost updates across requests and inside the server | no lock at read; at 100 threads on one row, 87 to 89 of 100 refused | ObjectOptimisticLockingFailureException, 409 | edit forms, anything a person merges |
@Version + retry around the transaction | lost updates, without bothering the user | wasted transactions: 35 to 59 retries for 20 reservations; still failures at 100 threads | the last attempt's exception | relative changes on rows that rarely collide |
PESSIMISTIC_WRITE with a timeout | lost updates and check-then-act on locked rows | serialises every transaction on the row, and holds it until commit | waits; CannotAcquireLockException on timeout or deadlock | read-decide-write that needs the entity, several rows in id order |
SKIP LOCKED | two workers taking the same job | workers never wait for each other: 1000 jobs in 0.7 to 0.9 s with 4 workers, longest call 53 ms | an empty batch while locked jobs remain | job queues, outbox relays |
| atomic conditional update | lost updates and check-then-act on one counter | one statement; the row lock lasts until commit, so keep it last | 0 rows changed, turned into a 409 | stock, quotas, counters |
CHECK constraint | invalid values from any code path | none | DataIntegrityViolationException, 409, a WARN per rejection | a backstop under every other strategy |
Isolation levels are the other lever: whether REPEATABLE READ or SERIALIZABLE would have stopped these races on their own, and what a serialization failure looks like, is the subject of article 6. Everything above locks rows inside one database. Coordinating separate application instances, so that a scheduled job runs on only one of them, is what ShedLock does in article 20; locks held in Redis are outside the scope of this course.
FAQ
What is the difference between optimistic and pessimistic locking in Spring Boot?
Optimistic locking takes no lock when reading: @Version adds and version=? to every UPDATE, and a transaction that finds the version changed gets ObjectOptimisticLockingFailureException. Pessimistic locking locks the row when reading, @Lock(PESSIMISTIC_WRITE) sending for no key update on PostgreSQL, and makes other lockers wait. With 100 concurrent reservations on one row, @Version alone let 11 to 13 through and refused the rest; PESSIMISTIC_WRITE sold exactly the 50 in stock.
Why do I get ObjectOptimisticLockingFailureException?
Because an UPDATE with where id=? and version=? matched no row: another transaction committed a change to the row after yours read it. The message is Unexpected row count (expected row count 1 but was 0) when the flush found it, and Row was already updated or deleted by another transaction when save or saveAll merged a detached entity. The cause is org.hibernate.StaleObjectStateException; jakarta.persistence.OptimisticLockException only appears when you call EntityManager.flush() yourself.
Should the version go in an ETag or in the request body?
Either works; pick one per API. ETag plus If-Match is standard HTTP and answers 412, which suits generic clients, caches and body-less DELETEs. A version field in the body fits a JSON API that already answers conflicts with 409, and gives the stale check in the service and the conflict at the UPDATE the same status; this article's API answered 200, 409 and 422 (version missing) that way.
Does a @Modifying @Query update increment the @Version column?
No. update Product p set p.price = p.price * :factor left every version at 0, and an editor holding the old entity then overwrote the new price without an error. Write update versioned Product p … (Hibernate HQL, accepted by Spring Data JPA 4.1.1 in @Query), which added version=(p1_0.version+1) to the statement, and the editor holding the old entity got ObjectOptimisticLockingFailureException instead.
How do I set a lock timeout with Spring Data JPA on PostgreSQL?
Add @QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "2000")) next to @Lock. Hibernate 7.4.5 runs select current_setting('lock_timeout', true) and set local lock_timeout = 2000 before the query and restores the old value after it; a lock not granted in time fails with canceling statement due to lock timeout (SQLSTATE 55P03) as CannotAcquireLockException. The value 0 sends nowait, -2 sends skip locked.
How do several workers process a job queue without duplicates?
Claim jobs with @Lock(PESSIMISTIC_WRITE) plus the lock timeout hint -2, which Hibernate sends as for no key update … skip locked, and process them in the same transaction. Four workers on 1000 jobs processed each exactly once, 250 per worker, in 0.7 to 0.9 s; without a lock nearly every job ran four times, and with a plain lock the workers queued behind each other for 2.5 to 2.7 s.
Why does my retry of an optimistic lock failure never retry?
Because it runs inside the transaction it is trying to repeat. With @Transactional on the retrying method, the UPDATE is flushed when that method's proxy commits, after the loop has returned, and the test logged zero retries. The retry must call a @Transactional method of another bean, so that each attempt is a new transaction with a fresh read.
Conclusion
Every race in this article is the same shape, read then write with a gap in between, and every tool closes the gap differently. @Version detects it at the write and turns it into a 409 the user can resolve, or into a retry when the change can be re-applied, as long as the retry wraps the whole transaction. PESSIMISTIC_WRITE closes it by making others wait, which needs a timeout, a consistent order and a short hold, and SKIP LOCKED turns the same lock into a queue where nobody waits at all. An atomic conditional update removes the gap for a counter, and a CHECK constraint catches whatever slips through, but only values that are actually invalid.
The measurements that are worth remembering are the traps: a bulk @Modifying update and an atomic stock update both leave the version untouched, so an editor holding the entity silently writes old values back; PESSIMISTIC_READ deadlocks the moment two readers update; a row lock held while other work runs costs as much as the work, whichever strategy took it; and a retry placed inside the transaction never retries.
The next article stays in Chapter 2 and turns from how rows are written to how queries are built: dynamic queries with Specifications and the Criteria API, Querydsl or jOOQ, for search screens whose filters are only known at runtime.