Software Engineer's Blog

From Pessimistic to Optimistic Locking: A Practical Guide to Retries and Backoff

From Pessimistic to Optimistic Locking: A Practical Guide to Retries and Backoff

If you’ve ever shipped a high-traffic feature — a flash sale, a coupon drop, a ticketing event — you’ve run into concurrency control sooner or later. The instinctive first move is to reach for a pessimistic lock at the database level, because correctness feels safer than throughput.

But under a real traffic spike, pessimistic locks can become the bottleneck themselves. In this post, I’ll walk through a concurrency scenario you’re likely to hit in production, and show how to replace pessimistic locking with optimistic locking plus a well-designed retry/backoff strategy.

1. The Problem: Why Pessimistic Locks Break Down

A pessimistic lock grabs a shared or exclusive lock on a database row the moment you touch it, blocking any other transaction from getting in. Consistency-wise it’s airtight — but the trade-off becomes brutal under spike traffic.

  • Spike traffic kills TPS. Every request has to line up for the lock. The wait queue grows, your connection pool drains, and throughput collapses.
  • The case for optimistic locking. Instead of locking the row in the database, you can manage consistency at the application layer using a version column (@Version). Optimistic locks only check for conflicts at write time, so there’s no waiting queue — which makes them a better fit when conflicts are rare to moderate. The catch is that under extreme contention (think a true first-come-first-served event), retry costs can explode, and you’ll want a different strategy altogether — more on that later.

Pessimistic Locking with JPA

With JPA, pessimistic locking is configured at the repository query level with @Lock. JPA translates this into a SELECT ... FOR UPDATE, so the database itself holds the row.

public interface CouponRepository extends JpaRepository<Coupon, Long> {

    // PESSIMISTIC_WRITE → SELECT ... FOR UPDATE (exclusive lock)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT c FROM Coupon c WHERE c.id = :id")
    Optional<Coupon> findByIdForUpdate(@Param("id") Long id);
}
@Service
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;

    @Transactional
    public void buyCoupon(Long couponId) {
        // The DB locks the row here — other transactions wait
        Coupon coupon = couponRepository.findByIdForUpdate(couponId)
            .orElseThrow(() -> new IllegalArgumentException("Coupon not found"));

        coupon.decreaseQuantity();
        // Lock is released when the transaction commits
    }
}

Consistency-wise this is rock solid. But the moment traffic spikes, every request piles up behind the lock — which is exactly the connection-pool exhaustion and TPS collapse described above.

📌 How to spot pessimistic locking at a glanceThe repository has @Lock(LockModeType.PESSIMISTIC_WRITE) or **PESSIMISTIC_READ**The generated SQL contains **FOR UPDATE**The entity itself has no annotation (locking is a query-level behavior)

Optimistic Locking with JPA

Optimistic locking flips the model: you add a @Version field to the entity, and the repository stays untouched. At write time, JPA automatically appends WHERE version = ? to the UPDATE and detects conflicts there.

@Entity
@Getter
@NoArgsConstructor
public class Coupon {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private int availableQuantity;

    // The version field is what makes this optimistic
    @Version
    private Long version;

    public void decreaseQuantity() {
        if (this.availableQuantity <= 0) {
            // Business exception — retrying won't change the outcome, so it must NOT be retried
            throw new IllegalStateException("Out of stock");
        }
        this.availableQuantity--;
    }
}
@Service
@RequiredArgsConstructor
public class CouponService {

    private final CouponRepository couponRepository;

    @Transactional
    public void buyCoupon(Long couponId) {
        // No lock — other transactions can enter at the same time
        Coupon coupon = couponRepository.findById(couponId)
            .orElseThrow(() -> new IllegalArgumentException("Coupon not found"));

        coupon.decreaseQuantity();
        // On commit, JPA compares the version. Mismatch → exception
    }
}

Side-by-Side Comparison

AspectPessimistic LockOptimistic Lock
Configured atRepository (@Lock)Entity (@Version)
Generated SQLSELECT ... FOR UPDATEUPDATE ... WHERE version = ?
Conflict detectedRead time (others wait)Write time (exception thrown)
Conflict handled byDB queues them up (automatic)App has to retry (manual)
Sweet spotVery high contentionLow to moderate contention
Main riskConnection-pool exhaustion, deadlocksRetry storms under heavy contention

📌 What @Version does NOT protect you from

Optimistic locking only verifies whether someone else modified the row between your read and your write. It says nothing about whether the operation makes business sense — for example, decrementing stock that’s already zero. You still need explicit checks for that in your domain logic, as shown above. And the IllegalStateException from that check is not retryable — retrying won’t magically refill the stock. We’ll handle this distinction explicitly via the isRetryable check in Section 3.

2. Four Things You Need to Get Right

Optimistic locking isn’t just about adding @Version. The hard part is the surrounding design — making sure conflicts don’t surface to the user as errors, and that the system holds up under contention. Here are the four pieces you can’t skip.

① Catch the Right Exceptions, Cap the Retries

You need to catch the specific exceptions thrown on conflict (for example, ObjectOptimisticLockingFailureException in Spring Data), and you need a hard ceiling on retries. Unbounded retries will take your service down faster than the original conflict ever would have.

② Spread the Load with Backoff

Retrying immediately after a conflict almost guarantees another conflict — and floods the database with wasted queries. Use exponential backoff combined with jitter so retries spread out over time instead of dogpiling.

③ Plan for the Failure Case (Fallback)

Sooner or later, even with backoff and retries, some requests will exhaust the limit and fail. A technical failure shouldn’t become a broken user experience. Design the fallback as part of the feature, not as an afterthought.

④ Make It Observable

If you can’t see your retry rate and exhaustion rate, you’re picking thresholds by gut feel. Expose retry.attempts and retry.exhausted counters via Micrometer or similar. A jump from a 1% retry rate to 30% is the kind of signal that catches a hot key before it takes down the service. In production, metrics matter more than code.

3. Implementation: A Custom AOP for Retries with Exponential Backoff

⚠️ The trap that bites everyone first: transaction boundaries

This is the #1 reason hand-rolled retry logic fails to work as expected.

If you slap @Transactional and @OptimisticRetry on the same method, the transaction is already marked rollback-only by the time the conflict exception fires. Retrying inside the same transaction will fail every single time. The retry has to live outside the transaction boundary.[Wrong] Retry inside @Transactional → same transaction → always fails
[Right] @OptimisticRetry → @Transactional → fresh transaction on each attempt

In practice, declare @Order on the retry aspect, or — better — split the retry-owning Facade/Service from the transactional Service entirely.

There’s one more gotcha: self-invocation. If you call a @Transactional method via this.foo() from within the same class, Spring’s proxy never gets a chance to intercept — meaning neither @Transactional nor @OptimisticRetry will apply. @Order alone won’t save you here. Splitting concerns across separate beans is the most reliable fix.

Spring Retry already gives you @Retryable(retryFor=..., backoff=@Backoff(delay=50, multiplier=2, random=true)) as a one-liner. So when does it make sense to roll your own AOP?

  • You need different backoff policies for different exception types (optimistic-lock conflicts vs. DB deadlocks vs. external API failures)
  • You want retry metrics tagged with business context (coupon ID, user ID)
  • You want to wire up the fallback queue in the same place

For straightforward retries, Spring Retry is plenty. The example below is intentionally simplified for clarity.

① The Custom Annotation

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface OptimisticRetry {
    int maxAttempts() default 3;       // Total attempts including the first (3 = 1 initial + 2 retries)
    long backoff() default 50;         // Base backoff in milliseconds
}

② The Aspect (Exponential Backoff with Jitter)

@Aspect
@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE) // Must wrap @Transactional, not the other way around
public class OptimisticRetryAspect {

    // 1) Pure optimistic-lock conflicts (version mismatch)
    private static final Set<Class<? extends Throwable>> OPTIMISTIC_LOCK_EXCEPTIONS = Set.of(
        ObjectOptimisticLockingFailureException.class, // Spring Data
        OptimisticLockException.class,                 // JPA
        StaleObjectStateException.class                // Hibernate
    );

    // 2) Transient DB-level conflicts (deadlock, lock timeout) — retryable, but a different beast
    private static final Set<Class<? extends Throwable>> TRANSIENT_DB_EXCEPTIONS = Set.of(
        CannotAcquireLockException.class
    );

    // Hard upper bound on backoff — guards against absurdly long sleeps and overflow
    private static final long MAX_DELAY_MS = 1_000L;

    @Around("@annotation(optimisticRetry)")
    public Object doRetry(ProceedingJoinPoint joinPoint, OptimisticRetry optimisticRetry) throws Throwable {
        int maxAttempts = optimisticRetry.maxAttempts(); // total attempts, first included
        long baseDelay = optimisticRetry.backoff();

        Throwable last = null;

        for (int attempt = 1; attempt <= maxAttempts; attempt++) {
            try {
                // Invoke the wrapped logic — typically a separate @Transactional bean,
                // so each attempt gets a fresh transaction
                return joinPoint.proceed();
            } catch (Throwable e) {
                if (!isRetryable(e)) {
                    throw e; // Business exceptions (e.g. out of stock) propagate immediately
                }
                log.warn("[Conflict] attempt {}/{} failed, retrying. cause={}",
                         attempt, maxAttempts, e.getClass().getSimpleName());
                last = e;

                if (attempt == maxAttempts) {
                    break;
                }

                // Full Jitter (per AWS Architecture Blog)
                // sleep ∈ [0, min(baseDelay * 2^(attempt-1), MAX_DELAY_MS)], uniform
                // The cap keeps things sane even when attempt counts grow large
                long exponential = baseDelay * (1L << (attempt - 1));
                long cap = Math.min(exponential, MAX_DELAY_MS);
                long sleepTime = ThreadLocalRandom.current().nextLong(0, cap + 1);

                try {
                    Thread.sleep(sleepTime);
                } catch (InterruptedException ie) {
                    // Restore the interrupt flag so the thread pool / container can shut down cleanly
                    Thread.currentThread().interrupt();
                    throw ie;
                }
            }
        }

        log.error("[Retries exhausted] giving up after {} attempts", maxAttempts);
        throw last;
    }

    private boolean isRetryable(Throwable e) {
        return OPTIMISTIC_LOCK_EXCEPTIONS.stream().anyMatch(c -> c.isInstance(e))
            || TRANSIENT_DB_EXCEPTIONS.stream().anyMatch(c -> c.isInstance(e));
    }
}

💡 The hidden cost of Thread.sleep

Sleeping holds the Tomcat worker thread for the entire duration. Trading “connection-pool exhaustion” (the pessimistic-lock problem) for “thread-pool exhaustion” isn’t a win. If your traffic is genuinely extreme, look at non-blocking retries (WebFlux, coroutines) or push the work into a queue instead.

When InterruptedException does fire, always call Thread.currentThread().interrupt() to restore the interrupt flag. Otherwise your thread pool and container can miss the shutdown signal, and graceful shutdown breaks.

And keep retryable exceptions (isRetryable) strictly separated from business exceptions like IllegalStateException. Retrying a non-retryable exception just shows the user the same error N times.

📚 Why split the exception set into two groups

OPTIMISTIC_LOCK_EXCEPTIONS and TRANSIENT_DB_EXCEPTIONS are separate sets because they aren’t really the same problem.ObjectOptimisticLockingFailureException and friends mean a version mismatch — “someone else committed first”. A short backoff usually clears it up.CannotAcquireLockException means a DB-level lock acquisition failure (deadlock, lock timeout). That’s not an optimistic-lock conflict at all. You can still retry it, but in production you’ll often want a different backoff policy (longer waits, fewer attempts) tailored to the failure mode.

In larger systems, these often live in entirely separate aspects rather than a single shared one.

4. When the Retries Run Out

No matter how clean your backoff and jitter are, some requests will exhaust the retry budget. If your only fallback is an error page, a technical conflict becomes a broken user experience. Senior-level design treats the fallback as a product decision, not just a try/catch.

💡 Fallback patterns worth stealing****Drop into an async queue, notify when ready. Show the user something like “We’re processing a high volume of orders right now — we’ll text you when yours is ready,” and push the request into a message queue for asynchronous handling.Soft-fail UI. Instead of an error dialog, show a progress bar or a refresh prompt. Buy yourself time without burning the UX.

Going Further: When Optimistic Locking Itself Isn’t Enough

For a real flash sale — 1,000 units, 100,000 concurrent buyers — optimistic locking will conflict 99%+ of the time and effectively stops working. At that point you need a different model entirely.

  • Redis as a gate. Use DECR or a Lua script to decrement stock atomically. Only callers that pass the gate hit the database; the DB becomes an eventually-consistent record of truth.
  • A first-come-first-served queue (Kafka/SQS). Drop incoming requests into a queue immediately, then drain at a controlled rate. Database load flattens out.

⚠️ A Redis gate is only half the design — the other half is reconciliation

Don’t stop at “Redis decremented, we’re good.” You also need to handle Redis success but DB write failure, queue redelivery causing duplicate processing, and dropouts from network partitions. In practice this means idempotency keys (so duplicates become no-ops), the outbox pattern (atomically tying DB writes to message publication), and a periodic reconciliation job that verifies Redis and the database still agree.

The bigger point: instead of treating “pessimistic → optimistic” as a one-way upgrade, let the traffic shape and conflict rate dictate the locking model itself.

Wrapping Up

Concurrency control isn’t really a question of which annotation to use. It’s a trade-off between keeping infrastructure load under control (think connection-pool exhaustion) and maintaining business continuity (think user-facing UX).

Whether you’re refactoring an existing system or designing a new one, get into the habit of writing down the backoff policy and the fallback path alongside the lock choice. The architecture you end up with will be a lot more resilient for it.