Distributed caches provide shared, low-latency data storage across multiple application instances. Redis is the most popular choice in Java ecosystems.

Cache Patterns

Cache-Aside (Lazy Loading)

Application manages cache explicitly:

  public Product getProduct(String sku) {
    String key = "product:" + sku;
    Product cached = (Product) redisTemplate.opsForValue().get(key);
    if (cached != null) return cached;

    Product product = productRepository.findBySku(sku).orElseThrow();
    redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(30));
    return product;
}
  

Read-Through

Cache loads data automatically on miss (via LoadingCache or Redis modules).

Write-Through

Write to cache and database synchronously:

  @Transactional
public Product updateProduct(Product product) {
    Product saved = productRepository.save(product);
    redisTemplate.opsForValue().set("product:" + saved.getSku(), saved, Duration.ofMinutes(30));
    return saved;
}
  

Write-Behind (Write-Back)

Write to cache immediately, flush to database asynchronously. Higher performance but risk of data loss.

Cache Invalidation

The hardest problem in computer science:

  // Event-driven invalidation
@CacheEvict(value = "products", key = "#product.sku")
public Product updateProduct(Product product) { return productRepository.save(product); }

// Pub/Sub for multi-instance invalidation
@Service
public class CacheInvalidationService {
    @EventListener
    public void onProductUpdated(ProductUpdatedEvent event) {
        redisTemplate.convertAndSend("cache:invalidate",
            "product:" + event.getSku());
    }

    @RedisMessageListenerContainer
    public void handleInvalidation(String key) {
        localCache.invalidate(key);
    }
}
  

Multi-Level Cache (L1 + L2)

  Request → L1 (Caffeine, local) → L2 (Redis, shared) → Database
           ~100ns                    ~1ms                 ~10ms
  
  @Service
public class MultiLevelCacheService {
    private final LoadingCache<String, Product> localCache;
    private final RedisTemplate<String, Product> redisTemplate;
    private final ProductRepository repository;

    public Product getProduct(String sku) {
        return localCache.get(sku, key -> {
            Product cached = (Product) redisTemplate.opsForValue().get("product:" + key);
            if (cached != null) return cached;
            Product product = repository.findBySku(key).orElseThrow();
            redisTemplate.opsForValue().set("product:" + key, product, Duration.ofMinutes(30));
            return product;
        });
    }
}
  

Cache Stampede Prevention

When cache expires, many requests hit the database simultaneously:

  // Solution 1: Lock-based
public Product getProduct(String sku) {
    Product cached = getFromCache(sku);
    if (cached != null) return cached;

    String lockKey = "lock:product:" + sku;
    if (redisTemplate.opsForValue().setIfAbsent(lockKey, "1", Duration.ofSeconds(10))) {
        try {
            Product product = repository.findBySku(sku).orElseThrow();
            setCache(sku, product);
            return product;
        } finally {
            redisTemplate.delete(lockKey);
        }
    }
    Thread.sleep(50);
    return getProduct(sku);  // retry
}

// Solution 2: Random TTL jitter
Duration ttl = Duration.ofMinutes(30).plusSeconds(ThreadLocalRandom.current().nextInt(300));
  

Redis Cluster for HA

  spring:
  data:
    redis:
      cluster:
        nodes:
          - redis-1:6379
          - redis-2:6379
          - redis-3:6379
        max-redirects: 3
  

Best Practices

  • Set TTL on all cache entries — no eternal caches
  • Use cache-aside for most scenarios; write-through when consistency is critical
  • Implement multi-level caching for high-QPS read paths
  • Prevent cache stampede with locking or jitter
  • Monitor hit rate, memory usage, and eviction rate