Topic 6.1
Cache-Aside: The Default Pattern
In one line
In cache-aside (lazy loading), the application checks Redis first, and on a miss reads the database and stores the result with a TTL. Writes go to the database and then delete the cached entry. It's simple, resilient to cache failure, and the default choice, as long as you handle its races.
Think of it like this
A chef's prep station. When an order comes in, the chef checks the station first. If the chopped onions are there, great. If not, they go to the walk-in fridge, chop some, use them, and leave the extra on the station for next time. The station is never the source of truth; the fridge is.
Key ideas
- 01
Read path:
GET product:9→ hit: return. Miss: query the database,SET product:9 <value> EX <ttl>, return. The application owns all the logic; Redis is just storage. - 02
Write path: update the database, then
DEL product:9(invalidate), rather than writing the new value to the cache. Deleting is idempotent and avoids racing writers leaving the older value in the cache. The next read repopulates it. - 03
TTL is your safety net: even if an invalidation is lost (crash between the database write and the
DEL, network error), the stale value disappears after the TTL. Choose the TTL from how long stale data is acceptable, and add jitter. - 04
Resilience: if Redis is down, reads fall back to the database (slower, and possibly overloading it, so protect the database with timeouts, circuit breakers and rate limits). The cache is an optimisation, not a dependency for correctness.
- 05
Cache only what's worth it: data read far more than written, expensive to compute, and tolerant of brief staleness. Don't cache everything; measure hit ratio per keyspace.
- 06
Serialise deliberately: versioned keys (
product:v2:9) and a stable format (Topic 8.3) so deployments and rollbacks don't break cached values.
Code & diagrams
@Service
public class ProductService {
private static final Duration TTL = Duration.ofMinutes(10);
private final StringRedisTemplate redis;
private final ProductRepository repo;
private final ObjectMapper json;
public Product get(long id) throws IOException {
String key = Keys.product(id);
String cached = redis.opsForValue().get(key);
if (cached != null) return json.readValue(cached, Product.class); // hit
Product p = repo.findById(id).orElseThrow(); // miss
Duration ttl = TTL.plusSeconds(ThreadLocalRandom.current().nextInt(0, 120)); // jitter
redis.opsForValue().set(key, json.writeValueAsString(p), ttl);
return p;
}
@Transactional
public void updatePrice(long id, BigDecimal price) {
repo.updatePrice(id, price);
// Delete AFTER commit, so a reader can't re-cache the old row in between.
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override public void afterCommit() { redis.delete(Keys.product(id)); }
});
}
}Interview problem
The problem
Implement cache-aside for product pages
Product pages read product details from PostgreSQL at 20K reads/sec. Add a Redis cache-aside layer. Explain hits, misses, TTL, invalidation on update, and what happens when Redis is unavailable.
You're given
- 20K reads/sec
- 200 writes/sec
- Price changes must show within 1 minute
- Database max ~3K queries/sec
The interviewer follows up
Why delete on write instead of updating the cache with the new value?
When it breaks
Redis outage with no database protection
What you see
Every request becomes a database query; the database saturates, latency explodes for all features, and the outage spreads beyond caching.
Fix & prevent
Short Redis timeouts, circuit breakers, an in-process L1 cache, request coalescing, and load shedding; capacity-plan the database for partial cache loss.
Explain it without notes
Walk through cache-aside reads and writes and explain why the TTL is still needed.
Practice
Add cache-aside to an endpoint in your own project and measure hit ratio and p95 latency before and after.
Trade-offs
- ↔
Cache-aside is simple and fault-tolerant, but the first read after a miss is slow and races must be handled.
- ↔
Short TTLs reduce staleness and increase database load; long TTLs do the opposite.
Done when you can
I can implement cache-aside with TTL, jitter and delete-after-commit.
I can explain why delete beats update on writes.
I plan for Redis outages with timeouts and database protection.