Master caching patterns. When to cache, what to cache, how to invalidate. Real-world trade-offs: memory vs latency, consistency vs performance.
JOptimize Team
Scenario: Database query takes 500ms. With cache:
Caching is simple in theory. Brutal in practice.
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager() { CaffeineCacheManager cacheManager = new CaffeineCacheManager("users", "products"); cacheManager.setCaffeine(Caffeine.newBuilder() .maximumSize(10000) // Max 10k entries .expireAfterWrite(10, TimeUnit.MINUTES) // 10 min TTL .recordStats()); // Track hit/miss rates return cacheManager; } } @Service public class UserService { @Cacheable("users") // Automatic caching by ID public User findById(Long id) { return userRepository.findById(id); // Hit = 5ms, Miss = 500ms } }
Pros:
Cons:
JOptimize detects this automatically
Scan your Java project in 30 seconds — free, no credit card.
@Configuration public class CacheConfig { @Bean public CacheManager cacheManager(LettuceConnectionFactory connectionFactory) { return new RedisCacheManager.create( RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer( new StringRedisSerializer())) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer())), connectionFactory); } } @Service public class UserService { @Cacheable("users", cacheManager = "redisCache") public User findById(Long id) { return userRepository.findById(id); } }
Pros:
Cons:
// ❌ BAD: Query everything, cache it @Cacheable("users") public List<User> getAllUsers() { return userRepository.findAll(); // Loads 1M rows! } // ✅ GOOD: Optimize query first @Cacheable("active-users") public List<User> getActiveUsers() { return userRepository.findByStatusAndCreatedAfter( "ACTIVE", LocalDateTime.now().minusDays(30)); // Only 10k rows }
Principle: Optimize the query first, then cache the result.
cache: user: ttl: 10 minutes # Auto-expire after 10 min product: ttl: 1 hour # Slower-changing data config: ttl: 24 hours # Nearly static
Pros: Simple, no coordination needed Cons: Stale data possible for up to TTL duration
@Service public class UserService { @CacheEvict("users", key = "#user.id") @Transactional public User updateUser(User user) { return userRepository.save(user); } } // Or publish event @EventListener public void onUserUpdated(UserUpdatedEvent event) { redisTemplate.delete("user:" + event.userId); }
Pros: Fresh data immediately Cons: Requires event infrastructure, can miss updates
public User updateUser(User user) { // 1. Update database userRepository.save(user); // 2. Update cache immediately cache.put("user:" + user.id, user); return user; }
Pros: Guaranteed fresh Cons: Slower writes, write fails if cache down
public User updateUser(User user) { // 1. Update cache immediately cache.put("user:" + user.id, user); // 2. Queue database update (async) asyncQueue.publish(new UpdateUserEvent(user)); return user; // Instant response }
Pros: Fastest writes Cons: Risk of data loss if queue crashes
// ❌ Problem: 1000 requests, key expires // All 1000 hit database simultaneously // Database overloaded // ✅ Solution: Probabilistic early expiration if (cache.getIfPresent(key) == null && Math.random() < 0.1) { // 10% preload cache.put(key, expensiveCompute()); // Refresh before expiry }
// ❌ Problem: Cache data from failed request Object data = externalAPI.fetch(); // Returns garbage on error cache.put(key, data); // Cached garbage for 10 min // ✅ Solution: Validate before caching Object data = externalAPI.fetch(); if (isValid(data)) { cache.put(key, data); } else { throw new InvalidDataException(); }
// ❌ Problem: All data expires at once // Midnight: 1M cache entries expire // Same second: 1M database queries // Database down // ✅ Solution: Stagger expiration expireAfterWrite(10 + random(0, 5), TimeUnit.MINUTES) // 10-15 min
Cache Hit Rate = Hits / (Hits + Misses) Good: > 80% Okay: 60-80% Bad: < 60%
Caffeine cache with metrics: .recordStats() // Enables hit/miss tracking meterRegistry.gauge("cache.hits", cache::hitCount) meterRegistry.gauge("cache.misses", cache::missCount) meterRegistry.gauge("cache.size", cache::size)
Without cache:
Database query: 500ms P99 latency: 2100ms (including network, parsing) QPS: 100 Cost: Normal
With Redis cache:
Cache hit (80%): 20ms (80% of requests) Cache miss (20%): 500ms (20% of requests) Avg P99: 300ms (15x faster!) QPS: 500 (5x more throughput) Cost: +$200/month (Redis) ROI: 100x (from 5x QPS gain alone)
Is data accessed repeatedly? ├─ No → Don't cache ├─ Yes → Is data larger than 1GB? │ ├─ No → Use Caffeine (in-process) │ ├─ Yes → Use Redis (distributed) │ ├─ Data changes frequently? │ ├─ Every second → Don't cache (or short TTL) │ ├─ Every hour → Event-based invalidation │ ├─ Daily → TTL cache (24h) │ ├─ Never → TTL cache (infinity)
Caching is about trade-offs:
Master these patterns, and you'll know when to cache and how to cache safely.
Get started: joptimize.io
JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.