Skip to main content

Redis as Distributed Cache

Redis is the most widely used distributed cache because of its speed, rich data structures, and built-in TTL. Understanding caching patterns and their failure modes is one of the most common senior interview topics.


Caching Patterns

1. Cache-Aside (Lazy Loading)

The most common pattern. The application manages the cache explicitly:

Read:
1. Check cache → HIT → return cached value
2. Cache MISS → query DB → write to cache → return value

Write:
1. Update DB
2. Invalidate cache (or update cache)
@Service
public class ProductService {

public Product getProduct(Long id) {
String key = "product:" + id;

// 1. Check cache
String cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return objectMapper.readValue(cached, Product.class); // Cache HIT
}

// 2. Cache MISS — query DB
Product product = productRepository.findById(id)
.orElseThrow(() -> new NotFoundException(id));

// 3. Populate cache with TTL
redisTemplate.opsForValue().set(key, objectMapper.writeValueAsString(product),
Duration.ofMinutes(30));

return product; // Return from DB
}

public void updateProduct(Long id, Product product) {
productRepository.save(product);
redisTemplate.delete("product:" + id); // Invalidate cache
}
}

Pros: Simple, only caches what's needed (lazy), tolerant of cache failure
Cons: First request always misses (cold start), data can be stale between DB write and cache invalidation

2. Write-Through

Write to cache and DB simultaneously on every write:

Write:
1. Update cache
2. Update DB (synchronous)

Read:
1. Always hits cache (if key exists)
public void updateProduct(Long id, Product product) {
productRepository.save(product); // DB write
String json = objectMapper.writeValueAsString(product);
redisTemplate.opsForValue().set("product:" + id, json, Duration.ofHours(1)); // Cache write
}

Pros: Cache always fresh, no stale reads
Cons: Write latency increases (2 writes), cache fills with infrequently-read data (use TTL to evict unused)

3. Write-Behind (Write-Back)

Write to cache immediately, then asynchronously flush to DB:

Write:
1. Write to cache
2. Return OK to client
3. Background job flushes cache → DB

Read:
1. Always from cache

Pros: Extremely fast writes (only cache I/O on critical path)
Cons: Risk of data loss if Redis crashes before flush, complexity of async flush, consistency challenges

Rarely used without Redis persistence enabled (RDB+AOF).

4. Read-Through

Cache is the primary interface — cache handles its own population:

Read:
1. App asks cache for data
2. Cache HIT → return
3. Cache MISS → cache fetches from DB, stores, returns

Implemented via Spring's @Cacheable:

@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
return productRepository.findById(id).orElseThrow();
}

@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}

@CachePut(value = "products", key = "#result.id")
public Product updateProduct(Product product) {
return productRepository.save(product);
}

Cache Failure Modes

Cache Stampede (Thundering Herd)

When a cached item expires, many concurrent requests all miss and hammer the DB:

t=0:   product:123 TTL expires
t=0: 100 concurrent requests → cache MISS
t=0: 100 threads all query DB for product:123 simultaneously
t=0: 100 "repopulate cache" operations

Solutions:

1. Mutex Lock (only one thread repopulates)

public Product getProduct(Long id) {
String key = "product:" + id;
String lockKey = "lock:" + key;

String cached = redisTemplate.opsForValue().get(key);
if (cached != null) return deserialize(cached);

// Only one thread repopulates — others wait
Boolean acquired = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(acquired)) {
try {
Product product = productRepository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, serialize(product), Duration.ofMinutes(30));
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
Thread.sleep(50); // Brief wait
return getProduct(id); // Retry — now cache is warm
}
}

2. Probabilistic Early Expiration (PER)

def get_product(id):
cached = redis.get(f"product:{id}")
ttl = redis.ttl(f"product:{id}")
# Probabilistically recompute cache before it expires
if ttl < 60 and random.random() < 0.1: # 10% chance when <60s left
product = db.fetch(id)
redis.set(f"product:{id}", product, ex=1800)
return product
return cached if cached else recompute(id)

3. Jitter on TTL (prevents mass expiry)

int jitterSeconds = ThreadLocalRandom.current().nextInt(0, 300);
redisTemplate.opsForValue().set(key, value, Duration.ofSeconds(1800 + jitterSeconds));

Cache Penetration

Requests for keys that never exist bypass cache and hit DB every time:

Request: product:99999999 (doesn't exist in DB)
→ Cache MISS → DB query → not found → no caching → next request repeats!

Solutions:

// Cache null values with short TTL
public Product getProduct(Long id) {
String key = "product:" + id;
String cached = redisTemplate.opsForValue().get(key);

if ("NULL".equals(cached)) return null; // Cached null — don't hit DB
if (cached != null) return deserialize(cached);

Product product = productRepository.findById(id).orElse(null);
if (product == null) {
redisTemplate.opsForValue().set(key, "NULL", Duration.ofMinutes(5)); // Cache the miss
return null;
}
redisTemplate.opsForValue().set(key, serialize(product), Duration.ofMinutes(30));
return product;
}
// Bloom Filter (space-efficient membership check before DB hit)
// Guava BloomFilter or Redisson RBloomFilter
BloomFilter<Long> existingProductIds = BloomFilter.create(Funnels.longFunnel(), 1_000_000, 0.01);
// Populated at startup from DB

public Product getProduct(Long id) {
if (!existingProductIds.mightContain(id)) {
return null; // Definitely doesn't exist — skip DB
}
return cacheAside(id);
}

Cache Avalanche

Many keys expire at the same time — DB is overwhelmed with concurrent misses.

Solutions:

  • Jittered TTL (see above)
  • Staggered cache warm-up
  • Background refresh before expiry
  • Circuit breaker on DB calls during high miss rates

Cache Invalidation Strategies

The hardest problem in caching. Choose based on consistency requirements:

StrategyConsistencyComplexityUse Case
TTL onlyEventual (stale until TTL)LowestRead-heavy, tolerate slight staleness
Invalidation on writeStrongLowWrite-behind/write-through patterns
Event-driven invalidationNear-real-timeMediumMicroservices, Pub/Sub
Version-tagged keysDependsMediumSafe migrations, gradual rollout
Two-phase invalidationStrongHighMulti-region consistency

Event-Driven Cache Invalidation

Service A updates product:123 in DB
→ Publishes "product:updated" event to message bus (Kafka/Redis Streams)
→ All service instances subscribe
→ Each instance invalidates "product:123" from its local + Redis cache

Version-Tagged Keys

# v=2 suffix — safe rollout of schema changes
redis.set("product:123:v2", new_format_data, ex=3600)
# Old key "product:123:v1" expires naturally
# Easy rollback: just read old version key

Spring Cache Abstraction

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultCfg = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
.disableCachingNullValues();

Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
"products", defaultCfg.entryTtl(Duration.ofHours(1)),
"users", defaultCfg.entryTtl(Duration.ofMinutes(15)),
"sessions", defaultCfg.entryTtl(Duration.ofHours(24))
);

return RedisCacheManager.builder(factory)
.cacheDefaults(defaultCfg)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}

Two-Level (L1 + L2) Cache Pattern

Combine in-process cache (L1) with Redis (L2) for maximum performance:

Request → L1 (Caffeine, in-process, nanoseconds)
HIT → return immediately
MISS → L2 (Redis, milliseconds)
HIT → populate L1 → return
MISS → DB (tens of milliseconds)
→ populate L2 → populate L1 → return
@Service
public class ProductService {
private final Cache<Long, Product> localCache = Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(Duration.ofMinutes(5)) // L1 shorter TTL
.build();

public Product getProduct(Long id) {
// L1 check
Product cached = localCache.getIfPresent(id);
if (cached != null) return cached;

// L2 check
String redisKey = "product:" + id;
String json = redisTemplate.opsForValue().get(redisKey);
if (json != null) {
Product product = deserialize(json);
localCache.put(id, product); // Warm L1
return product;
}

// DB
Product product = productRepository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(redisKey, serialize(product), Duration.ofHours(1)); // Warm L2
localCache.put(id, product); // Warm L1
return product;
}
}

L1 invalidation: When L2 is evicted/updated, L1 needs invalidation too. Use Redis Pub/Sub to broadcast invalidation events to all JVM instances:

Redis PUBLISH cache:invalidate "product:123"
→ All service instances: localCache.invalidate(123)