Concurrency — Optimistic & Pessimistic Locking, MVCC
Two concurrent transactions touching the same row is unavoidable in any real application — @Version-based optimistic locking and JPA's pessimistic lock modes are the concrete tools for handling that collision correctly.
Learning objectives
- Implement optimistic locking via @Version and handle OptimisticLockException with a retry strategy.
- Implement pessimistic locking via LockModeType and explain what it guarantees that optimistic locking doesn't.
- Choose the correct locking strategy for a given contention level and correctness requirement.
Two users editing the same record at the same time is unavoidable in any real application with concurrent traffic — this chapter is where JPA's specific tools for handling that collision become concrete, working code.
📖 Story
Imagine two editors working on the same shared document offline, each making changes, then both saving back at the same time. Whoever saves last silently overwrites the other's changes — unless something detects the collision. Here's that exact silent-overwrite bug, in code, with no protection:
// Session A loads a customer, changes the email Customer a = entityManager.find(Customer.class, 1L); a.setEmail("new-a@example.com"); // Session B (concurrently) loads the SAME row, changes the phone number Customer b = entityManager.find(Customer.class, 1L); b.setPhone("555-1234"); // A commits first. B commits second — and SILENTLY OVERWRITES A's email // change back to whatever it was, since B never even saw A's update.
Here's the fix — @Version:
@Entity public class Customer { @Id @GeneratedValue private Long id; private String email; private String phone; @Version private Long version; // <- Hibernate manages this automatically }
Now, when Session B tries to commit, Hibernate's generated UPDATE includes WHERE id = ? AND version = ? — using the version B originally read. Since A already bumped the version, B's WHERE clause matches ZERO rows, and Hibernate throws OptimisticLockException instead of silently overwriting anything.
@Version — a field JPA uses to implement optimistic locking, incremented automatically on every update. Optimistic locking — proceeding without a lock, detecting a conflict only at write time via the version check. Pessimistic locking — acquiring an actual database lock upfront, so a conflicting transaction simply waits. OptimisticLockException — thrown when a version check fails.
Let's continue this chapter's Customer example with the retry logic B's session actually needs, and the alternative strategy for a different scenario.
Handling the conflict — a retry loop
int attempts = 0; while (attempts < 3) { try { Customer b = entityManager.find(Customer.class, 1L); // re-read, gets LATEST version b.setPhone("555-1234"); entityManager.flush(); break; // success } catch (OptimisticLockException e) { attempts++; // retry with fresh data } }
Pessimistic locking — for a different kind of problem
Optimistic locking fits low-contention cases like this chapter's customer-edit example. But imagine a flash sale, where hundreds of buyers are all trying to decrement the SAME product's last few units of stock at once — an optimistic-locking retry storm under that much contention would itself become a performance problem:
Product product = entityManager.find(Product.class, productId, LockModeType.PESSIMISTIC_WRITE); // Translates to SELECT ... FOR UPDATE — any other transaction wanting // this SAME row simply WAITS until this one finishes, instead of racing. if (product.getStock() > 0) { product.setStock(product.getStock() - 1); }
Hibernate implements @Version checking by including the loaded version value directly in the generated UPDATE's WHERE clause, then checking the JDBC driver's reported affected-row count — a count of zero (this chapter's Session B scenario) means the WHERE clause matched nothing, which Hibernate translates into OptimisticLockException. Pessimistic locking delegates directly to the database's native row-locking mechanism (SELECT ... FOR UPDATE) — LockModeType is a portable, JPA-standard way of requesting that lock without writing raw SQL.
- This chapter's customer-profile edit example is the classic optimistic-locking case — conflicts are rare, and "someone else changed this, please refresh" is a perfectly acceptable outcome.
- This chapter's flash-sale inventory example is the classic pessimistic-locking case — with many concurrent buyers, making transactions wait briefly beats an optimistic retry storm.
- Default to optimistic locking (
@Version) for the overwhelming majority of entities — it costs essentially nothing when there's no actual conflict. - Reserve pessimistic locking specifically for high-contention, correctness-critical operations, like this chapter's flash-sale example.
- Always implement retry logic for
OptimisticLockException, exactly like this chapter's retry-loop example.
⚠️ Why this keeps happening
Both strategies "work" perfectly fine in single-user testing, where there's no second transaction around to collide with — this entire class of bug is invisible until real concurrent traffic hits the same rows.
- Not adding
@Versionto entities genuinely subject to concurrent edits — exactly this chapter's opening silent-overwrite scenario. - Not implementing retry logic for
OptimisticLockException, treating a normal, expected-under-contention outcome as an unhandled error. - Using pessimistic locking for a long, user-facing operation (an editable form left open for minutes) — the lock stays held that whole time, blocking every other transaction wanting the same row.
Optimistic locking has essentially zero overhead when no conflict occurs. Pessimistic locking pays real lock-wait cost regardless of whether a conflict would have happened — worth it specifically for genuinely hot, contended rows like this chapter's flash-sale example.
In financial operations, an undetected lost update (missing @Version) is a genuine integrity/security risk, not just a data-quality one.
Track OptimisticLockException rate as a production metric — a rising rate signals genuinely growing contention on specific entities.
Document, per entity, which locking strategy was chosen and why — this reasoning is invisible from the code alone.
- Reproduce this chapter's exact two-session silent-overwrite scenario without
@Version, then add it and confirmOptimisticLockExceptionis thrown instead. - Implement the retry loop from this chapter's Concept Overview and confirm it succeeds on a second attempt.
- Reproduce this chapter's flash-sale pessimistic-locking example and confirm a second concurrent transaction blocks until the first completes.
✓ Quick recap
@Versionimplements optimistic locking — an automatic version check in the UPDATE's WHERE clause detects a concurrent conflict, exactly like this chapter's two-session example.- Pessimistic locking (
LockModeType.PESSIMISTIC_WRITE) acquires an actual lock upfront, making conflicting transactions wait. - Default to optimistic for low-contention cases; reserve pessimistic for high-contention, correctness-critical hot paths like a flash sale.
Want a visual for this concept?
Generate a diagram tailored to “Concurrency — Optimistic & Pessimistic Locking, MVCC” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →