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)
| # | Scenario | What it shows |
|---|---|---|
| S1 | RMW without @Version | Lost Update reproduction |
| S2 | @Version, no retry | Detection, no recovery |
| S3 | @Retryable(3) + backoff=0 | Retry stampede |
| S4 | @Retryable(5) + exp + full jitter | Distributed retries |
| S5 | Self 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 stale | JPA first-level cache | |
|---|---|---|
a == b | false | true |
| retry_count result | 0 (lost) | 1 (correct) |
Distributed Lost Update needs locks; self Lost Update needs the first-level cache.
8. Operational rules
| Environment | Recommendation |
|---|---|
| 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 / decrement | UPDATE ... 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
- AWS Architecture Blog — Exponential Backoff and Jitter
- Toss SLASH22 — Delivering one Apple share to the customer — JPA OptimisticLock + distributed lock + MVCC