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.

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.RedisCachewith 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
ConcurrentHashMapof 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.

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:
- 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 theSET. Cache-aside only pays off when keys get read more than once before they expire. - The hit is about 2 ms, and most of it isn't Redis. A raw
GETfrom 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. - 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, notgetfollowed byput, so exactly one caller becomes the leader.- Re-check Redis after winning. A load can finish between your first
GETand yourputIfAbsent. Without the re-check you'd do a second, useless read. remove(id, mine)infinally, so a failed load doesn't leave a broken future that poisons the key, and the two-argumentremovenever 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(setIfAbsentwith a duration) is one atomic command. Never useSETNXfollowed byEXPIRE: 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
DELfrom the old leader would then delete the new leader's lock. - Re-check after winning. The counter
lockWinnerFoundCachedwas 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:
- Reader:
GET product:500misses, so it reads the row from the DB and gets price 1500. The result is on its way back. - Writer:
UPDATE price = 999, thenDEL product:500. There's nothing to delete yet. - 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 PXlock 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).
Related posts
- Spring Boot Virtual Threads and HikariCP: Why Your Pool Runs Dry, what a stampede does to a real connection pool.
- Idempotency Keys in Spring Boot: Making POST Safe to Retry, the same "one winner" idea, enforced by the database when correctness matters.
- JWT + Role-Based Access in Spring Security: Refresh Tokens Without the Footguns, another atomic "claim it once" pattern (
UPDATE ... WHERE used_at IS NULL). - Retrying fetch in Node.js the Right Way: Backoff, Retry-After, Deadlines, the client side of a herd: jittered backoff.
- Building Production-Ready Spring Boot APIs: Strategies for Top Performance
Sources
- Spring Framework reference: Declarative annotation-based caching (Synchronized Caching,
sync) - Spring Data Redis reference: Redis Cache (non-locking default writer; "Locking applies on the cache level, not per cache entry")
- Redis docs: SET (
NX,PX) - Redis docs: Distributed Locks with Redis (random value, safe release with Lua)
- Redis docs: cache-aside with Jedis
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.
Comments
Join the discussionNo comments yet. Be the first to comment.
Backend & APIs
Practical, tested backend engineering: Spring Boot, REST APIs, databases and the bugs that only show up under real traffic.




