skip to content

How do you decide between optimistic and pessimistic locking for a given operation, and how do you handle the resulting failures gracefully?

level: principalimportance: should knowfreq 26%

answer

  1. optimistic default; pessimistic for hot rows
  2. retry storm vs blocking tradeoff
  3. user think-time -> must be optimistic (send version)
  4. 409 for stale user write vs backoff-retry for server race
  5. atomic conditional UPDATE for hot counters

basics

~20 s

Use optimistic (@Version) by default — no locks, scales, retry rare conflicts. Use pessimistic (@Lock FOR UPDATE) only for hot, high-contention rows where retries would storm. Handle failures by catching the specific Spring exception and retrying with backoff or reporting a conflict.

solid answer

~40 s

Default to optimistic locking with @Version: it takes no database lock, scales horizontally, avoids deadlocks, and only pays a cost on the rare conflict. Switch to pessimistic @Lock(PESSIMISTIC_WRITE) when contention on a specific row is high enough that optimistic retries would storm — inventory decrement, seat allocation, wallet debit — because there serializing at the DB is cheaper than repeated failed retries. The decision hinges on conflict probability, cost of a retry, and how strict the invariant is. For failures: catch ObjectOptimisticLockingFailureException (optimistic) or CannotAcquireLockException/PessimisticLockingFailureException (pessimistic) and either retry with jittered exponential backoff for transient conflicts, or, if the user submitted stale data, surface a 409-style conflict so they can re-review. Keep pessimistic transactions short, order locks consistently, and cap retries so a hot row can't livelock.

code

java · 20 lines
java
@Service
class InventoryService {

    // Server-side race: safe to retry the whole unit of work.
    @Retryable(retryFor = ObjectOptimisticLockingFailureException.class,
               maxAttempts = 4,
               backoff = @Backoff(delay = 50, multiplier = 2, random = true))
    @Transactional
    public void decrementOptimistic(Long id) {
        Product p = repo.findById(id).orElseThrow();  // re-read each attempt
        if (p.getStock() <= 0) throw new OutOfStockException();
        p.setStock(p.getStock() - 1);                 // @Version guards commit
    }

    // Hot counter: an atomic conditional UPDATE beats both lock strategies.
    @Modifying
    @Query("update Product p set p.stock = p.stock - 1 " +
           "where p.id = :id and p.stock >= 1")
    int tryDecrement(@Param("id") Long id);           // returns rows updated
}

go deeper

for a junior

Know the default is optimistic and pessimistic is for high contention.

for a middle

Catch the right exceptions and retry transient conflicts.

for a senior

Weigh conflict probability, retry cost, and invariant strictness; implement jittered bounded retry and 409-on-stale-write.

for a principal

Set strategy policy per operation, prefer atomic conditional updates for hot counters, and govern retry/timeout budgets against SLAs and connection-pool limits.

## The core decision Both strategies prevent the *lost update* problem; they differ in *when* and *how* they detect/prevent conflict. | | Optimistic (`@Version`) | Pessimistic (`@Lock` FOR UPDATE) | |---|---|---| | DB lock | none | real row lock held for the tx | | Assumes | conflicts rare | conflicts likely | | Cost | cheap; retry on rare conflict | blocking; serializes writers | | Failure mode | `ObjectOptimisticLockingFailureException` at commit | blocking, then deadlock/timeout | | Scales | horizontally, no contention | limited by lock contention | | Deadlocks | none | possible | ## Decision factors 1. **Conflict probability.** Low (most web CRUD) → optimistic. High (a single hot counter under heavy concurrency) → pessimistic, because optimistic would produce a *retry storm* where many transactions repeatedly read the same version, all but one fail, and retry — wasting work. 2. **Cost of a retry.** If the transaction is expensive to redo (lots of computation, external side effects), blocking once via pessimistic may be cheaper than repeatedly retrying optimistically. 3. **Strictness of the invariant.** A hard business rule that must never be momentarily violated (never oversell the last unit) argues for pessimistic serialization or a DB-level constraint. 4. **User think-time in the loop.** If the 'transaction' spans a human editing a form across an HTTP round-trip, you **must** use optimistic locking — you cannot hold a DB lock across think-time. Send the `@Version` to the client and check it on submit. 5. **Cross-node scale.** Optimistic locking works uniformly across many app instances; pessimistic locking concentrates contention at the DB. ## Handling optimistic failures Catch `org.springframework.orm.ObjectOptimisticLockingFailureException`: - **Server-side race** (two backend transactions) → **retry** the whole operation with jittered exponential backoff, re-reading fresh state each attempt. Use Spring Retry `@Retryable` or a manual bounded loop. Cap attempts to avoid livelock. - **User submitted stale data** (they loaded version 3, someone else saved version 4) → do **not** blindly retry; return an **HTTP 409 Conflict** so the user re-reads current state and reconciles. Silent retry would clobber the newer data. ## Handling pessimistic failures Catch `CannotAcquireLockException` / `PessimisticLockingFailureException` (deadlock victim or lock timeout): - These are **transient** — retry with backoff. - Ensure **consistent lock ordering** (e.g. ascending PK) to minimize deadlocks. - Keep transactions **short** and never hold locks across remote calls. ## Retry mechanics ```java @Retryable(retryFor = { ObjectOptimisticLockingFailureException.class, CannotAcquireLockException.class }, maxAttempts = 4, backoff = @Backoff(delay = 50, multiplier = 2, random = true)) @Transactional public void adjustStock(Long id, int delta) { ... } ``` Key points: **jitter** (`random = true`) spreads retries so contenders don't collide again; **bounded attempts** prevent unbounded livelock; the transaction must be **idempotent per attempt** — re-read state at the start of each try, don't reuse a stale entity. ## Alternatives / complements - **Atomic DB update** (`UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty >= 1`) sidesteps entity locking entirely for simple counters — often the best answer for hot counters. - **Database constraints** (unique, check) as a backstop for invariants regardless of locking. - **Serializable isolation** for a whole unit of work, at higher abort rates. ## Anti-patterns to avoid - Pessimistic lock held across user think-time or an external HTTP call. - Unbounded optimistic retry (livelock on a hot row). - Blind retry of a *user-originated* stale write (data loss). - Choosing pessimistic 'to be safe' when conflicts are actually rare — you pay contention for nothing. ## Summary heuristic Start optimistic. Measure conflict rate. If a specific row is genuinely hot and retries hurt, move that operation to pessimistic (or an atomic conditional UPDATE), keep its transaction tiny, order locks, and bound retries everywhere.

  • A user loads a record, edits it for two minutes, and submits. Which locking strategy fits and how do you detect their edit conflicting with someone else's?
    Optimistic — you can't hold a DB lock across two minutes of think-time. Send the @Version to the client, and on submit include it so Hibernate's versioned UPDATE fails (0 rows) if someone else saved meanwhile. On ObjectOptimisticLockingFailureException, return HTTP 409 so the user re-reviews current state rather than clobbering it.
  • When is blindly retrying an optimistic-lock failure the wrong thing to do?
    When the conflict is user-originated stale data. Retrying would re-apply the user's outdated edit on top of the newer committed state, silently losing the other person's change. Instead surface a conflict (409) and let the user reconcile. Auto-retry is only safe for server-side races where re-reading fresh state is correct.
  • For a single hot inventory counter, why might an atomic conditional UPDATE beat both locking strategies?
    UPDATE ... SET qty = qty - 1 WHERE id = ? AND qty >= 1 does the check-and-decrement in one atomic DB statement — no entity load, no version-retry storm, no held pessimistic lock. The affected-row count tells you success or out-of-stock, giving maximum throughput on the hottest rows.

saying these in an interview costs you the question

  • Defaulting to pessimistic locking 'to be safe' when conflicts are rare.
  • Auto-retrying a stale user-submitted write, silently losing another user's committed change.
  • Holding a pessimistic lock across user think-time.
  • Unbounded retries causing livelock on a hot row.
  • Retry loops that reuse the stale entity instead of re-reading fresh state each attempt.

context