Redis Cache-Aside
Three provider calls per valuation is expensive to repeat, tolerable to serve slightly stale, and exactly what Redis's cache-aside pattern was built to absorb.
Every valuation this service computes costs three outbound calls — Black Book, Carfax and S&P VIS — each with its own latency, its own failure mode, and in some contracts, its own per-call fee. If a dealer looks up the same VIN twice in an afternoon, or three different dealers all price the same trade-in vehicle that just came off a lease, recomputing that valuation from scratch every time is pure waste: the vehicle has not changed, the market has not meaningfully moved in the last thirty minutes, and the three providers would very likely return the same numbers they returned the first time. This is precisely the shape of problem cache-aside solves.
Cache-aside (sometimes called lazy loading) is a pattern, not a Redis-specific feature — it describes how the application, not the cache, orchestrates reads and writes. The read path is a small state machine: check the cache first; on a hit, return immediately and skip the source of truth entirely; on a miss, fall through to the expensive path (here, calling the three providers and running the rules engine), then write that result into the cache before returning it, so the next reader gets a hit. The cache never initiates anything — it is purely a passive lookup table the application chooses to consult or populate.
The reason this fits valuation lookups so well is the same reason it fits almost any 'expensive to compute, cheap to store, tolerant of some staleness' problem: the cost asymmetry is enormous. A cache hit is a Redis round-trip measured in single-digit milliseconds; a cache miss is three HTTP calls to third parties that can each take a second or more and can each fail independently. And staleness here is genuinely tolerable — a valuation that is fifteen minutes old is, for a dealer deciding whether to make an offer, indistinguishable in practical terms from one computed a second ago. That tolerance is what makes caching a valid choice at all; it would be the wrong pattern entirely for, say, an account balance.
A cache with no expiration is not a cache — it is an uncontrolled, ever-growing map that will eventually exhaust Redis's memory, because every VIN this service has ever priced would live in it forever. Setting a TTL (`Duration.ofMinutes(30)` in the earlier lesson's sketch) does two jobs at once: it bounds memory by guaranteeing that keys nobody has asked for recently eventually evict themselves, and it caps staleness by guaranteeing that a valuation is never served older than the window the business decided was acceptable. Picking that window is a genuine trade-off — shorter TTLs mean fresher data and more provider calls; longer TTLs mean cheaper operation and a larger blast radius if a provider's pricing was briefly wrong when it got cached.
There is a sharper failure mode hiding inside this pattern, though, and it is worth naming even in an introductory lesson: cache stampede (also called a thundering herd). Imagine a popular VIN's cache entry expires at exactly the moment fifty concurrent requests for it arrive. Every one of them checks the cache, every one of them misses, and all fifty proceed to hammer Black Book, Carfax and S&P VIS simultaneously for a value that is about to be identical fifty times over — the exact waste the cache existed to prevent, concentrated into one painful spike.
Two mitigations are standard, and this service leans toward the first: jitter the TTL so that keys do not all expire in lockstep — instead of every VIN cached at 9:00am expiring at exactly 9:30am, add a small random offset (say, plus or minus two minutes) so expirations spread out over time instead of clustering. The second is a short-lived lock (sometimes done with Redis's own `SETNX`): the first request that misses the cache acquires a lock and does the expensive work, while the other forty-nine either wait briefly for that result to land in the cache or fall back to a slightly stale value rather than all recomputing independently.
Notice what neither mitigation requires: neither changes the fundamental cache-aside shape from the first paragraph. Jittered TTLs and stampede locks are refinements bolted onto the same check-miss-populate loop, which is worth remembering — cache-aside is deliberately simple, and most of its production hardening is about softening its edge cases, not replacing its core idea.
suspend fun getValuation(vin: String): ValuationResult {val cacheKey = "valuation:$vin"// 1. Check the cachevaluationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?.let { cached ->return cached // hit — skip the providers entirely}// 2. Miss — fall through to the source of truth (three provider calls + rules engine)val fresh = computeValuationFromProviders(vin)// 3. Populate the cache with a jittered TTL to avoid synchronized expirationsval jitter = Random.nextLong(-120, 120) // secondsval ttl = Duration.ofMinutes(30).plusSeconds(jitter)valuationCache.opsForValue().set(cacheKey, fresh, ttl).awaitSingle()// 4. Return to callerreturn fresh}
This is illustrative only (runnable: false) — the cache-aside read path as its own function: check, miss, read source of truth, populate, return. Requires a live Redis connection, so it isn't runnable in-browser.
suspend fun getValuationWithStampedeGuard(vin: String): ValuationResult {val cacheKey = "valuation:$vin"val lockKey = "lock:valuation:$vin"valuationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?.let { return it }// SETNX-style lock: only one caller wins the right to recomputeval acquiredLock = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(5)).awaitSingle()if (!acquiredLock) {delay(150) // brief wait for the lock holder to populate the cachereturn valuationCache.opsForValue().get(cacheKey).awaitSingleOrNull()?: computeValuationFromProviders(vin) // fallback if the holder is still working}val fresh = computeValuationFromProviders(vin)valuationCache.opsForValue().set(cacheKey, fresh, Duration.ofMinutes(30)).awaitSingle()redisTemplate.delete(lockKey).awaitSingle()return fresh}
This is illustrative only (runnable: false) — a short-lived lock guarding against cache stampede: only the first miss recomputes, while concurrent misses on the same key wait briefly instead of all calling the providers.
🧠 Check your understanding
0/1 · 0/1 answered1. A popular VIN's cached valuation expires, and 200 requests for that same VIN arrive within the same second. Under a plain cache-aside implementation with no stampede protection, what actually happens?