Code that increments a reservation count can overbook past capacity even with @Transactional on it. The transaction is clearly open and the commit succeeds. The problem is that what a transaction prevents and what concurrency demands are different things.
A transaction gives atomicity: the operations grouped together are all applied or all rolled back. But two transactions reading the same value at once and each writing its own change isn’t stopped by atomicity alone. Serializing competing writes is the job of a lock. A transaction and a lock aren’t substitutes; they solve different problems.
Lost Update
Consider reservations arriving for a slot with a capacity of 100. The server reads the current reservation count, and if it’s under capacity, increments it by one and saves. That’s a read-modify-write sequence.
The current count is 99, one seat left. Two requests arrive at nearly the same time.
sequenceDiagram
participant R1 as Request 1
participant DB
participant R2 as Request 2
R1->>DB: SELECT count → 99
R2->>DB: SELECT count → 99
Note over R1,R2: both pass 99 < 100
R1->>DB: UPDATE count = 100
R2->>DB: UPDATE count = 100
Note over DB: two reserved, but count = 100 (overbooked)
Both requests read 99, both pass the “99 < 100” check, and both save 100. The count is recorded as 100, but two reservations were made. One seat went to two people. The later save overwrote the earlier one, and one update disappeared. This is a Lost Update. It happens inside transaction boundaries, and the transaction doesn’t stop it, because each transaction is self-contained.
Raising the isolation level isn’t a real fix here either. The plain reads of Read Committed or Repeatable Read only show each transaction the value as of its own point in time; they don’t serialize two competing writes. Serializing the read-modify-write takes a lock.
Pessimistic Locking
The most direct fix is to lock the row from the moment it’s read. A pessimistic lock assumes conflicts are frequent and takes a lock as the data is read, making other transactions wait.
In JPA, you set the lock mode on the query method.
@Lock(LockModeType.PESSIMISTIC_WRITE)
Optional<Slot> findById(Long id);
PESSIMISTIC_WRITE is an exclusive lock, and JPA translates it into the database’s SELECT ... FOR UPDATE.
SELECT * FROM slot WHERE id = ? FOR UPDATE
The second request waits at the read until the transaction that took the lock first commits. Once the wait clears, the second request reads the latest value committed by the first transaction — the updated count. Because it reads the current committed value rather than a snapshot, it re-checks capacity with the first request’s result already applied. The read-modify-write is serialized, and the Lost Update goes away.
The price is waiting. Transactions competing for the same row queue up, so throughput drops. And if different transactions lock several rows in opposite orders, a deadlock can occur. So pessimistic locking suits resources where conflicts really are frequent — a popular reservation slot that requests pile onto.
Optimistic Locking
If conflicts are rare, locking every time is overkill. An optimistic lock assumes conflicts almost never happen and proceeds without a lock, checking for a conflict at save time.
In JPA, you add a version column.
@Version
private Long version;
When you modify the entity, JPA puts the version read earlier into the UPDATE’s condition.
UPDATE slot SET count = ?, version = version + 1 WHERE id = ? AND version = ?
When two transactions read the same version and each saves, the one that commits first bumps the version. The second UPDATE no longer matches on WHERE version = ?, updates zero rows, and JPA throws an OptimisticLockException. The side that detects the conflict reads the latest value and retries.
Because it takes no database lock, there’s no contention cost. But if conflicts are frequent, repeated retries make it a loss instead. So optimistic locking suits workloads where conflicts are rare — where most requests touch different resources.
Locks and the Transaction Boundary
Neither lock holds up without a transaction. A lock’s lifetime equals the transaction’s, so an acquired lock is released when the transaction ends by commit or rollback. So if you run a pessimistic-lock query with no open transaction, JPA doesn’t pass over it quietly — it throws a TransactionRequiredException. You declared a lock, and with no transaction to hold it, it fails immediately.
That locks work only on top of a transaction leads to a precondition: the transaction has to actually be open. An annotation doesn’t always open one, but that’s a separate topic.
There’s also what a lock doesn’t cover. A lock only serializes competing access; it doesn’t check whether that access is correct. An invariant like “the reservation count must stay under capacity” still has to be checked in code. Derived state is the dangerous case. If you store whether a slot is reservable as a separate enable flag instead of computing it from count and capacity each time, a window opens where the source condition and the flag disagree. A lock doesn’t close that gap.
Choosing Between Them
Pessimistic and optimistic locking aren’t ranked; they differ in what they assume about conflict frequency.
Pessimistic locking fits when:
- Requests pile onto the same resource and conflicts are frequent (a popular slot, stock decrements)
- Retries are expensive or awkward
- Waiting is preferable to failing on conflict
Optimistic locking fits when:
- Conflicts are rare and most requests touch different resources
- Reads dominate and write contention is low
- You want throughput without waiting, and can absorb the rare conflict with a retry
In practice the two get mixed. Most paths use an optimistic lock, and a pessimistic lock goes only on the few resources where conflicts concentrate.
Wrap-up
A transaction handles atomicity, an isolation level handles visibility, and a lock handles serialization. Because the three solve different problems, adding a transaction doesn’t make concurrency problems disappear. The Lost Update that read-modify-write contention creates has to be serialized away with a lock.
A pessimistic lock locks up front and turns conflict into waiting; an optimistic lock proceeds and absorbs conflict through retries. Which one is better is decided by how frequent conflicts are. And even after a lock has serialized access, the invariant that access must uphold stays the code’s responsibility.
References
- What RDB Transaction ACID Actually Guarantees — the starting point for the atomicity a transaction guarantees
- What Isolation Levels Actually Prevent — a lock covers the Lost Update that plain reads don’t
- @Transactional Only Works Through the Proxy — a lock works only when a transaction is actually open