advanced~4h

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 @Version to 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.

  1. Reproduce this chapter's exact two-session silent-overwrite scenario without @Version, then add it and confirm OptimisticLockException is thrown instead.
  2. Implement the retry loop from this chapter's Concept Overview and confirm it succeeds on a second attempt.
  3. Reproduce this chapter's flash-sale pessimistic-locking example and confirm a second concurrent transaction blocks until the first completes.

✓ Quick recap

  • @Version implements 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 →

Practice quiz

Next Step

Continue to Production Engineering — HikariCP, SQL Logging & Hibernate Statistics← Back to all Spring Data JPA & Hibernate Mastery chapters