1. Two worlds of reservation: commit vs reading
When we say “reserve stock,” it sounds as if it is one and the same algorithm, just with different annotations. In practice, these are two different worlds: optimistic moves the conflict to the write moment, while pessimistic tries to prevent contention ahead of time by locking the row. Now it is important to see this in one application flow: SQL, waiting, exceptions, and how all of that turns into a clear service contract.
At this point we already have all the handles: we can see when a hot row does not get along with retry, which lock to ask the database for, and why waiting by itself does not save anything. All that remains is to assemble this into one reservation use case.
Imagine that we have a product, and for it there is a stock row InventoryItem. An order is created, and inside one service use case we must do a “small accounting trick”: decrease availableQty and increase reservedQty. If two users buy the same “hot” product at the same time (a typical flash sale, yes), contention arises for the same row in the stock table.
The key point: concurrency here is not an abstraction, but a very concrete question of “who will change the same row, and when”. In the optimistic approach, both transactions can happily read the same value, both can “mentally” calculate a new one, and only during the update will it become clear that someone was faster. In the pessimistic approach, the second transaction usually will not even start “mentally” calculating, because it will run into a lock: it will either wait, or quickly receive “resource busy” (NOWAIT), or skip the row altogether (SKIP LOCKED — but for a targeted reservation that is usually dangerous).
To make comparison easier, keep this engineering formula in mind:
- Optimistic: “Let everyone run, but at the finish line we will check who arrived first.”
- Pessimistic: “First we put up a turnstile, then only one person runs.”
2. The InventoryItem model and the reserve() method
Before comparing strategies, we need to make sure we are comparing them on one and the same model. Otherwise we get the classic mistake: “our optimistic approach is broken,” while in reality the invariant availableQty >= 0 is broken, and we are not checking it anywhere at all. So first we will fix a minimal InventoryItem model and a clean way to change the quantity — through a small method, not through two setters in different places in the code.
A minimal fragment of the entity (in the project it is most likely already there; here — only what matters for reservation):
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
@Entity
public class InventoryItem {
@Id
private Long id;
// How many units of the product are available for sale right now (not reserved yet).
private int availableQty;
// How many units of the product are already reserved for orders.
private int reservedQty;
// Service version for detecting concurrent updates (optimistic locking).
@Version
private long version;
}
Why does @Version not disappear even if we use pessimistic locking? Because the version is not a “flag for optimistic,” but a mechanism for detecting concurrent updates. If we lock the row pessimistically, there will most likely be no concurrent updates, but the version is still a useful service column: it changes on update and helps with diagnostics (and sometimes business logic — for example, to understand that the object was definitely modified).
To make the update readable and identical in both approaches, let us add a domain method:
public void reserve(int qty) {
// Important: this method only shifts the numbers.
// Checks (qty > 0, availableQty >= qty) are usually done outside, at the use case level.
this.availableQty -= qty;
this.reservedQty += qty;
}
Yes, the method is tiny. But it is like a toothbrush: a small thing, yet without it life quickly becomes suspiciously stale. The entire code that follows will differ only in how we read InventoryItem (normally or with a lock) and how we handle the concurrent outcome (version conflict or busy row).
3. Optimistic flow: fast and cheap
Optimistic locking is popular for the same reason people like “let’s not complicate things yet”: in everyday life conflicts are rare, so the system runs quickly and in parallel. First it is useful to establish the optimistic flow as a baseline: it shows where the conflict appears on its own and what we pay for while it is rare. The main thing is to understand that the conflict will not be detected at setAvailableQty(), but at the moment of the actual write (flush/commit).
A hypothetical service method (in com.example.commerce.inventory.service) may look like this:
import org.springframework.transaction.annotation.Transactional;
@Transactional
public ReservationResult reserveOptimistically(Long itemId, int qty) {
// Ordinary read without a lock: two transactions can read the same row in parallel.
InventoryItem item = entityManager.find(InventoryItem.class, itemId);
// Business check: is there enough stock?
if (item.getAvailableQty() < qty) return ReservationResult.NOT_ENOUGH_STOCK;
// Change the aggregate state in memory (the database write happens on flush/commit).
item.reserve(qty);
return ReservationResult.RESERVED;
}
At this point a beginner usually thinks: “Where is the lock? Where is synchronized? Where is the magic?” The magic is in @Version. On flush, Hibernate will form an UPDATE that checks whether the version has changed. A typical SQL statement (simplified) looks like this:
update inventory_item
set available_qty = ?, reserved_qty = ?, version = ?
where id = ? and version = ?;
The important part is and version = ?. If the second transaction tries to update the row but the version is already different, the UPDATE will affect 0 rows, and Hibernate will throw an OptimisticLockException (in Spring this often becomes ObjectOptimisticLockingFailureException).
Here it is important to keep the boundary honest. OPTIMISTIC_CONFLICT is the outcome of this use case, but in a normal Spring flow it will surface where flush/commit actually happens, not necessarily right inside the body of such a method. So do not draw a false symmetry with the pessimistic flow and pretend that a version conflict can always be caught neatly next to item.reserve(qty). If you explicitly call flush(), you can materialize the conflict earlier. But by default it lives at the boundary where Hibernate really sends UPDATE ... WHERE version = ?.
To make the comparison not just theoretical, let us fix the optimistic-conflict timeline:
sequenceDiagram
participant A as Tx A
participant B as Tx B
participant DB as PostgreSQL
A->>DB: "SELECT inventory_item (version=5)"
B->>DB: "SELECT inventory_item (version=5)"
A->>DB: "UPDATE ... WHERE version=5 (success, version=6)"
B->>DB: "UPDATE ... WHERE version=5 (0 rows) => OptimisticLockException"
That is exactly why we said yesterday: optimistic is good as long as conflicts are rare and retry/conflict handling is not too expensive.
For this reservation case, the pessimistic branch ends up being quite narrow. We are changing a specific InventoryItem row, so we need PESSIMISTIC_WRITE, not PESSIMISTIC_READ. For a user request it is usually better to quickly hear “row busy” than to hang in a queue, so next we will take the fail-fast variant with NOWAIT. SKIP LOCKED does not fit semantically here: you cannot silently skip exactly this product and pretend that reservation still happened. And the whole scenario rests on a short critical section: cheap checks before the lock, stock and update — under the lock.
4. Pessimistic flow: lock before update
Pessimistic locking changes the usual CRUD flow in that you ask the database: “Please, while I am working, do not let anyone else change this row.” Psychologically, it is like a sign saying “Do not touch, I am counting.” In reality this is SQL like SELECT ... FOR UPDATE, and the price is very real: other transactions will wait, fail with NOWAIT, or skip the row if you explicitly request SKIP LOCKED.
The basic pessimistic-flow step is to fetch InventoryItem with PESSIMISTIC_WRITE. The simplest version by id looks like this:
import jakarta.persistence.LockModeType;
InventoryItem item = entityManager.find(
InventoryItem.class,
itemId,
LockModeType.PESSIMISTIC_WRITE
);
If we reserve stock by productId (which is closer to the real order domain), then it is often more convenient to use a JPQL query and attach a lock to it:
import jakarta.persistence.LockModeType;
InventoryItem item = entityManager.createQuery("""
select i
from InventoryItem i
where i.product.id = :productId
""", InventoryItem.class)
.setParameter("productId", productId) // Bind the query parameter.
.setLockMode(LockModeType.PESSIMISTIC_WRITE) // Ask the DB for a row-level lock (FOR UPDATE).
.getSingleResult();
Now let us add a “similar” business logic path, but with NOWAIT, so we do not hang. We use the JPA hint jakarta.persistence.lock.timeout = 0 (fail-fast):
import jakarta.persistence.LockModeType;
import java.util.Map;
InventoryItem item = entityManager.find(
InventoryItem.class,
itemId,
LockModeType.PESSIMISTIC_WRITE,
// timeout = 0 means: do not wait for the lock, fail immediately with an exception (NOWAIT).
Map.of("jakarta.persistence.lock.timeout", 0)
);
What do we expect to see in the SQL log? For PostgreSQL it is usually something like:
select ...
from inventory_item
where id = ?
for update nowait;
And this is where the main difference from optimistic appears. The conflict, or more precisely the contention, now manifests on read. If the row is busy, we get an exception when trying to acquire the lock. Then, at the external boundary of the use case, it is convenient to name this outcome LOCK_BUSY and do no further work.
At the boundary where an infrastructure exception is translated into a meaningful use-case outcome, this usually looks like:
import jakarta.persistence.LockTimeoutException;
import jakarta.persistence.PessimisticLockException;
catch (PessimisticLockException | LockTimeoutException ex) {
// Normal business-scenario branch: the row is busy in another transaction.
return ReservationResult.LOCK_BUSY;
}
And the transactional part itself, after the lock has been successfully acquired, remains short and decides only two outcomes on an already protected row: NOT_ENOUGH_STOCK and RESERVED.
import jakarta.persistence.LockModeType;
import org.springframework.transaction.annotation.Transactional;
import java.util.Map;
@Transactional
public ReservationResult reserveWithNowait(Long itemId, int qty) {
InventoryItem item = entityManager.find(
// Take the lock right on read so nobody can update this row in parallel.
InventoryItem.class, itemId, LockModeType.PESSIMISTIC_WRITE,
Map.of("jakarta.persistence.lock.timeout", 0)
);
// The check is done under the lock: this is the key meaning of the pessimistic approach.
if (item.getAvailableQty() < qty) return ReservationResult.NOT_ENOUGH_STOCK;
item.reserve(qty);
return ReservationResult.RESERVED;
}
If the row is busy, we simply will not reach this branch: lock acquisition will fail earlier, and at the outer boundary of the use case this will become LOCK_BUSY.
5. Service contract: outcomes
When developers first encounter locking, they often turn the system into a quest: “if it failed, it means bad luck.” But concurrency is not a power outage and not a disk crash. It is a normal business-scenario branch. That is why in Commerce Persistence Lab it is useful to make the outcome explicit: the calling code needs four different results of one operation, even if they arise at different points — inside a business check, on lock acquisition, or on flush/commit.
Let us start with a small enum. It looks very “childlike,” but that is exactly what makes it valuable: it forces us to name the outcomes in their own terms.
public enum ReservationResult {
// Reservation created successfully.
RESERVED,
// Not enough stock (business error, no concurrency).
NOT_ENOUGH_STOCK,
// Version conflict: someone managed to modify the row before us.
OPTIMISTIC_CONFLICT,
// Row busy: could not acquire the pessimistic lock (NOWAIT/timeout).
LOCK_BUSY
}
It is important not to expect the same mechanics for all four values. We learn NOT_ENOUGH_STOCK from the business check inside the transaction, LOCK_BUSY appears when trying to acquire the lock, and OPTIMISTIC_CONFLICT lives on the flush/commit boundary, where Hibernate really writes UPDATE ... WHERE version = ?.
Now the idea becomes simple: the optimistic flow can return OPTIMISTIC_CONFLICT, the pessimistic NOWAIT flow can return LOCK_BUSY, and both can return NOT_ENOUGH_STOCK and RESERVED. This makes the comparison of strategies transparent: each strategy has its own concurrency outcomes.
Another nice bonus: when you look at logs and SQL, you stop thinking “why did it fail?” and start thinking “which outcome did we get, and what should we do with it?” Even if you do not yet have a web layer, this is already design discipline. In a real application, this later turns into different HTTP responses (for example, 409 CONFLICT or 423 LOCKED), but we are deliberately not going into REST design — our course is about the persistence layer, not controller aesthetics.
6. Comparing optimistic and pessimistic
Comparing optimistic and pessimistic by the criterion of “reliability” is like comparing a hammer and a screwdriver by the criterion of “who is better at driving nails.” They solve the problem in different ways and pay different costs. So let us compare the strategies by the parameters that are actually felt in practice: the moment of conflict, the effect on other transactions, latency predictability, and the typical user effect.
A small table to keep nearby when choosing a strategy for InventoryItem:
| Comparison axis | Optimistic (@Version) | Pessimistic (FOR UPDATE) |
|---|---|---|
| Where contention appears | On UPDATE (flush/commit) | On SELECT ... FOR UPDATE (read/lock) |
| What happens to the parallel transaction | It runs in parallel, but may fail with a conflict | It waits / fails with NOWAIT / skips the row (SKIP LOCKED) |
| Latency (response time) | Usually more stable, but sometimes “blows up at the end” | Can grow sharply because of lock waiting |
| Throughput | Usually higher when conflicts are low | Can decrease because access is serialized |
| What to do on conflict | Retry or show the conflict | Respond quickly with “busy” or wait/timeout |
The same idea in a short “where the pain moved” diagram:
flowchart TD
A[Optimistic] --> B[Business logic runs]
B --> C[Flush/Commit]
C --> D{Version ok?}
D -->|Yes| E[RESERVED]
D -->|No| F[OPTIMISTIC_CONFLICT]
P[Pessimistic] --> Q[Attempt to acquire lock on SELECT]
Q --> R{Lock acquired?}
R -->|No| S[LOCK_BUSY / timeout]
R -->|Yes| T[Stock check + reserve + commit]
If you have a “hot” product and conflicts happen often, optimistic can turn into a generator of conflicts and retries. If conflicts are rare, pessimistic can turn into a generator of queues and waiting. That is exactly why in lecture 1 we said: first measure and understand, then choose.
Laboratory mini-scenario
It is very useful to see the difference between the strategies “on live meat,” otherwise the brain will stubbornly think that both implementations are simply “about the same thing.” In the lab project we can stage a small artificial conflict: one transaction holds a lock on an InventoryItem row, and another tries to reserve the same product through NOWAIT. This is not load testing and not a benchmark — it is just a teaching microscope that makes the behaviour reproducible.
The simplest way to show “row busy” is to hold the transaction in the first thread for a short time (you cannot do this in real code, but in the lab it is exactly useful). For example, the first thread acquires PESSIMISTIC_WRITE, “sleeps” for 300 ms, and the second thread in the meantime does NOWAIT.
A mini fragment (an idea, not the final infrastructure) can look like this:
import java.util.concurrent.CountDownLatch;
CountDownLatch locked = new CountDownLatch(1);
txTemplate.executeWithoutResult(s -> {
entityManager.find(InventoryItem.class, id, LockModeType.PESSIMISTIC_WRITE);
locked.countDown(); // lock already acquired
Thread.sleep(300); // hold the lock (lab only!)
});
And the second thread waits until the lock is definitely acquired, then tries NOWAIT:
locked.await();
ReservationResult r = reservationService.reserveWithNowait(id, 1);
// in a service with explicit outcome, we expect: LOCK_BUSY
Why is this useful? Because after such an experiment, you stop having the illusion that “pessimistic is just another flag.” You literally see it: optimistic gives a conflict on update, pessimistic gives a conflict on lock. And you also see the main rule from lecture 4: you must not hold a lock for long. Even 300 ms, which looks funny in the lab, can become a queue of hundreds of requests in production if the product is very hot.
7. Typical mistakes
Mistake #1: treating pessimistic locking as “more correct” and using it everywhere just because the word is “money”.
It is very easy to fall into the trap of “if the operation is important, let’s lock everything.” The problem is that a pessimistic lock buys correctness at the cost of waiting and serializing access. If conflicts are rare, you will simply worsen throughput and create queues where there were none before. It is important to first honestly answer whether the same row really contends often, and how expensive your retry is in the optimistic model.
Mistake #2: holding a pessimistic lock longer than necessary because “we are in a transaction”.
Technically Hibernate does not forbid you from doing ten more queries after SELECT ... FOR UPDATE, calculating a quarterly report, and remembering why you even opened the IDE. But the database does not share your love of creativity. The longer you hold the lock, the longer the waiting queue, the higher the chance of timeouts, and the closer you get to “why does everything slow down only on Fridays?”
Mistake #3: not modelling the outcome “row busy” and hiding it under a general RuntimeException.
The LOCK_BUSY scenario is a normal outcome, especially if you use NOWAIT or a small timeout. If you catch the exception and return “something went wrong,” you lose control over the behaviour of the system. It is much more useful to explicitly return ReservationResult.LOCK_BUSY and give the calling code the ability to decide: retry later, pick another product, show the user a clear message.
Mistake #4: trying to do a “pre-check of stock” before the lock and believing that is enough.
A pre-check before the lock can be useful, but it never replaces the check after the lock. Before the lock you see a “snapshot” that can go stale in a millisecond. In a correct pessimistic flow, the key invariant (availableQty >= qty) is checked only after the row is protected, otherwise you are just moving the race somewhere else, not eliminating it.
Mistake #5: comparing strategies only by one criterion (“is there a conflict”).
Optimistic and pessimistic are almost always “both correct,” but they pay different prices and create different user effects. Optimistic can produce a conflict on commit, when the user is already waiting for a result. Pessimistic can produce waiting or a fast “resource busy” failure. In a real project, the choice is more often determined not by what is “more correct,” but by what kind of degradation you consider acceptable for a particular business case.
GO TO FULL VERSION