Skip to main content

Caching Strategies

A cache is a fast, temporary data store closer to the application than the source of truth.


Cache Levels

L1: In-process (JVM heap)      → ~nanoseconds, not shared across instances
L2: Distributed cache (Redis) → ~microseconds, shared across instances
L3: DB read replica → ~milliseconds, full data available
L4: Primary DB → ~milliseconds, authoritative

Caching Patterns

Cache-Aside (Lazy Population)

Application controls cache. Most common pattern.

// Spring Boot + Caffeine (L1) + Redis (L2)
@Service
public class ProductService {
@Autowired private ProductRepository repo;
@Autowired private RedisTemplate<String, Product> redis;

// Spring @Cacheable uses Caffeine for L1 by default
@Cacheable(value = "products", key = "#id")
public Product getProduct(Long id) {
String redisKey = "product:" + id;
Product cached = redis.opsForValue().get(redisKey);
if (cached != null) return cached;

Product product = repo.findById(id).orElseThrow();
redis.opsForValue().set(redisKey, product, Duration.ofHours(1));
return product;
}

@CacheEvict(value = "products", key = "#product.id")
public Product updateProduct(Product product) {
Product saved = repo.save(product);
redis.delete("product:" + product.getId());
return saved;
}
}

Write-Through

Write to cache and DB simultaneously.

@CachePut(value = "products", key = "#product.id")  // Always updates cache
public Product saveProduct(Product product) {
return repo.save(product); // Saves to DB too
}

Write-Behind (Write-Back)

Write to cache, async flush to DB.

Write → Cache (immediate ACK)
↓ async (every 5s)
Flush to DB (batch)

Risk: Data loss if cache crashes before flush. Use with durable Redis (AOF).

Refresh-Ahead

Predictively refresh cache before TTL expires.

// Background refresher
@Scheduled(fixedDelay = 3600_000) // Every hour
public void refreshTopProducts() {
List<Long> hotProductIds = analyticsService.getTopProductIds(100);
hotProductIds.forEach(id -> {
Product product = repo.findById(id).orElseThrow();
redis.opsForValue().set("product:" + id, product, Duration.ofHours(2));
});
}

Eviction Policies

PolicyHowBest For
LRU (Least Recently Used)Evict least recently accessedGeneral-purpose, temporal locality
LFU (Least Frequently Used)Evict least accessed over timeSkewed access (Pareto distribution)
FIFOEvict oldest entrySimple, fair
TTLEvict after fixed timeData with known freshness requirements
RandomEvict random entrySimple, low overhead
# Redis maxmemory policy
redis:
maxmemory: 2gb
maxmemory-policy: allkeys-lru # LRU across all keys
# Options: noeviction, allkeys-lru, allkeys-lfu, volatile-lru, volatile-ttl

Redis Data Structures

StructureCommandsUse Case
StringGET/SET/INCRSession tokens, counters, simple values
HashHGET/HSET/HMGETUser profiles, objects with fields
ListLPUSH/RPOP/LRANGEActivity feeds, queues
SetSADD/SISMEMBER/SUNIONTags, unique visitors, permissions
Sorted SetZADD/ZRANGE/ZRANGEBYSCORELeaderboards, rate limiting, expiring sets
BitmapSETBIT/BITCOUNTDaily active users, feature flags
HyperLogLogPFADD/PFCOUNTApproximate unique counts (1% error, ~12KB)
StreamXADD/XREADEvent log, message queue
// Leaderboard with Sorted Set
redisTemplate.opsForZSet().add("leaderboard:weekly", userId, score);

// Top 10 players
Set<ZSetOperations.TypedTuple<Long>> top10 = redisTemplate.opsForZSet()
.reverseRangeWithScores("leaderboard:weekly", 0, 9);

// Approximate unique daily active users
redisTemplate.opsForHyperLogLog().add("dau:" + today, userId);
long dau = redisTemplate.opsForHyperLogLog().size("dau:" + today);

Cache Invalidation Strategies

Time-Based (TTL)

redis.opsForValue().set(key, value, Duration.ofMinutes(30));

Simple but stale during TTL window.

Event-Based

// On product update → publish invalidation event
@Transactional
public void updateProduct(Product product) {
repo.save(product);
eventPublisher.publishEvent(new ProductUpdatedEvent(product.getId()));
}

@EventListener
@Async
public void onProductUpdated(ProductUpdatedEvent event) {
redis.delete("product:" + event.getProductId());
// Local cache eviction
cacheManager.getCache("products").evict(event.getProductId());
}

Tag-Based Invalidation

// Associate cache entries with tags
// Invalidate all entries with a given tag
cacheManager.getCache("products-by-category-electronics").clear();

Cache Warming

Pre-populate cache before traffic hits (prevents cold start).

@Component
public class CacheWarmer implements ApplicationListener<ApplicationReadyEvent> {
@Override
public void onApplicationEvent(ApplicationReadyEvent event) {
log.info("Warming cache...");
// Load top N items into cache
productRepository.findTopByOrderByViewCountDesc(1000)
.forEach(p -> redis.opsForValue()
.set("product:" + p.getId(), p, Duration.ofHours(2)));
log.info("Cache warm-up complete");
}
}

Spring Cache Configuration

@Configuration
@EnableCaching
public class CacheConfig {

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

return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withCacheConfiguration("products",
config.entryTtl(Duration.ofHours(1))) // Override per cache
.withCacheConfiguration("sessions",
config.entryTtl(Duration.ofDays(1)))
.build();
}
}

Cache Anti-Patterns

Anti-PatternProblemFix
Caching mutable user-specific data globallyWrong data served to usersUse user-scoped keys
No TTL (infinite cache)Memory leak, stale data foreverAlways set TTL
Caching exceptions/nullsRepeated DB hits for non-existent keysCache null with short TTL
Cache as primary storeData loss on evictionCache is supplemental only
Caching in DB transactionTransactional boundary mismatchInvalidate after commit

Cache Hit Rate Calculation

Hit Rate = Cache Hits / (Cache Hits + Cache Misses)

Target: > 90% for read-heavy systems
If < 80%: Cache too small, TTL too short, or access pattern too random

Interview Questions

  1. What is cache-aside vs read-through? When do you use each?
  2. Why is cache invalidation considered one of the hardest problems in CS?
  3. What is a cache stampede? How do you prevent it?
  4. What Redis data structure would you use for a leaderboard? For counting unique visitors?
  5. What eviction policy would you choose for a product catalog vs a social feed?
  6. How do you ensure cache consistency across multiple application instances?
  7. What is the difference between L1 (local) and L2 (distributed) caching?
  8. How would you implement rate limiting using Redis?
  9. What is HyperLogLog and when would you use it instead of a Set?
  10. How do you handle cache warming after a deployment?