Skip to main content

Redis Cache-Aside in Spring Boot: Measured Latency and Stampede Fixes

A cache hit took 2.24 ms vs 53.54 ms, yet 100 concurrent misses made 100 DB reads, even with @Cacheable(sync = true). Single flight and a Redis lock fixed it, measured on real Redis.

Updated 32 min readMarkdown
Part of seriesBackend & APIs
Redis Cache-Aside in Spring Boot: Measured Latency and Stampede Fixes

I put Redis in front of a slow endpoint, deleted the key, and fired 100 requests at it at the same moment. Here is how many of them went to the database, pasted from the test run:

  naive                        DB reads 100 / 100   p50   70.00 ms  p95   74.42 ms  p99   76.18 ms  max   77.32 ms   distinct bodies 1
  cacheable-sync               DB reads 100 / 100   p50   63.32 ms  p95   73.97 ms  p99   74.74 ms  max   75.23 ms   distinct bodies 1
  single-flight                DB reads   1 / 100   p50   49.75 ms  p95   57.26 ms  p99   57.56 ms  max   59.21 ms   distinct bodies 1
  redis-lock                   DB reads   1 / 100   p50   48.15 ms  p95   61.41 ms  p99   62.76 ms  max   62.92 ms   distinct bodies 1

Plain cache-aside sent all 100 requests to the database. That's a cache stampede. The second line surprised me more: @Cacheable(sync = true), the usual Spring answer to stampedes, also sent all 100. On Spring Data Redis 4.1.2 with the default cache writer, sync = true doesn't lock anything. Two small fixes got it down to one read.

Most "Spring Boot Redis cache-aside" tutorials stop at "add @Cacheable, it's faster now". This post measures how much faster, shows the stampede happening, and then fixes it. It covers a per-key single flight inside the JVM, a Redis SET NX PX lock that works across instances, TTL jitter and refresh-ahead. It also covers the update-then-delete race that leaves a stale price in the cache. Every number is from one run of the code on this box (8 vCPU Xeon, Linux) on 13 Nov 2026, against a real redis-server 7.4.2. Your numbers will be different. The read counts shouldn't be.

The short version

  • A cache hit over HTTP took 2.24 ms (p50) against 53.54 ms without the cache. The "database" here is a fake that sleeps 50 ms per read, so the gap is as big as your real query makes it. The shape of the result is what carries over.
  • Cache-aside on its own doesn't coordinate misses. 100 concurrent requests on a cold key gave 100 DB reads, and so did 100 requests right after the key expired by TTL.
  • @Cacheable(sync = true) only locks if the cache provider does. RedisCache with Boot's default (non-locking) writer did not: 100 reads. With the locking writer it was 1 read, but that lock is one Redis key per cache name. 20 misses on 20 different keys then took 1,444 ms instead of 56-60 ms.
  • Single flight (a ConcurrentHashMap of futures, keyed by id) gave 1 read per JVM: 1 with one instance, 2 with two.
  • A Redis lock (SET lock:key token NX PX 2000, released with a compare-and-delete Lua script) gave 1 read across both instances.
  • TTL jitter of ±10% cut the busiest expiry second from 1,000 keys to 98. Refresh-ahead served 50 concurrent callers from cache while one background read renewed the TTL.
  • "Update the DB, then delete the key" can still leave stale data: the run left a cached price of 1500 against a DB price of 999, with 59 s of TTL left. A version check in a Lua write fixed it.

Cache stampede: 100 concurrent requests on a cold key, naive vs single flight vs Redis lock, across two app instances

What the top results cover, and what they skip

I read the top results for "spring boot redis cache aside stampede" on 13 Nov 2026:

Result Good at Missing
Muni Prasad: Redis Cache Stampede in Spring Boot The best of the lot: per-key lease, random token, double-check, a clear table of what each defence does not guarantee No numbers. Nothing shows how many reads each option actually saves, and sync = true is called "provider-dependent" without showing what Redis does.
Redis docs: cache-aside with Jedis Lua lock acquire and release, plus a stampedesSuppressed counter Plain Jedis, not Spring. It mentions "update the primary then delete the key" without the race it leaves open.
CodeWithYash: Prevent Key Leaks, Stampedes & Hotspots Explains @Cacheable(sync = true) and TTL jitter, and notes the single-JVM limit Says sync = true makes only one thread run the method. With RedisCache and the default writer my run shows 100 out of 100.
tuanh.net: Ways to Prevent Cache Stampedes in Spring Boot A broad list: two-layer cache, coalescing, locks, probabilistic early refresh Conceptual snippets. Nothing is run or measured.
Engineered.at: Thundering Herd Problem Spring Boot + Postgres + Redis with Zipkin traces before and after a lock Shows traces, not counts or latency percentiles. Nothing on expiry, jitter or invalidation.

So the gap isn't "what is a stampede". It's a runnable Spring Boot project that counts the database reads for each fix, on one instance and on two. It also tests the claims people repeat about sync = true, and includes a test for the invalidation race. That's this post.

Setup

What Value
JDK Temurin OpenJDK 25.0.5 (code compiles with --release 21)
Framework Spring Boot 4.1.2, Spring Framework 7.0.10, Tomcat 11.0.27
Redis redis-server 7.4.2 built from source, started by the tests on a random port. Not Docker, not an embedded fake.
Client Spring Data Redis 4.1.2 on Lettuce 7.5.2, StringRedisTemplate, values as JSON (Jackson 3.1.7)
"Database" SlowProductRepository: an in-memory map that sleeps 50 ms on every read and counts reads
Tests JUnit 6.0.3, @SpringBootTest(webEnvironment = RANDOM_PORT), java.net.http.HttpClient, 100 threads behind a start gate
Run it ./run.sh in the project folder (8 tests, about 30 s)

The fake database is deliberate. A real Postgres on the same box answers a primary-key lookup in well under a millisecond, and the cache would look pointless. A fixed 50 ms stands in for a slow query or a remote service, and the read counter makes every stampede visible. One detail matters later: the repository picks the row first and then sleeps, the way a real SELECT has already decided its result while the bytes travel back.

public class SlowProductRepository {

    private final long latencyMs;
    private final Map<Long, Product> rows = new ConcurrentHashMap<>();
    private final AtomicInteger reads = new AtomicInteger();

    public SlowProductRepository(long latencyMs) { this.latencyMs = latencyMs; }

    public Product findById(long id) {
        Product row = rows.computeIfAbsent(id, k -> new Product(k, "Product " + k, 1_000 + k));
        reads.incrementAndGet();
        try {
            Thread.sleep(latencyMs);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        }
        return row;
    }

    public void updatePrice(long id, long priceCents) {
        rows.compute(id, (k, old) -> new Product(k, old == null ? "Product " + k : old.name(), priceCents));
    }

    public int reads() { return reads.get(); }

    public void resetReads() { reads.set(0); }

    public long latencyMs() { return latencyMs; }
}

Dependencies (Boot BOM imported, so no versions) and the config:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<!-- Lettuce + StringRedisTemplate + RedisCacheManager -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- @Cacheable / @EnableCaching -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
spring.data.redis.host=localhost
spring.data.redis.port=6379
spring.cache.type=redis
# every "DB" read sleeps this long (demo stand-in for a real query)
demo.db.latency-ms=50
demo.cache.ttl=60s
# +/-10% TTL jitter for the protected variants
demo.cache.jitter=0.1
demo.lock.ttl=2s
demo.lock.poll=10ms
demo.cache.refresh-window=5s
logging.level.root=WARN
spring.main.banner-mode=off

The endpoint takes the strategy as a query parameter, so every variant runs through the same Tomcat, the same Spring MVC stack and the same Redis:

@GetMapping("/products/{id}")
public Product get(@PathVariable long id, @RequestParam(defaultValue = "naive") String mode) {
    return switch (mode) {
        case "none" -> db.findById(id);
        case "naive" -> cache.naive(id);
        case "single-flight" -> cache.singleFlight(id);
        case "redis-lock" -> cache.redisLock(id);
        case "refresh-ahead" -> cache.refreshAhead(id);
        case "cacheable" -> annotated.plain(id);
        case "cacheable-sync" -> annotated.sync(id);
        case "cacheable-sync-locking" -> annotated.syncLocking(id);
        default -> throw new IllegalArgumentException("unknown mode " + mode);
    };
}

Plain cache-aside, and what it buys you

The textbook version: read Redis; on a miss, read the database and write the value back with a TTL.

/** GET; on a miss read the DB and SET with a fixed TTL. Correct, but every concurrent miss hits the DB. */
public Product naive(long id) {
    Product hit = read(id);
    if (hit != null) return hit;
    Product p = db.findById(id);
    write(id, p, ttl);
    return p;
}

read and write are thin wrappers over StringRedisTemplate and Jackson:

public Product read(long id) {
    String s = redis.opsForValue().get(key(id));
    return s == null ? null : json.readValue(s, Product.class);
}

public void write(long id, Product p, Duration t) {
    redis.opsForValue().set(key(id), json.writeValueAsString(p), t);
}

200 sequential requests each way, over HTTP, after 50 warm-up calls:

  no cache (every call reads DB)     p50   53.54 ms  p95   55.59 ms  p99   62.45 ms  max  111.72 ms
  cache-aside, first call (miss)       58.73 ms
  cache-aside, next 200 (hits)       p50    2.24 ms  p95    7.83 ms  p99   13.25 ms  max   23.45 ms
  raw Redis GET (no HTTP)            p50    0.11 ms  p95    0.30 ms  p99    0.56 ms  max    1.75 ms
  DB reads for the 201 cached calls  1

Three things to read from this:

  1. A miss is slower than no cache at all (58.73 ms against a 53.54 ms p50). It pays for the Redis GET, the DB read and the SET. Cache-aside only pays off when keys get read more than once before they expire.
  2. The hit is about 2 ms, and most of it isn't Redis. A raw GET from the same JVM took 0.11 ms (p50). The rest is HTTP, Tomcat, Spring MVC and JSON on both ends. If your endpoint already takes 2 ms without a cache, Redis won't make it much faster.
  3. The tail is wider on hits (p99 13.25 ms) than the median suggests. Probably GC and scheduling noise on a shared box (I didn't dig into it), and a good reason to quote p95/p99 and not averages.

The stampede

Now the part tutorials skip: 100 requests at the same instant, on a key that isn't in Redis. The harness parks 100 threads behind a latch and releases them together, so they really do arrive at once:

static <T> Result<T> concurrent(int n, IntFunction<T> call) throws Exception {
    ExecutorService pool = Executors.newFixedThreadPool(n);
    try {
        CountDownLatch ready = new CountDownLatch(n), go = new CountDownLatch(1);
        List<Future<long[]>> timings = new ArrayList<>();
        List<Object> values = Collections.synchronizedList(new ArrayList<>(Collections.nCopies(n, null)));
        for (int i = 0; i < n; i++) {
            int idx = i;
            timings.add(pool.submit(() -> {
                ready.countDown();
                go.await();
                long t0 = System.nanoTime();
                values.set(idx, call.apply(idx));
                return new long[] {System.nanoTime() - t0};
            }));
        }
        ready.await();
        long start = System.nanoTime();
        go.countDown();
        List<Long> nanos = new ArrayList<>();
        for (Future<long[]> f : timings) nanos.add(f.get()[0]);
        long wall = System.nanoTime() - start;
        @SuppressWarnings("unchecked") List<T> vs = (List<T>) (List<?>) new ArrayList<>(values);
        return new Result<>(vs, nanos, wall);
    } finally {
        pool.shutdownNow();
    }
}

The test then runs each mode in turn. Each one is warmed up with the same burst on a throwaway key first, then Redis is flushed and the read counter reset:

@Test
void coldKey_100ConcurrentMisses() throws Exception {
    System.out.printf("[stampede] %d concurrent GET /products/7 on a cold key, one app instance (each mode warmed up first)%n", N);
    for (String mode : List.of("naive", "cacheable", "cacheable-sync", "cacheable-sync-locking", "single-flight", "redis-lock")) {
        warmUp(mode);
        var r = burst(mode, 7);
        int reads = db.reads();
        row(mode, r, reads);
        assertThat(new HashSet<>(r.values())).hasSize(1);
        // sync=true alone does not coalesce on RedisCache (Spring Data Redis 4.1): the provider decides
        if (List.of("naive", "cacheable", "cacheable-sync").contains(mode)) assertThat(reads).isGreaterThan(1);
        else assertThat(reads).isEqualTo(1);
    }
    System.out.println("  redis-lock winners that found the value already cached on re-check: " + cache.lockWinnerFoundCached.get());
}
[stampede] 100 concurrent GET /products/7 on a cold key, one app instance (each mode warmed up first)
  naive                        DB reads 100 / 100   p50   70.00 ms  p95   74.42 ms  p99   76.18 ms  max   77.32 ms   distinct bodies 1
  cacheable                    DB reads 100 / 100   p50   74.60 ms  p95   82.36 ms  p99   83.85 ms  max   84.23 ms   distinct bodies 1
  cacheable-sync               DB reads 100 / 100   p50   63.32 ms  p95   73.97 ms  p99   74.74 ms  max   75.23 ms   distinct bodies 1
  cacheable-sync-locking       DB reads   1 / 100   p50   57.19 ms  p95  158.26 ms  p99  159.62 ms  max  210.19 ms   distinct bodies 1
  single-flight                DB reads   1 / 100   p50   49.75 ms  p95   57.26 ms  p99   57.56 ms  max   59.21 ms   distinct bodies 1
  redis-lock                   DB reads   1 / 100   p50   48.15 ms  p95   61.41 ms  p99   62.76 ms  max   62.92 ms   distinct bodies 1
  redis-lock winners that found the value already cached on re-check: 0

naive made 100 reads, which follows from the code. All 100 requests run GET, all 100 see null, and all 100 go to the database before the first one gets to write. A key that expires by TTL behaves the same, and that's the realistic case, because hot keys expire under load:

[stampede] same burst right after the key expired by TTL
  naive (expired)              DB reads 100 / 100   p50   57.46 ms  p95   62.85 ms  p99   65.03 ms  max   65.95 ms   distinct bodies 1
  single-flight (expired)      DB reads   1 / 100   p50   47.42 ms  p95   68.94 ms  p99   69.60 ms  max   69.73 ms   distinct bodies 1
  redis-lock (expired)         DB reads   1 / 100   p50   60.16 ms  p95   73.13 ms  p99   77.43 ms  max   77.89 ms   distinct bodies 1

The latency columns barely change between rows, and that's a property of the fake. It can run 100 sleeps in parallel. A real database can't: with a pool of 10 connections, 100 concurrent queries means 90 of them queue for a connection, and the latency grows with the stampede. I measured that queueing in Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry. So the number that matters here is DB reads, not p50.

The @Cacheable(sync = true) trap

The usual advice is "add sync = true". The Spring reference describes sync as an instruction to "the underlying cache provider to lock the cache entry while the value is being computed", and adds: "This is an optional feature, and your favorite cache library may not support it." Spring's CacheAspectSupport only calls Cache.get(key, valueLoader). Whatever locking happens is up to the provider.

/** The Spring Cache way. Same repository, RedisCacheManager underneath. */
@Service
public class AnnotatedProductService {

    private final SlowProductRepository db;

    public AnnotatedProductService(SlowProductRepository db) { this.db = db; }

    @Cacheable(cacheNames = "products", key = "#id")
    public Product plain(long id) { return db.findById(id); }

    @Cacheable(cacheNames = "products-sync", key = "#id", sync = true)
    public Product sync(long id) { return db.findById(id); }

    @Cacheable(cacheNames = "products-locked", key = "#id", sync = true, cacheManager = "lockingCacheManager")
    public Product syncLocking(long id) { return db.findById(id); }
}

In Spring Data Redis 4.1.2, RedisCache.get(key, valueLoader) hands the loader to the RedisCacheWriter. The default writer, which Spring Boot auto-configures for spring.cache.type=redis, is non-locking: no synchronized, no per-key lock. That's why cacheable-sync made 100 reads, the same as plain @Cacheable. (The demo declares that manager itself, with the same non-locking writer Boot would pick, so it can sit next to a locking one.)

The opt-in locking writer does stop the stampede (1 read, and also 1 across two instances, because the lock lives in Redis). The Spring Data Redis docs are explicit about the catch: "Locking applies on the cache level, not per cache entry." The lock key is <cacheName>~lock, and a waiter retries every 50 ms by default. So misses on different keys in the same cache now queue behind each other:

[stampede] 20 concurrent misses on 20 DIFFERENT keys (no sharing possible)
  cacheable-sync         DB reads 20   wall   60.33 ms   p50   58.98 ms  p95   60.13 ms  p99   60.26 ms  max   60.26 ms
  cacheable-sync-locking DB reads 20   wall 1444.05 ms   p50  563.05 ms  p95 1341.22 ms  p99 1443.97 ms  max 1443.97 ms
  single-flight          DB reads 20   wall   56.00 ms   p50   52.81 ms  p95   54.14 ms  p99   54.46 ms  max   54.46 ms
  redis-lock             DB reads 20   wall   58.53 ms   p50   55.29 ms  p95   57.82 ms  p99   58.49 ms  max   58.49 ms

20 misses that need 20 reads anyway took 1,444 ms of wall time with the locking writer and 56-60 ms with everything else. Also, the cache lock has no expiry by default (TtlFunction.persistent()). If a JVM dies between SET NX and DEL, every miss on that cache waits forever. If you use it, set a timeout:

/**
 * Same, with Spring Data Redis cache locking: one Redis lock key per cache NAME ("products-locked~lock"),
 * retried every 50 ms (the default once enabled). Give the lock a timeout: the default is no expiry.
 */
@Bean
RedisCacheManager lockingCacheManager(RedisConnectionFactory cf, RedisCacheConfiguration config) {
    RedisCacheWriter writer = RedisCacheWriter.create(cf,
            c -> c.enableLocking(l -> l.lockTimeout(RedisCacheWriter.TtlFunction.just(Duration.ofSeconds(5)))));
    return RedisCacheManager.builder(writer).cacheDefaults(config).build();
}

My take: sync = true is a fine one-liner with Caffeine (which locks per key in-process). On Redis, measure it before you trust it.

Fix 1: single flight inside the JVM

The first request for a key does the load. Everyone else who misses while it's running waits on the same CompletableFuture:

/** One DB read per key per JVM: the first miss loads, the others wait on its future. */
public Product singleFlight(long id) {
    Product hit = read(id);
    if (hit != null) return hit;
    CompletableFuture<Product> mine = new CompletableFuture<>();
    CompletableFuture<Product> leader = inFlight.putIfAbsent(id, mine);
    if (leader != null) return join(leader);
    try {
        Product again = read(id);                    // a load may have finished since our first GET
        Product p = again != null ? again : loadAndWrite(id);
        mine.complete(p);
        return p;
    } catch (RuntimeException e) {
        mine.completeExceptionally(e);               // waiters fail too, instead of hanging
        throw e;
    } finally {
        inFlight.remove(id, mine);
    }
}

The details that matter:

  • putIfAbsent, not get followed by put, so exactly one caller becomes the leader.
  • Re-check Redis after winning. A load can finish between your first GET and your putIfAbsent. Without the re-check you'd do a second, useless read.
  • remove(id, mine) in finally, so a failed load doesn't leave a broken future that poisons the key, and the two-argument remove never deletes a newer leader's future.
  • Fail the waiters too (completeExceptionally). Otherwise they block forever when the leader throws.

It costs nothing extra in Redis and it gave 1 read on one instance. Its limit is in the name: with two instances it was 2 reads (see below). That's fine for most services. Five pods means at most five reads per hot key, not five thousand.

Fix 2: a Redis lock across instances

When the load itself is expensive (a heavy aggregation, a paid third-party API), use a per-key lock in Redis:

/** Delete the lock only if we still own it (an expired lock may belong to someone else now). */
private static final RedisScript<Long> RELEASE_LOCK = RedisScript.of("""
        if redis.call('GET', KEYS[1]) == ARGV[1] then
          return redis.call('DEL', KEYS[1])
        end
        return 0
        """, Long.class);
/** One DB read per key across all app instances while the lock is held. */
public Product redisLock(long id) {
    Product hit = read(id);
    if (hit != null) return hit;
    String lockKey = "lock:" + key(id);
    String token = UUID.randomUUID().toString();
    if (Boolean.TRUE.equals(redis.opsForValue().setIfAbsent(lockKey, token, lockTtl))) {
        try {
            Product again = read(id);
            if (again != null) {
                lockWinnerFoundCached.incrementAndGet();
                return again;
            }
            return loadAndWrite(id);
        } finally {
            redis.execute(RELEASE_LOCK, List.of(lockKey), token);
        }
    }
    long deadline = System.nanoTime() + lockTtl.toNanos();
    while (System.nanoTime() < deadline) {
        sleep(lockPoll);
        Product p = read(id);
        if (p != null) return p;
    }
    return loadAndWrite(id);                         // holder died or is too slow: load it ourselves
}

Why each line is there:

  • SET lock:product:9 <uuid> NX PX 2000 (setIfAbsent with a duration) is one atomic command. Never use SETNX followed by EXPIRE: crash between the two and the lock never expires.
  • A random token and a Lua compare-and-delete. If the leader stalls past 2 s, the lock expires and someone else takes it. A plain DEL from the old leader would then delete the new leader's lock.
  • Re-check after winning. The counter lockWinnerFoundCached was 0 in this run, but under a slower burst a late request can win a lock that was just released after the value was written.
  • Bounded waiting. Losers poll Redis every 10 ms until the lock TTL runs out, then load anyway. A dead leader costs one extra read, not an outage.
  • The lock is a lease, not a guarantee. Keep the lock TTL above your worst-case load time (set a DB statement timeout below it). A paused leader can still write an older value after a newer one. For data where that matters, combine it with the version check further down.

Two copies of the app (two Spring contexts, two Tomcats, one Redis, one shared repository), 50 requests to each:

[two-instances] 100 concurrent GET /products/9 on a cold key, 50 to instance A + 50 to instance B
  naive                  DB reads 100 / 100   p50   55.49 ms  p95   61.95 ms  p99   63.45 ms  max   64.06 ms
  cacheable-sync         DB reads 100 / 100   p50   54.97 ms  p95   69.91 ms  p99   70.65 ms  max   71.64 ms
  cacheable-sync-locking DB reads   1 / 100   p50   58.25 ms  p95   62.93 ms  p99   67.38 ms  max  104.23 ms
  single-flight          DB reads   2 / 100   p50   49.66 ms  p95   57.22 ms  p99   57.55 ms  max   57.61 ms
  redis-lock             DB reads   1 / 100   p50   54.68 ms  p95   58.97 ms  p99   61.30 ms  max   61.49 ms

That table is the whole argument. Single flight is per JVM (2 reads), the Redis lock is per key across JVMs (1), and the locking cache writer is per cache across JVMs (1, but with the cross-key queueing shown earlier).

TTL jitter: stop keys expiring together

A stampede on one key is what locks solve. The other version is many keys expiring together: a batch job or a cold start writes 1,000 keys with the same TTL, and 60 s later all of them miss together. Jitter spreads them out:

/** base TTL +/- jitter (0.1 = +/-10%), so keys written together don't expire together. */
public Duration jitteredTtl() {
    double f = 1 + ThreadLocalRandom.current().nextDouble(-jitter, jitter);
    return Duration.ofMillis(Math.round(ttl.toMillis() * f));
}

The test writes 1,000 keys in one batch and reads each key's PTTL back from Redis:

[jitter] warm 1,000 keys in one batch, then read every key's PTTL back from Redis
  fixed TTL 60 s           keys expire across  1 distinct seconds (59 s .. 59 s from now), busiest second: 1000 keys
  TTL 60 s +/- 10% jitter  keys expire across 13 distinct seconds (53 s .. 65 s from now), busiest second:   98 keys

The busiest second went from all 1,000 keys to 98. Jitter does nothing for a single hot key, so use it together with single flight or a lock, not instead of them.

Refresh-ahead: never let a hot key go cold

Locks make the herd wait for one load. Refresh-ahead avoids the wait: if a hit finds the key close to expiry, it returns the cached value and starts one background reload.

/** Serve the cached value; if it expires within refreshWindow, reload it once in the background. */
public Product refreshAhead(long id) {
    Product hit = read(id);
    if (hit == null) return singleFlight(id);
    Long pttl = redis.getExpire(key(id), TimeUnit.MILLISECONDS);
    if (pttl != null && pttl >= 0 && pttl < refreshWindow.toMillis() && refreshing.add(id)) {
        refreshExecutor.submit(() -> {
            try {
                loadAndWrite(id);
            } finally {
                refreshing.remove(id);
            }
        });
    }
    return hit;
}

The test writes a key with 3 s left (the refresh window is 5 s) and sends 50 concurrent requests:

[refresh-ahead] key has 3 s left (refresh window 5 s); 50 concurrent GET /products/42?mode=refresh-ahead
  callers served from cache    p50   10.49 ms  p95   12.62 ms  p99   14.44 ms  max   14.44 ms   distinct bodies 1
  background DB reads          1
  TTL after refresh            55 s (was 3 s)

All 50 callers were served from cache, one background read renewed the key, and nobody waited on the database. The cost is an extra PTTL round trip on every hit (pipeline it with the GET if that matters), plus serving data that's up to one refresh window older. It only helps keys that are read often. A key nobody reads in its last 5 seconds still expires and goes back to Fix 1 or 2.

The invalidation trap: update the DB, then delete the key

The usual write path is "update the database, then delete the cache key". It's the right order (the reverse is worse), but it still has a race:

  1. Reader: GET product:500 misses, so it reads the row from the DB and gets price 1500. The result is on its way back.
  2. Writer: UPDATE price = 999, then DEL product:500. There's nothing to delete yet.
  3. Reader: SET product:500 {price: 1500} with a 60 s TTL.

The test forces that interleaving (the writer runs while the reader's 50 ms read is in flight):

[invalidation] writer: UPDATE price=999, then DEL key, while a cache miss is still reading the old row
  update-then-delete   reader returned 1500, DB has 999, cache has 1500 (TTL 59 s)
  version-guarded      reader returned 1501, DB has 999, cache has nothing, next read -> 999

The first line is the bug: the DB says 999, and the cache says 1500 for another 59 seconds. The fix used here is a per-key version. The writer bumps it, and the reader only writes the cache if the version didn't change while it was reading the DB:

/** Write the cache only if no writer bumped the version since we read it (KEYS: value, version). */
private static final RedisScript<Long> SET_IF_VERSION = RedisScript.of("""
        local current = redis.call('GET', KEYS[2]) or '0'
        if current == ARGV[1] then
          redis.call('SET', KEYS[1], ARGV[2], 'PX', ARGV[3])
          return 1
        end
        return 0
        """, Long.class);
/** Version-guarded read: remember the version before the DB read, write the cache only if unchanged. */
public Product versioned(long id) {
    Product hit = read(id);
    if (hit != null) return hit;
    String version = redis.opsForValue().get("ver:" + key(id));
    Product p = db.findById(id);
    redis.execute(SET_IF_VERSION, List.of(key(id), "ver:" + key(id)),
            version == null ? "0" : version, json.writeValueAsString(p), Long.toString(ttl.toMillis()));
    return p;
}

/** Writer for the versioned path: update the DB, bump the version, then delete the key. */
public void updateBumpVersionDelete(long id, long priceCents) {
    db.updatePrice(id, priceCents);
    redis.opsForValue().increment("ver:" + key(id));
    redis.delete(key(id));
}

The reader reads the version before the DB read, so any write that commits after that read started changes the version, and the stale value is dropped. The check and the SET run as one Lua script, so nothing can slip in between them. The one false positive is harmless: a reader that already saw the new row skips its write, and the next read fills the cache.

Cheaper alternatives, if a short stale window is acceptable: a short TTL (it bounds the damage), or a "delayed double delete" (delete again a few hundred ms after the write). The delay is a guess, though, and the version check isn't. If you already stream DB changes (Debezium/CDC), invalidating from the change stream sidesteps the race.

Comparison

From the same run. "Reads" = DB reads for 100 concurrent requests on a cold key.

Approach 1 instance 2 instances Different keys queue? Extra Redis calls per miss Use it when
Plain cache-aside 100 100 no 0 Never on hot keys
@Cacheable 100 not run no 0 Cold, cheap data only
@Cacheable(sync = true), default writer 100 100 no 0 Doesn't protect anything on Redis
@Cacheable(sync = true), locking writer 1 1 yes (20 keys: 1,444 ms) lock SET/DEL + 50 ms polling Small caches with rare misses, and set a lock timeout
Single flight (in-JVM futures) 1 2 no 1 re-check GET Default choice for most services
Redis SET NX PX lock 1 1 no SET NX, Lua DEL, polling GETs Expensive loads, many instances
TTL ±10% jitter n/a n/a n/a 0 Always, alongside one of the above
Refresh-ahead 0 for callers, 1 in background n/a no 1 PTTL per hit Hot keys where waiting is not OK

The run, unedited

./run.sh on 13 Nov 2026. Test classes run in alphabetical order:

# ./run.sh — 13 Nov 2026 09:57 IST, Linux x86_64
== openjdk version "25.0.5" 2026-10-20 LTS; Apache Maven 3.9.11 (3e54c93a704957b63ee3494413a2b544fd3d825b); Spring Boot 4.1.2; Redis server v=7.4.2
[invalidation] writer: UPDATE price=999, then DEL key, while a cache miss is still reading the old row
  update-then-delete   reader returned 1500, DB has 999, cache has 1500 (TTL 59 s)
  version-guarded      reader returned 1501, DB has 999, cache has nothing, next read -> 999
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.356 s -- in com.gowtham.cache.InvalidationRaceTest
[latency] 200 sequential GET /products/1 over HTTP, after 50 warm-up calls (demo DB sleeps 50 ms per read)
  no cache (every call reads DB)     p50   53.54 ms  p95   55.59 ms  p99   62.45 ms  max  111.72 ms
  cache-aside, first call (miss)       58.73 ms
  cache-aside, next 200 (hits)       p50    2.24 ms  p95    7.83 ms  p99   13.25 ms  max   23.45 ms
  raw Redis GET (no HTTP)            p50    0.11 ms  p95    0.30 ms  p99    0.56 ms  max    1.75 ms
  DB reads for the 201 cached calls  1
  body                               {"id":1,"name":"Product 1","priceCents":1001}
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 15.01 s -- in com.gowtham.cache.LatencyTest
[stampede] same burst right after the key expired by TTL
  naive (expired)              DB reads 100 / 100   p50   57.46 ms  p95   62.85 ms  p99   65.03 ms  max   65.95 ms   distinct bodies 1
  single-flight (expired)      DB reads   1 / 100   p50   47.42 ms  p95   68.94 ms  p99   69.60 ms  max   69.73 ms   distinct bodies 1
  redis-lock (expired)         DB reads   1 / 100   p50   60.16 ms  p95   73.13 ms  p99   77.43 ms  max   77.89 ms   distinct bodies 1
[stampede] 100 concurrent GET /products/7 on a cold key, one app instance (each mode warmed up first)
  naive                        DB reads 100 / 100   p50   70.00 ms  p95   74.42 ms  p99   76.18 ms  max   77.32 ms   distinct bodies 1
  cacheable                    DB reads 100 / 100   p50   74.60 ms  p95   82.36 ms  p99   83.85 ms  max   84.23 ms   distinct bodies 1
  cacheable-sync               DB reads 100 / 100   p50   63.32 ms  p95   73.97 ms  p99   74.74 ms  max   75.23 ms   distinct bodies 1
  cacheable-sync-locking       DB reads   1 / 100   p50   57.19 ms  p95  158.26 ms  p99  159.62 ms  max  210.19 ms   distinct bodies 1
  single-flight                DB reads   1 / 100   p50   49.75 ms  p95   57.26 ms  p99   57.56 ms  max   59.21 ms   distinct bodies 1
  redis-lock                   DB reads   1 / 100   p50   48.15 ms  p95   61.41 ms  p99   62.76 ms  max   62.92 ms   distinct bodies 1
  redis-lock winners that found the value already cached on re-check: 0
[stampede] 20 concurrent misses on 20 DIFFERENT keys (no sharing possible)
  cacheable-sync         DB reads 20   wall   60.33 ms   p50   58.98 ms  p95   60.13 ms  p99   60.26 ms  max   60.26 ms
  cacheable-sync-locking DB reads 20   wall 1444.05 ms   p50  563.05 ms  p95 1341.22 ms  p99 1443.97 ms  max 1443.97 ms
  single-flight          DB reads 20   wall   56.00 ms   p50   52.81 ms  p95   54.14 ms  p99   54.46 ms  max   54.46 ms
  redis-lock             DB reads 20   wall   58.53 ms   p50   55.29 ms  p95   57.82 ms  p99   58.49 ms  max   58.49 ms
[INFO] Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 4.696 s -- in com.gowtham.cache.StampedeTest
[jitter] warm 1,000 keys in one batch, then read every key's PTTL back from Redis
  fixed TTL 60 s           keys expire across  1 distinct seconds (59 s .. 59 s from now), busiest second: 1000 keys
  TTL 60 s +/- 10% jitter  keys expire across 13 distinct seconds (53 s .. 65 s from now), busiest second:   98 keys
[refresh-ahead] key has 3 s left (refresh window 5 s); 50 concurrent GET /products/42?mode=refresh-ahead
  callers served from cache    p50   10.49 ms  p95   12.62 ms  p99   14.44 ms  max   14.44 ms   distinct bodies 1
  background DB reads          1
  TTL after refresh            55 s (was 3 s)
[INFO] Tests run: 2, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.313 s -- in com.gowtham.cache.TtlJitterAndRefreshAheadTest
[two-instances] 100 concurrent GET /products/9 on a cold key, 50 to instance A + 50 to instance B
  naive                  DB reads 100 / 100   p50   55.49 ms  p95   61.95 ms  p99   63.45 ms  max   64.06 ms
  cacheable-sync         DB reads 100 / 100   p50   54.97 ms  p95   69.91 ms  p99   70.65 ms  max   71.64 ms
  cacheable-sync-locking DB reads   1 / 100   p50   58.25 ms  p95   62.93 ms  p99   67.38 ms  max  104.23 ms
  single-flight          DB reads   2 / 100   p50   49.66 ms  p95   57.22 ms  p99   57.55 ms  max   57.61 ms
  redis-lock             DB reads   1 / 100   p50   54.68 ms  p95   58.97 ms  p99   61.30 ms  max   61.49 ms
[INFO] Tests run: 1, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 2.083 s -- in com.gowtham.cache.TwoInstancesTest
[INFO] Tests run: 8, Failures: 0, Errors: 0, Skipped: 0
[INFO] BUILD SUCCESS

Checklist

  • Measure a hit and a miss at p50/p95/p99 before you add the cache. A miss costs more than no cache.
  • Coalesce misses: single flight per JVM at minimum, and a Redis SET NX PX lock with a token and compare-and-delete if the load is expensive or you run many instances.
  • Don't assume @Cacheable(sync = true) locks on Redis. Default writer: no locking. Locking writer: one lock per cache name, no timeout unless you set one.
  • Re-check the cache after winning the lock or the leader slot.
  • Bound the wait, and fall back to loading if the leader dies.
  • Add TTL jitter to anything written in batches.
  • Refresh hot keys ahead of expiry if callers can't wait.
  • Update the DB, then delete the key, and guard the read-path write with a version (or accept a short TTL as the bound).
  • Count DB reads in your load tests, not only latency.

Interview follow-ups

"What's the difference between cache-aside, read-through and write-through?" Cache-aside: the application reads the cache, loads from the DB on a miss and writes the value back. Read-through: the cache library does the load for you (@Cacheable is read-through from your code's point of view). Write-through: every write goes to the cache and the DB together. Cache-aside plus delete-on-write is the most common setup with Redis.

"Your @Cacheable(sync = true) service still hammers the DB when a key expires. Why?" sync asks the provider to lock. RedisCache with the default non-locking writer doesn't, so all concurrent misses run the method. With the locking writer it serializes per cache name, not per key. Within one JVM, use a per-key single flight (or Caffeine in front of Redis). Across JVMs, use a per-key Redis lock.

"Why a random token in the lock value?" So you only delete your own lock. If your load outlives the PX, another node takes the lock, and a plain DEL from you would release their lock and let a third node in.

"Is a Redis lock safe for correctness?" No. It's a lease with an expiry, fine for "avoid duplicate loads" and not fine for "never charge twice". For correctness, let the database enforce it (unique keys, conditional updates, fencing tokens). I did that for retried POSTs in Idempotency Keys in Spring Boot.

"Delete or update the cache on write?" Delete. Updating from the writer races with other writers, and two writers can leave the older value cached. Delete, then let the next read repopulate, and handle the read-repopulate race with a version or a short TTL.

"Thundering herd vs cache avalanche?" Herd/stampede: many requests miss the same key at once (fix: coalescing, locks, refresh-ahead). Avalanche: many different keys expire at once, or Redis goes down (fix: TTL jitter, warm-up, a circuit breaker or rate limit in front of the DB).

Sources

Written by Gowtham M. Code tested on OpenJDK 25.0.5 / Spring Boot 4.1.2 / Spring Data Redis 4.1.2 / Redis 7.4.2, 13 Nov 2026.


Preparing for a Java backend interview? My eBook Crack the Java Backend Interview has tested answers with runnable code for questions like this one. Get it for ₹99.

No comments yet. Be the first to comment.