Skip to content
Forward Engineering
Go back

JPA Optimistic Lock and the Retry Stampede Trap — 6 Scenarios @Version Cannot Cover Alone

- views

Table of contents

Open Table of contents

Why this article

Everyone “knows” the answer to concurrency on a JPA entity: put @Version on it. That answer is half right. The lesser-known half is what you do with the OptimisticLockException it throws — and that detail decides whether your update is correct, fast, both, or neither.

This article measures the same update intent (priority += 1) across six scenarios to draw the trade-off boundary precisely.


1. The setup

- entity:    auto_reply_rule (id, owner_id, priority, @Version version)
- initial:   priority=0, version=0
- workers:   100 concurrent workers, each priority +1
- correct:   final priority = 100 (zero loss)
#ScenarioWhat it shows
S1RMW without @VersionLost Update reproduction
S2@Version, no retryDetection, no recovery
S3@Retryable(3) + backoff=0Retry stampede
S4@Retryable(5) + exp + full jitterDistributed retries
S5Self Lost Update (DC-4)JDBC vs JPA first-level cache
S6(combined into next article)

2. S1 — Read-modify-write without @Version

@Transactional
public void incrementWithoutVersion(Long ruleId) {
    Integer current = jdbc.queryForObject(
        "SELECT priority FROM auto_reply_rule WHERE id = ?", Integer.class, ruleId);
    jdbc.update("UPDATE auto_reply_rule SET priority = ?, updated_at = NOW(6) WHERE id = ?",
        current + 1, ruleId);
}

100 workers run this concurrently. Result: priority < 100. Two workers SELECT the same value, both write value + 1, one increment is silently lost. Every worker reports success — the bug is in the data, invisible without correctness checks.


3. S2 — @Version only, no retry

@Entity class AutoReplyRule {
    @Version Long version;
    public void incrementPriority() { this.priority += 1; }
}

@Transactional
public void incrementWithVersion(Long ruleId) {
    AutoReplyRule rule = repo.findById(ruleId).orElseThrow();
    rule.incrementPriority();
}

Hibernate emits:

UPDATE auto_reply_rule
   SET priority=?, version=?+1, updated_at=?
 WHERE id=? AND version=?

If 0 rows match, Spring throws ObjectOptimisticLockingFailureException. Lost Update is prevented, but only some workers succeed. Recovery is the caller’s job.


4. S3 — Retry stampede

@Retryable(retryFor = OptimisticLockingFailureException.class,
           maxAttempts = 3, backoff = @Backoff(delay = 0))
@Transactional
public void incrementWithRetryNoBackoff(Long ruleId) { ... }

99 workers fail, all retry simultaneously, 1 succeeds, 98 fail again, all retry simultaneously again. Total elapsed grows; effective throughput stays low. Spring Retry without jitter is a stampede generator.

The AWS Architecture Blog — Exponential Backoff and Jitter is the canonical reference for why this happens and what fixes it.


5. S4 — Exponential + full jitter

@Retryable(retryFor = OptimisticLockingFailureException.class, maxAttempts = 5,
           backoff = @Backoff(delay = 5, maxDelay = 100, multiplier = 2.0, random = true))
@Transactional
public void incrementWithRetryJitter(Long ruleId) { ... }

Each worker waits a random delay between attempts. Retries spread across time → fewer concurrent collisions → progress accumulates. Final priority = 100. This is the only safe shape of retry under contention.


6. AOP order — @Retryable outside @Transactional

For each retry to start a fresh transaction (and read a fresh version), @Retryable must wrap outside @Transactional. Spring’s default ordering does this. If @Transactional is outer, retries happen inside one transaction and the first-level cache hands back the same stale entity, retrying forever.

Self-invocation trap (expand)

Spring AOP only intercepts external calls. this.method() from inside the same class skips the proxy → no @Retryable, no @Transactional. Use a separate bean or AopContext.currentProxy().

W3 EXP-02 hit this exact trap once — every worker reported success while the balance stayed at 100. See JPA Spring Mastery #7 — AOP Self-Invocation.


7. S5 — Self Lost Update (DC-4)

A different family of Lost Update — inside one transaction.

JDBC stale read anti-pattern

@Transactional
public void selfLostUpdateJdbcStale(Long id) {
    Integer aRetry = jdbc.queryForObject(
        "SELECT retry_count FROM reply_request_dc4 WHERE id = ?", Integer.class, id);
    jdbc.update("UPDATE ... SET retry_count = ? WHERE id = ?", aRetry + 1, id);   // DB: 1
    // (later, using the stale aRetry variable)
    jdbc.update("UPDATE ... SET last_attempted_at = ?, retry_count = ? WHERE id = ?",
        Timestamp.from(now()), aRetry, id);   // ★ overwrites 1 with 0 (stale)
}

No lock prevents this — same transaction.

JPA first-level cache == guarantee

@Transactional
public boolean jpaIdentityProof(Long id) {
    ReplyRequestDc4 a = repo.findById(id).orElseThrow();
    ReplyRequestDc4 b = repo.findById(id).orElseThrow();
    return (a == b);   // ★ true — application-level repeatable read
}

Vlad Mihalcea — JPA First-Level Cache: two findById in the same transaction return the same Java instance. Two changes accumulate on one object, flushed as a single UPDATE.

JDBC staleJPA first-level cache
a == bfalsetrue
retry_count result0 (lost)1 (correct)

Distributed Lost Update needs locks; self Lost Update needs the first-level cache.


8. Operational rules

EnvironmentRecommendation
High contention (e.g. balance deduction)Pessimistic lock (SELECT ... FOR UPDATE) — see W3 EXP-02
Low contention (rule edit)Optimistic + retry with jitter
Atomic increment / decrementUPDATE ... SET col = col ± n WHERE ... AND col >= n

9. Conclusion

@Version alone is not enough. Without retry, only some succeed. Retry without backoff stampedes. Only retry with jitter is safe. And the entire family of self Lost Update trap is orthogonal — it needs the first-level cache, not a lock.

Same priority += 1, six combinations of @Version, retry, backoff, jitter, and first-level cache, five different outcomes. Senior interviews about JPA concurrency live in this trade-off space.


References

Official

Vlad Mihalcea

External

Sister posts


Share this post on:

Next Post
[JPA + Spring Mastery 01] L1 Cache · flush · Transaction Lifecycle — what readOnly really shaves off, dirty checking's true cost