A concurrent update makes a flush fail with jakarta.persistence.OptimisticLockException. Describe a retry strategy that is actually correct — what state you must discard, what you must re-read, and when retrying is the wrong answer.
answer
- Fails at flush -> whole transaction, retry outside it
- Discard EM: snapshots are poisoned
- Re-read, re-apply intent, not stale values
- Cap attempts + jitter; count retries as a metric
- Side effects must be idempotent or after-commit
basics
~20 sRoll back and discard the persistence context — it holds a snapshot now known to be wrong. Retry the whole transactional unit: open a fresh context, re-read the row, re-apply the business intent (not the stale field values), flush again. Bound the attempts, add jitter, and only retry work that is safe to redo.
solid answer
~60 sThe exception is thrown at flush, so it fails the whole transaction, not one statement. A correct retry therefore lives **outside** the transaction boundary. The recipe: 1. **Discard everything.** Roll back and close the `EntityManager`. After any `PersistenceException` the persistence context is unusable — its snapshots and identity map no longer reflect the database. 2. **Re-read, then re-apply intent.** Start a new transaction, load the entity fresh, and re-run the business operation (`balance -= amount`, `status = SHIPPED`) rather than reapplying the values you computed from the stale copy — otherwise you reinstate the lost update you were protected against. 3. **Bound it.** Three to five attempts with a short randomised backoff. Unbounded retries turn a hot row into a live-lock and a CPU sink. 4. **Keep side effects out.** Anything non-idempotent — emails, payments, external calls — must move after commit or be made idempotent; the failed attempt already ran the method body. Retry is wrong when the conflict is semantic: a human edited the same form, or the operation is not recomputable. Then surface a conflict and let the user decide.
code
java · 14 linespublic BigDecimal withdraw(long id, BigDecimal amount) {
for (int attempt = 1; ; attempt++) {
try {
return txTemplate.execute(() -> { // fresh EntityManager + transaction
Account a = em.find(Account.class, id);
a.withdraw(amount); // intent re-applied to fresh state
return a.getBalance();
});
} catch (OptimisticLockException e) {
if (attempt >= 4) throw new ConflictException(id, e);
sleepJitter(attempt);
}
}
}go deeper
Know that the operation must be redone from scratch on freshly loaded data, in a new transaction, and that you cannot keep using the old EntityManager.
Describe the loop concretely — rollback, new context, re-read, re-apply, capped attempts — and explain why the exception surfaces at commit.
Cover intent-versus-values, backoff with jitter, side-effect handling, conflict responses to the caller, and metrics that reveal a hot row before it becomes an incident.
Judge when retry is the wrong tool at all: contention hotspots needing atomic SQL or model changes, human-authored conflicts needing UX, and long transactions that should be shortened first.
## Why the retry cannot be local The optimistic check runs at flush, which under the default flush mode usually means **at commit**. By then your service method has already returned, so the failure is not attached to a particular `setter` or `save` call — it fails the entire unit of work. Two consequences: - A `try/catch` around a repository call typically catches nothing. - Retrying "the update" is meaningless; you retry **the transaction**. ## Step 1 — throw the context away After any `PersistenceException`, JPA declares the persistence context's state undefined and the transaction rollback-only. Concretely: the identity map still holds the entity with a version Hibernate now knows is stale, the snapshots are wrong, and further flushes on that context are undefined behaviour. So you must roll back and **close** the `EntityManager` (or let the framework close it) before the next attempt. Reusing it is the single most common bug in hand-written retry code — it produces either an immediate second failure or, worse, an inconsistent write. ## Step 2 — re-read and re-apply *intent* The distinction that separates a correct retry from a silent data loss: ```java // WRONG: reinstates the value computed from stale data fresh.setBalance(staleComputedBalance); // RIGHT: re-run the operation against the fresh state fresh.withdraw(amount); ``` Optimistic locking exists to stop you overwriting someone else's change. If the retry writes the value you computed before, you have defeated it manually. Express the operation as a function of current state — a delta, a state transition, a recomputation — and re-run it on the freshly loaded entity. If the operation cannot be expressed that way, it probably should not be retried automatically at all. The re-read must also **re-validate**: the fresh state may now violate a precondition (the order was cancelled, the balance is insufficient), in which case the correct outcome is a business error, not another attempt. ## Step 3 — bound and space the attempts - **Cap attempts** (3–5). Every retry is a full transaction: connection, queries, work. - **Randomised backoff.** Without jitter, contending threads resynchronise and collide again; a few milliseconds of randomness measurably improves throughput. - **Observe it.** Count retries and failures-after-retry per operation. A rising retry rate is an early warning that a row has become a hotspot; a flat 0 tells you the mechanism is untested. - **Fail visibly.** After the cap, return a conflict to the caller (in an HTTP API, `409`) rather than pretending success. A practical detail: the retry must wrap the transactional call from **outside**, so the framework starts a genuinely new transaction and context. Calling a transactional method from within the same object or within the failed transaction reuses the doomed one. ## Step 4 — side effects The body of the failed attempt ran. If it sent an email, charged a card, or published an event outside the transaction, the retry duplicates it. Options: move side effects to after-commit hooks, make them idempotent with a key, or record intent transactionally (outbox) and let a separate process perform it. This is not optional detail — it is the difference between a retry and double-charging a customer. ## When retrying is the wrong answer - **Human-authored conflicts.** Two people edited the same record in a form. Re-running "set these fields" would overwrite the other person's edits by definition; the right response is to tell the user, ideally showing what changed. - **Non-recomputable operations.** If the new value depends on data the user saw, redoing it silently makes a decision on their behalf. - **Chronically hot rows.** If a single row is contended constantly — a global counter, an aggregate total — retries do not fix the design; they burn resources at increasing rates. Replace with an atomic `update ... set n = n + ?`, a per-partition row, or an append-and-aggregate model. - **Long transactions.** The longer the unit of work, the higher the chance of losing again; shorten the transaction before adding retries. ## Sketch ```java for (int attempt = 1; ; attempt++) { try { return tx.execute(() -> { // new EM + tx each time Account a = em.find(Account.class, id); a.withdraw(amount); // re-applied intent return a.getBalance(); }); } catch (OptimisticLockException e) { if (attempt == MAX) throw new ConflictException(id, e); sleep(backoffWithJitter(attempt)); } } ``` The important parts are that the lambda opens a *new* context, that the operation is re-derived from fresh state, that attempts are capped, and that exhaustion produces a conflict rather than a silent partial result.
- Why can't you simply catch the exception, clear the EntityManager and flush again?Because after any PersistenceException the persistence context's state is undefined and the transaction is marked rollback-only. Its snapshots and identity map reflect a database state that no longer exists, so a second flush is either rejected or writes inconsistent data. The only safe move is to roll back, close the context, and start a fresh transaction.
- How do you keep retries from duplicating side effects?Move non-transactional effects out of the transactional body — publish them after commit, or record them transactionally in an outbox that a separate process drains. Where the effect must stay inline, give it an idempotency key so repeating it is a no-op. Otherwise every retry repeats whatever the failed attempt already did.
- When would you not retry at all?When the conflict is semantic rather than mechanical: two humans edited the same record, or the value depends on what the user saw. Automatic retry would silently overwrite the other party's decision. Also when the row is chronically contended — there the answer is a design change such as an atomic increment or partitioned rows, not more attempts.
Like being told your edit was rejected because the document moved on: you reopen the current version and redo your change there, rather than pasting the paragraph you wrote against the old one.
saying these in an interview costs you the question
- Retrying inside the same transaction or reusing the EntityManager after the exception.
- Re-applying values computed from the stale copy, which reinstates the lost update.
- Unbounded retry loops with no backoff or jitter.
- Ignoring side effects already performed by the failed attempt.
- Treating a persistently high retry rate as normal instead of as a signal to change the data model.