RizTech Academy logo
RizTech Academy
Concurrency, Transactions and AsyncLesson 2 of 540 min

Optimistic and pessimistic locking, and the lost-update race

The previous lesson ended on a warning: normal transaction isolation does not prevent the lost update — two requests reading a value, each changing it, the second overwriting the first. This is the most important concurrency bug in web applications: invisible in development (one request at a time), corrupting data in production (many at once). This lesson fixes it, two ways — optimistic and pessimistic locking — with optimistic locking verified against a running Dakiya.

The lost update, concretely

A Dakiya scenario: two staff members update the same parcel at the same time. Each request loads the parcel, changes a field, and saves:

Parcel p = repository.findById(id).orElseThrow();   // both requests read the SAME state
p.setStatus(newStatus);                              // each changes it
repository.save(p);                                  // the second save overwrites the first

If the two overlap — both read, then both write — the second write is based on the value before the first write, and silently clobbers it. One update is lost, with no error. On a parcel's status this is a wrong state; on a balance or a stock count it is money or inventory vanishing. No transaction isolation level below SERIALIZABLE stops this on its own, because each write is individually valid. You need locking.

Optimistic locking: @Version

Optimistic locking assumes conflicts are rare, lets both transactions proceed, and detects a conflict at write time. You add a @Version field to the entity:

@Entity
public class Hub {
    @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;

    @Version
    private Long version;      // Hibernate manages this — you never set it
}

Hibernate now includes the version in every update: UPDATE hub SET name = ?, version = 2 WHERE id = ? AND version = 1. If another transaction already bumped the version to 2, the WHERE version = 1 matches zero rows, and Hibernate throws an OptimisticLockException (Spring wraps it as ObjectOptimisticLockingFailureException). Verified against the running app: the hub started at version 0; transaction A updated it (version → 1); transaction B, holding a stale copy at version 0, tried to update and got the optimistic-lock exception — B's stale update was rejected, not silently lost.

That is the fix: the lost update becomes a detected conflict. You then handle it — typically by retrying (reload the fresh state, reapply the change) or returning a 409 Conflict so the client retries. Optimistic locking is the right default for web apps: it adds no locks (no contention when there is no conflict), costs only a version column, and turns silent corruption into a visible, handleable conflict. Add @Version to any entity that concurrent requests update.

Pessimistic locking: lock the row up front

Pessimistic locking takes the opposite stance: assume conflicts are likely, and lock the row when you read it, so no one else can touch it until you commit:

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Parcel p where p.id = :id")
Parcel findByIdForUpdate(@Param("id") Long id);

This issues a SELECT ... FOR UPDATE, taking a database lock on the row. Any other transaction trying to lock the same row waits until this one commits — so the two cannot interleave, and the lost update cannot happen. It must run inside a transaction (the lock lives for the transaction's duration).

Optimistic vs pessimistic, when to use which:

  • Optimistic (@Version) — the default. Best when conflicts are rare (most web CRUD). No locking, no waiting; a conflict is detected and retried. Scales well.
  • Pessimistic (SELECT ... FOR UPDATE) — when conflicts are frequent or a retry is unacceptable, or the operation is a short critical section (decrementing limited stock, allocating a unique slot). It guarantees no conflict but serialises access to the row, so it reduces concurrency and risks deadlocks — keep the locked section short.

The rule: optimistic by default (rare conflicts, no contention); pessimistic when contention is high or a conflict must be prevented outright — and keep pessimistic locks brief.

The database still needs its own guarantees: constraints

Locking coordinates updates; a related race is concurrent inserts creating a duplicate — two requests both check "does this tracking code exist?", both see no, both insert. No application lock reliably prevents this across transactions; the fix is a database constraint (UNIQUE), which the database enforces regardless of timing — the second insert fails with a constraint violation. As in the Django course: existence races are solved by constraints, not by check-then-act in code. So the full concurrency toolkit is: @Version for the lost update on updates, pessimistic locks for high-contention critical sections, and unique constraints for insert races — each for its situation.

Handling a detected conflict

Detecting a conflict is only useful if you handle it well. The common approaches:

  • Retry — catch ObjectOptimisticLockingFailureException, reload the current state, reapply the change, save again (Spring's @Retryable, or a manual loop with a bounded number of attempts). Right when the operation is naturally idempotent-ish (adjusting a status).
  • Return 409 Conflict — tell the client their update was based on stale data; they refetch and decide. Right when a human should confirm (they edited an old version of a record).

Never swallow the exception and carry on as if it succeeded — that reintroduces the lost update you just detected. The principle tying the lesson together: concurrent writes to the same row need locking, not just transactions; use optimistic @Version by default, pessimistic locks for hot rows, unique constraints for insert races — and turn the detected conflict into a retry or a 409, never a silent success.

Check your work

The lost update. Two overlapping read-modify-write cycles: the second write overwrites the first, one update lost silently. No isolation below SERIALIZABLE stops it — you need locking.

Optimistic (@Version). A version field; Hibernate updates WHERE version = ? and throws OptimisticLockException/ObjectOptimisticLockingFailureException on a stale write. Verified: version 0 → 1 after A; B's stale update rejected. Default for web apps — no contention, conflict detected and retried.

Pessimistic (SELECT ... FOR UPDATE). @Lock(PESSIMISTIC_WRITE): lock the row on read; others wait. For high contention or must-not-conflict critical sections; serialises access, risks deadlocks — keep short.

Constraints for insert races. Concurrent duplicate inserts are prevented by a UNIQUE constraint (the DB enforces it regardless of timing), not application checks.

Handle the conflict. Retry (reload, reapply) or return 409 — never swallow the exception (that brings back the lost update).

Practice

  1. Reproduce the lost update: load a hub into two detached copies, update+save A (version→1), then update+ save B (stale version) and confirm ObjectOptimisticLockingFailureException (reproduce the verified result).
  2. Handle it by catching the exception, reloading, reapplying, and saving — a retry.
  3. Add a pessimistic @Lock(PESSIMISTIC_WRITE) query and reason (or demonstrate with two sessions) about how a second transaction waits.
  4. Decide, for three Dakiya operations (update parcel status, allocate a unique tracking code, decrement a limited daily quota), whether optimistic, pessimistic, or a unique constraint fits — and why.
  5. Add a UNIQUE constraint on tracking_code and confirm two concurrent inserts of the same code cannot both succeed.
  6. Swallow the optimistic-lock exception and carry on; observe the lost update returns — then handle it properly.

Official documentation

Next: async work with @Async and thread pools.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship