skip to content

Your write is an UPDATE guarded by a version column, and it sometimes reports zero affected rows. Walk through how you would build the retry loop around that failure: what must be redone, how many attempts, what backoff, and what would make a retry unsafe.

level: middleimportance: must knowfreq 55%

answer

  1. Rowcount 0 = stale read, not a blip
  2. Retry the unit of work, re-read, recompute
  3. Cap 3–5 + jittered backoff, then 409
  4. Retry decorator outside the transaction
  5. Side effects after commit / idempotent key

basics

~20 s

Zero affected rows means someone else wrote the row, so your in-memory copy is stale. Retry the whole unit of work — new transaction, fresh read, recompute, write again — not just the UPDATE. Cap attempts (3–5), use jittered backoff, and keep external side effects out of the retried block or make them idempotent.

solid answer

~60 s

Zero affected rows is a *conflict signal*, not a transient network blip. It means the row changed after I read it, so everything I computed from that read is suspect. The loop therefore wraps the **entire unit of work**: begin a new transaction, re-read the row and its current version, re-run the business logic against the fresh state, re-issue the guarded update. Re-issuing the same UPDATE with the old version can only fail forever; re-issuing it with the new version but the old computed value re-introduces the lost update the version column exists to prevent. Practical details: cap retries at three to five and then surface a conflict error rather than spinning — under sustained contention, retrying harder makes throughput worse. Add a short randomized backoff so competing writers do not resynchronize. Emit a metric on conflicts; a rising rate is a schema or design signal. Retry is unsafe when the unit of work has already produced external effects — an email, a payment call, a message published. Move those after commit, or make them idempotent with a stable key.

code

text · 10 lines
text
for attempt in 1..MAX:
    begin transaction
        row = SELECT ... WHERE id = ?          # fresh read each attempt
        newState = businessLogic(row)          # recompute from fresh state
        n = UPDATE ... WHERE id = ? AND version = row.version
        if n == 1: commit; return SUCCESS
    rollback
    sleep(jitter(attempt))
return CONFLICT   # surface to caller; do not loop forever
# side effects (email, payment, publish) happen only after commit

go deeper

for a junior

Know that zero affected rows means a concurrent change, that you must re-read before retrying, and that retries need a limit.

for a middle

Explain the retry boundary sitting outside the transaction, fresh entity state per attempt, capped attempts with jitter, and which errors are retryable.

for a senior

Add side-effect safety (outbox, idempotency keys), conflict metrics, starvation under sustained contention, and when to switch the path to a short pessimistic lock.

for a principal

Argue about the design that removes the conflict — sharded counters, narrower rows, relative statements — and set system-wide policy for retry budgets and conflict SLOs.

## What zero affected rows actually tells you An optimistic write is a compare-and-set: `UPDATE ... SET v = v + 1 WHERE id = ? AND version = ?`. The engine matched the primary key but not the version predicate, so the row exists and its version has moved on. A concurrent transaction committed between your read and your write. (If the row could also have been deleted, distinguish the two cases with a follow-up existence check, otherwise you will retry forever against a row that is gone.) The important consequence is that *every value you derived from that read is stale*, not just the version number. If you computed `newBalance = oldBalance - amount`, the `oldBalance` you used is no longer current. ## The loop wraps the unit of work, not the statement The naive fix — catch the conflict and re-run just the UPDATE — is wrong in both possible forms. Re-running it with the original version will fail again deterministically, forever. Re-running it with the freshly-read version but the previously computed value writes stale data over the winner's change, which is precisely the lost update you were defending against. So the retry boundary is: rollback (or let the transaction end), start a fresh transaction, re-read current state, re-execute the domain logic, re-issue the guarded write. Structurally that means the transactional work lives in a callable unit — a function, a command handler — and the retry decorator sits *outside* the transaction boundary, never inside it. A retry attempted inside an already-failed transaction is another classic mistake: in many engines the transaction is unusable after certain errors until it is rolled back or unwound to a savepoint. ## Attempt caps and backoff Retry is a bet that the conflict was incidental. Under low contention two attempts nearly always suffice. Under high contention the loop degenerates: N writers all redo work, one wins, N-1 retry, and the useful throughput of the row is bounded by the transaction duration while CPU burns on discarded work. Convoying is real — the losers all wake at the same instant and collide again. So: cap attempts at roughly three to five; add a small randomized (jittered) delay between attempts so competitors desynchronize; and on exhaustion return a domain-level conflict to the caller — an HTTP 409-style outcome for a user edit, or a scheduled re-enqueue for a background job. Never loop unbounded, and never sleep for long inside an open transaction, because that turns a concurrency problem into a lock-hold problem. Also decide which failures are retryable at all. Conflict signals (zero rowcount, an ORM's optimistic-lock exception, a serialization-failure or deadlock-victim error) are retryable. A constraint violation, an authorization failure, or a validation error is deterministic — retrying just wastes attempts and hides the real error. ## Making retry safe Retry is only correct if the unit of work is *replayable*. Two hazards: 1. **External side effects.** If the block sends an email, calls a payment API, or publishes a message, a retry duplicates it. Fixes: move effects after successful commit (an outbox row written in the same transaction, dispatched afterwards), or make the call idempotent using a stable key derived from the business operation rather than generated per attempt. 2. **Mutable in-memory state.** If the retried function mutates an object graph or an accumulator that survives the failed attempt, attempt two starts from corrupted state. Re-hydrate entities from the database at the start of each attempt; do not reuse the stale objects. In ORM terms this usually means a fresh persistence context per attempt, since the old one still caches the losing snapshot. ## Observability and the escape hatch Instrument conflicts per entity type. A steady conflict rate on one row is a design smell: the row is a hot spot, and the fix is usually structural — split a counter into shards and sum on read, move the contended field to its own row so unrelated edits stop colliding, or replace read-modify-write with a single relative statement (`SET qty = qty - 1 WHERE qty >= 1`) that the engine serializes internally with no application retry at all. That last option is the cleanest answer whenever the new value is a pure function of the old one, and a strong candidate to name in an interview. Finally, if conflicts stay high after those changes, switch that specific path to a short pessimistic transaction: taking the lock up front converts wasted work into a brief wait, which under heavy contention is the better trade.

  • The update is just `qty = qty - 1`. Do you still need the read-recompute-retry loop?
    No. When the new value is a pure function of the stored value, express it as a single relative statement with a guard, such as `UPDATE stock SET qty = qty - 1 WHERE id = ? AND qty >= 1`. The engine locks the row for the duration of the statement and applies the arithmetic to the current value, so there is no stale read to invalidate. You check the affected-row count to know whether the guard held, but there is nothing to recompute and no application-level retry.
  • How is this loop different from retrying a deadlock-victim or serialization-failure error?
    Structurally it is the same loop: both mean the transaction must be replayed from the beginning against fresh state, with a capped attempt count and jittered backoff. The difference is where the signal comes from — the engine aborts the transaction for you on deadlock or serialization failure, whereas an optimistic conflict is reported as a successful statement affecting zero rows, so the application must check and raise it itself. In practice teams route all three into one retry helper.

saying these in an interview costs you the question

  • Re-running only the UPDATE instead of re-reading and recomputing
  • Retrying with the new version but the previously computed value — reintroducing the lost update
  • Unbounded retry loops with no cap and no backoff
  • Retrying inside the same failed transaction instead of starting a fresh one
  • Leaving emails, payments, or message publishes inside the retried block
  • Treating constraint violations or validation errors as retryable

context