Back to Blog
spring-bootrediscacheperformancejavadistributed-systems

Spring Boot Redis Caching: Beyond @Cacheable — Advanced Patterns (2026)

Spring Boot's @Cacheable is just the beginning. Learn cache-aside, write-through, TTL strategies, cache stampede prevention, and distributed locking with Redis.

J

JOptimize Team

May 25, 2026· 9 min read

@Cacheable on a service method is 5 lines of code and it works. Until you hit cache stampede, stale data serving incorrect results, or a Redis connection pool exhaustion that takes down your entire service. Real production caching requires thinking about eviction, consistency, and failure modes.


The Basics: @Cacheable Done Right

@Configuration public class RedisConfig { @Bean public RedisCacheManager cacheManager(RedisConnectionFactory cf) { RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig() .entryTtl(Duration.ofMinutes(10)) // Default TTL: 10 min .serializeKeysWith( RedisSerializationContext.SerializationPair.fromSerializer( new StringRedisSerializer())) .serializeValuesWith( RedisSerializationContext.SerializationPair.fromSerializer( new GenericJackson2JsonRedisSerializer())); Map<String, RedisCacheConfiguration> configs = new HashMap<>(); configs.put("products", config.entryTtl(Duration.ofHours(1))); configs.put("users", config.entryTtl(Duration.ofMinutes(5))); configs.put("sessions", config.entryTtl(Duration.ofMinutes(30))); return RedisCacheManager.builder(cf) .cacheDefaults(config) .withInitialCacheConfigurations(configs) .build(); } }

Each cache name gets its own TTL — products cache for 1 hour, sessions for 30 minutes. This is already much better than the default which is either no TTL or a single global TTL.


Cache Stampede: The Silent Killer

When a hot cache key expires, every concurrent request simultaneously misses the cache and slams the database:

Key expires → 200 requests hit cache miss simultaneously
→ 200 DB queries fire at once → DB overload → latency spike → more timeouts

Fix 1: Probabilistic Early Expiration

@Service @RequiredArgsConstructor public class ProductCacheService { private final RedisTemplate<String, Product> redisTemplate; private final ProductRepository productRepository; private static final Random RANDOM = new Random(); public Product getProduct(Long id) { String key = "product:" + id; ValueOperations<String, Product> ops = redisTemplate.opsForValue(); Product cached = ops.get(key); if (cached != null) { // Probabilistic early refresh — refresh early when TTL is low Long ttl = redisTemplate.getExpire(key, TimeUnit.SECONDS); if (ttl != null && ttl < 30 && RANDOM.nextDouble() < 0.1) { // 10% chance to refresh 30s before expiry — avoids synchronized expiry refreshAsync(id, key); } return cached; } Product product = productRepository.findById(id).orElseThrow(); ops.set(key, product, Duration.ofMinutes(10)); return product; } @Async public void refreshAsync(Long id, String key) { Product product = productRepository.findById(id).orElseThrow(); redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10)); } }

Fix 2: Distributed Lock on Cache Miss

public Product getProductWithLock(Long id) { String cacheKey = "product:" + id; String lockKey = "lock:product:" + id; Product cached = (Product) redisTemplate.opsForValue().get(cacheKey); if (cached != null) return cached; // Try to acquire lock — only ONE request rebuilds the cache Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, "1", Duration.ofSeconds(10)); if (Boolean.TRUE.equals(locked)) { try { Product product = productRepository.findById(id).orElseThrow(); redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(10)); return product; } finally { redisTemplate.delete(lockKey); } } else { // Another thread is rebuilding — wait briefly and retry try { Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } return getProductWithLock(id); } }

Write-Through vs Cache-Aside

Cache-aside (lazy loading): read from DB on miss, populate cache.

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

Pro: simple. Con: first read after update always hits DB.

Write-through: update cache on every write.

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

Pro: cache always fresh after write. Con: writes are slower (two writes: DB + cache).

Rule: use cache-aside for read-heavy data that can tolerate one stale read. Use write-through for data that's read immediately after every write.


Pattern: Cache Warming on Startup

Avoid the cold start problem by preloading hot data:

@Component @RequiredArgsConstructor public class CacheWarmer implements ApplicationRunner { private final ProductCacheService productCacheService; private final ProductRepository productRepository; @Override public void run(ApplicationArguments args) { log.info("Warming product cache..."); productRepository.findTop100ByOrderByViewCountDesc() .forEach(p -> productCacheService.getProduct(p.getId())); log.info("Cache warm-up complete"); } }

This runs on application startup and populates the 100 most-viewed products before any traffic hits.


Redis Connection Pool Tuning

# application.properties — Lettuce (default) connection pool spring.data.redis.host=redis spring.data.redis.port=6379 spring.data.redis.timeout=2000ms # Lettuce pool (add commons-pool2 dependency) spring.data.redis.lettuce.pool.min-idle=5 spring.data.redis.lettuce.pool.max-idle=10 spring.data.redis.lettuce.pool.max-active=20 spring.data.redis.lettuce.pool.max-wait=1000ms # Fail fast — don't queue indefinitely

The default Lettuce configuration uses a single connection (no pool). Under concurrent load, all requests queue on one connection. With max-active=20, you get 20 concurrent Redis operations.


Handling Redis Failures Gracefully

A Redis outage should NOT take down your application. Wrap cache calls with fallback:

@Service @RequiredArgsConstructor public class ResilientProductService { private final RedisTemplate<String, Product> redisTemplate; private final ProductRepository productRepository; public Product getProduct(Long id) { try { Product cached = (Product) redisTemplate.opsForValue().get("product:" + id); if (cached != null) return cached; } catch (Exception e) { // Redis unavailable — fallthrough to DB log.warn("Redis unavailable, fetching from DB: {}", e.getMessage()); } return productRepository.findById(id).orElseThrow(); } }

Combined with Resilience4j circuit breaker, Redis failures automatically stop hitting a dead Redis and fall back to DB until Redis recovers.


Common Mistakes to Avoid

  • No TTL on cache entries — without TTL, stale data accumulates forever and memory grows unbounded
  • Caching null values — Spring's @Cacheable doesn't cache nulls by default, causing repeated DB hits; use unless = "#result == null" explicitly
  • Serializing JPA entities with lazy relationships — serializing a Hibernate proxy to Redis throws LazyInitializationException; always map to DTOs before caching
  • Not monitoring cache hit rate — a hit rate below 80% usually means TTL is too short or keys aren't right; Micrometer exposes cache.gets tagged by result=hit/miss

Summary

Effective Redis caching goes beyond @Cacheable: per-cache TTL configuration, stampede prevention with distributed locks or probabilistic refresh, write-through for write-heavy paths, cache warming on startup, and graceful fallback to DB on Redis failure. These patterns make caching a resilience feature, not a single point of failure.


Detect Cache Anti-Patterns in Your Codebase

JOptimize detects @Cacheable methods missing TTL configuration, entities being cached directly (lazy load risks), and missing fallback handling for cache operations.

Fix caching anti-patterns before they cause stampede or data staleness in production.

Want to go deeper?

Master Spring Boot, security, and Java performance with hands-on courses.

Detect issues in your project

JOptimize finds N+1 queries, EAGER collections, and 70+ other issues in your Java codebase — in under 30 seconds.