Skip to content
Unverified — AI-generated content. Help verify this page

Caching

Caching is the most impactful performance optimization you can add to a Spring Boot application. A database query that takes 50ms can be served from cache in 0.1ms — a 500x improvement. But caching introduces complexity: stale data, cache invalidation, thundering herd, and memory management. Spring Boot's caching abstraction makes the happy path easy; this page covers the hard parts too.

Setup

xml
<!-- pom.xml -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>

<!-- Pick ONE cache implementation: -->

<!-- Option 1: Caffeine (in-process, recommended for single-instance) -->
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
</dependency>

<!-- Option 2: Redis (distributed, recommended for multi-instance) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
java
@SpringBootApplication
@EnableCaching  // Activates the caching abstraction
public class MyAppApplication {
    public static void main(String[] args) {
        SpringApplication.run(MyAppApplication.class, args);
    }
}

Cache Abstraction Annotations

@Cacheable

java
@Service
@RequiredArgsConstructor
@CacheConfig(cacheNames = "products")  // Default cache name for all methods
@Slf4j
public class ProductService {

    private final ProductRepository productRepository;

    /**
     * First call: executes the method, caches the result.
     * Subsequent calls with same ID: returns cached value, skips DB query.
     */
    @Cacheable(key = "#id")
    public ProductResponse findById(UUID id) {
        log.info("Cache MISS for product: {}", id);
        return productRepository.findById(id)
                .map(ProductResponse::from)
                .orElseThrow(() -> new ResourceNotFoundException("Product", id));
    }

    /**
     * Conditional caching: only cache if the result meets criteria.
     */
    @Cacheable(
            key = "#sku",
            unless = "#result == null",                 // Don't cache null
            condition = "#sku != null && #sku.length() > 0"  // Don't cache empty keys
    )
    public ProductResponse findBySku(String sku) {
        return productRepository.findBySku(sku)
                .map(ProductResponse::from)
                .orElse(null);
    }

    /**
     * Custom key using SpEL.
     */
    @Cacheable(
            cacheNames = "productSearch",
            key = "T(java.lang.String).format('%s_%s_%s', #category, #minPrice, #maxPrice)"
    )
    public List<ProductResponse> findByCategory(
            ProductCategory category, BigDecimal minPrice, BigDecimal maxPrice) {
        return productRepository.findByCategoryAndPriceBetween(category, minPrice, maxPrice)
                .stream().map(ProductResponse::from).toList();
    }
}

@CacheEvict and @CachePut

java
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {

    /**
     * @CachePut: always executes the method and updates the cache.
     * Use for update operations where you want the cache to reflect the new state.
     */
    @CachePut(cacheNames = "products", key = "#id")
    @CacheEvict(cacheNames = "productSearch", allEntries = true)
    @Transactional
    public ProductResponse update(UUID id, UpdateProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product", id));
        product.setName(request.name());
        product.setPrice(request.price());
        return ProductResponse.from(productRepository.save(product));
    }

    /**
     * @CacheEvict: removes entry from cache.
     */
    @CacheEvict(cacheNames = "products", key = "#id")
    @Transactional
    public void delete(UUID id) {
        productRepository.deleteById(id);
    }

    /**
     * @Caching: combine multiple cache operations.
     */
    @Caching(evict = {
            @CacheEvict(cacheNames = "products", key = "#id"),
            @CacheEvict(cacheNames = "productSearch", allEntries = true),
            @CacheEvict(cacheNames = "productsByCategory", allEntries = true)
    })
    @Transactional
    public void deactivate(UUID id) {
        productRepository.deactivateById(id);
    }

    /**
     * Evict all entries in a cache.
     */
    @CacheEvict(cacheNames = "products", allEntries = true)
    @Scheduled(fixedRate = 3600000)  // Every hour
    public void evictAllProductsCache() {
        log.info("Products cache evicted");
    }
}

Caffeine Configuration

Caffeine is the best in-process cache for Java. It uses Window TinyLFU eviction, achieving near-optimal hit rates.

java
@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager();
        manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(10_000)
                .expireAfterWrite(Duration.ofMinutes(30))
                .recordStats());  // Enable cache statistics
        return manager;
    }

    /**
     * Multiple caches with different configurations.
     */
    @Bean
    public CacheManager customCacheManager() {
        SimpleCacheManager manager = new SimpleCacheManager();

        manager.setCaches(List.of(
                buildCache("products", 5000, Duration.ofMinutes(30)),
                buildCache("productSearch", 1000, Duration.ofMinutes(5)),
                buildCache("categories", 100, Duration.ofHours(24)),
                buildCache("userSessions", 10000, Duration.ofMinutes(60)),
                buildCache("rateLimits", 50000, Duration.ofMinutes(1))
        ));

        return manager;
    }

    private CaffeineCache buildCache(String name, int maxSize, Duration ttl) {
        return new CaffeineCache(name,
                Caffeine.newBuilder()
                        .maximumSize(maxSize)
                        .expireAfterWrite(ttl)
                        .recordStats()
                        .build());
    }
}
yaml
# Alternative: YAML-based Caffeine configuration
spring:
  cache:
    type: caffeine
    caffeine:
      spec: maximumSize=10000,expireAfterWrite=30m,recordStats
    cache-names: products,categories,userSessions

Redis Configuration

yaml
# application.yml
spring:
  data:
    redis:
      host: ${REDIS_HOST:localhost}
      port: ${REDIS_PORT:6379}
      password: ${REDIS_PASSWORD:}
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 20
          max-idle: 10
          min-idle: 5
          max-wait: 2000ms

  cache:
    type: redis
    redis:
      time-to-live: 30m
      cache-null-values: false
      key-prefix: "myapp:"
      use-key-prefix: true
java
@Configuration
@EnableCaching
public class RedisCacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeKeysWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new StringRedisSerializer()))
                .serializeValuesWith(
                        RedisSerializationContext.SerializationPair.fromSerializer(
                                new GenericJackson2JsonRedisSerializer()))
                .disableCachingNullValues()
                .prefixCacheNameWith("myapp:");

        // Per-cache TTL configuration
        Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
                "products", defaultConfig.entryTtl(Duration.ofMinutes(30)),
                "categories", defaultConfig.entryTtl(Duration.ofHours(24)),
                "productSearch", defaultConfig.entryTtl(Duration.ofMinutes(5)),
                "userSessions", defaultConfig.entryTtl(Duration.ofHours(1)),
                "rateLimits", defaultConfig.entryTtl(Duration.ofMinutes(1))
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigs)
                .transactionAware()
                .build();
    }

    @Bean
    public RedisTemplate<String, Object> redisTemplate(
            RedisConnectionFactory connectionFactory) {
        RedisTemplate<String, Object> template = new RedisTemplate<>();
        template.setConnectionFactory(connectionFactory);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
        template.afterPropertiesSet();
        return template;
    }
}

Cache-Aside Pattern

The cache-aside (lazy-loading) pattern is what @Cacheable implements. But sometimes you need manual control:

java
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductCacheService {

    private final ProductRepository productRepository;
    private final RedisTemplate<String, Object> redisTemplate;
    private final ObjectMapper objectMapper;

    private static final String CACHE_PREFIX = "product:";
    private static final Duration TTL = Duration.ofMinutes(30);

    /**
     * Manual cache-aside: check cache, load from DB on miss, populate cache.
     */
    public ProductResponse findById(UUID id) {
        String key = CACHE_PREFIX + id;

        // 1. Check cache
        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            log.debug("Cache HIT: {}", key);
            return objectMapper.convertValue(cached, ProductResponse.class);
        }

        // 2. Cache miss — load from database
        log.debug("Cache MISS: {}", key);
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product", id));

        ProductResponse response = ProductResponse.from(product);

        // 3. Populate cache
        redisTemplate.opsForValue().set(key, response, TTL);

        return response;
    }

    /**
     * Write-through: update DB and cache atomically.
     */
    @Transactional
    public ProductResponse update(UUID id, UpdateProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Product", id));

        product.setName(request.name());
        product.setPrice(request.price());
        Product saved = productRepository.save(product);

        ProductResponse response = ProductResponse.from(saved);

        // Update cache
        String key = CACHE_PREFIX + id;
        redisTemplate.opsForValue().set(key, response, TTL);

        return response;
    }

    /**
     * Cache-aside with distributed lock (prevents thundering herd).
     */
    public ProductResponse findByIdWithLock(UUID id) {
        String key = CACHE_PREFIX + id;
        String lockKey = "lock:" + key;

        Object cached = redisTemplate.opsForValue().get(key);
        if (cached != null) {
            return objectMapper.convertValue(cached, ProductResponse.class);
        }

        // Acquire distributed lock
        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, "1", Duration.ofSeconds(10));

        if (Boolean.TRUE.equals(locked)) {
            try {
                // Double-check after acquiring lock
                cached = redisTemplate.opsForValue().get(key);
                if (cached != null) {
                    return objectMapper.convertValue(cached, ProductResponse.class);
                }

                // Load from DB and cache
                ProductResponse response = loadFromDb(id);
                redisTemplate.opsForValue().set(key, response, TTL);
                return response;
            } finally {
                redisTemplate.delete(lockKey);
            }
        } else {
            // Another thread is loading — wait and retry
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
            return findByIdWithLock(id);
        }
    }
}

Multi-Layer Caching

Combine Caffeine (L1, in-process) with Redis (L2, distributed):

java
@Configuration
@EnableCaching
public class MultiLayerCacheConfig {

    @Bean
    @Primary
    public CacheManager multiLayerCacheManager(
            RedisConnectionFactory redisFactory) {

        return new CompositeCacheManager(
                caffeineCacheManager(),     // L1: local, fast
                redisCacheManager(redisFactory)  // L2: distributed, shared
        );
    }

    private CacheManager caffeineCacheManager() {
        CaffeineCacheManager manager = new CaffeineCacheManager("products");
        manager.setCaffeine(Caffeine.newBuilder()
                .maximumSize(1000)
                .expireAfterWrite(Duration.ofMinutes(5)));
        return manager;
    }

    private CacheManager redisCacheManager(RedisConnectionFactory factory) {
        return RedisCacheManager.builder(factory)
                .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                        .entryTtl(Duration.ofMinutes(30)))
                .build();
    }
}

Cache Metrics

java
@Component
@RequiredArgsConstructor
public class CacheMetricsExporter {

    private final CacheManager cacheManager;
    private final MeterRegistry meterRegistry;

    @Scheduled(fixedRate = 60_000)
    public void exportCacheMetrics() {
        if (cacheManager instanceof CaffeineCacheManager caffeine) {
            caffeine.getCacheNames().forEach(name -> {
                Cache cache = caffeine.getCache(name);
                if (cache != null) {
                    com.github.benmanes.caffeine.cache.Cache<?, ?> nativeCache =
                            (com.github.benmanes.caffeine.cache.Cache<?, ?>)
                                    cache.getNativeCache();
                    CacheStats stats = nativeCache.stats();

                    Gauge.builder("cache.hit.rate", stats, CacheStats::hitRate)
                            .tag("cache", name).register(meterRegistry);
                    Gauge.builder("cache.size", nativeCache, c -> c.estimatedSize())
                            .tag("cache", name).register(meterRegistry);
                    Gauge.builder("cache.eviction.count", stats, CacheStats::evictionCount)
                            .tag("cache", name).register(meterRegistry);
                }
            });
        }
    }
}

Cache Strategy Decision Matrix

ScenarioStrategyTTLEviction
Product catalogCache-aside (Caffeine + Redis)30 minOn update + TTL
User sessionsRedis only1 hourOn logout + TTL
Rate limitsRedis (atomic counters)1 minuteTTL
Config/feature flagsCaffeine5 minOn refresh
Search resultsRedis5 minTTL only
Static reference dataCaffeine24 hoursTTL + startup

When NOT to cache

Do not cache: real-time data (stock prices), highly personalized data (user dashboards with live state), data that changes every request, or data that must be consistent across nodes without any staleness tolerance. Caching adds complexity — only cache when the read-to-write ratio justifies it.

Further Reading

Common Pitfalls

Pitfall 1: Caching null values or error responses

Caching a null result or an error means subsequent requests continue to get the wrong answer even after the underlying issue is fixed. Fix: Use @Cacheable(unless = "#result == null") to prevent caching null values. Never cache exception responses.

Pitfall 2: Not invalidating cache on updates

Updating data in the database without evicting the corresponding cache entry serves stale data to all subsequent reads. Fix: Use @CacheEvict on update and delete methods. Use @CachePut when you want to update the cache with the new value. Combine with @Caching for multi-cache eviction.

Pitfall 3: Using in-memory caching (Caffeine) across multiple instances

Each application instance has its own Caffeine cache. After an update on instance A, instance B still serves stale cached data until TTL expires. Fix: Use Redis for distributed caching across multiple instances. Or use multi-layer caching: Caffeine (L1, short TTL) + Redis (L2, longer TTL) for both speed and consistency.

Pitfall 4: Setting cache TTL too long

Long TTLs reduce database load but increase the window for serving stale data. For frequently changing data, this creates visible inconsistencies. Fix: Match TTL to data volatility: 5 minutes for search results, 30 minutes for product catalogs, 24 hours for static reference data. Use event-based eviction (@CacheEvict) in addition to TTL.

Pitfall 5: Thundering herd problem on cache expiration

When a popular cache entry expires, all concurrent requests simultaneously hit the database to reload it, potentially overloading the database. Fix: Use Caffeine's refreshAfterWrite for proactive background refresh, or implement distributed locking with Redis to ensure only one thread reloads the cache while others wait.

Pitfall 6: Not monitoring cache hit/miss rates

Operating caches without visibility into hit rates means you cannot tell if caching is actually helping or if the cache is too small or TTL too short. Fix: Enable recordStats() on Caffeine caches and expose metrics via Micrometer/Prometheus. Alert when hit rate drops below 80%.

Interview Questions

Q1: What is the difference between @Cacheable, @CachePut, and @CacheEvict?

Answer

@Cacheable checks the cache before method execution. On a cache hit, it returns the cached value without executing the method. On a miss, it executes the method and caches the result. @CachePut always executes the method and updates the cache with the result -- use it on update operations to keep the cache fresh. @CacheEvict removes entries from the cache -- use it on delete operations or when data changes. @CacheEvict(allEntries = true) clears the entire cache. @Caching combines multiple cache operations on a single method.

Q2: What is the cache-aside pattern and how does Spring's @Cacheable implement it?

Answer

The cache-aside (lazy-loading) pattern works as follows: (1) Application checks the cache for the requested data. (2) On a cache hit, return the cached data. (3) On a cache miss, load data from the database, store it in the cache, and return it. Spring's @Cacheable annotation implements this pattern automatically. The cache key is derived from the method parameters (or a custom SpEL expression), and the cache name is specified in the annotation. The pattern is simple but requires cache invalidation on writes to prevent stale data.

Q3: When should you use Caffeine vs. Redis for caching?

Answer

Caffeine (in-process): Sub-microsecond latency, no network overhead, automatic eviction with Window TinyLFU (near-optimal hit rates). Use for single-instance applications, hot data that fits in memory, and latency-critical paths. Redis (distributed): Shared across all application instances, survives application restarts, supports complex data structures. Use for multi-instance deployments, session storage, rate limiting, and data that must be consistent across instances. Multi-layer (Caffeine L1 + Redis L2): Combines the speed of in-process caching with the consistency of distributed caching.

Q4: How do you prevent cache stampede (thundering herd)?

Answer

Cache stampede occurs when a popular cache entry expires and many concurrent requests simultaneously load the data from the database. Solutions: (1) Distributed lock: Use Redis SETNX to ensure only one thread loads the data while others wait. (2) Caffeine's refreshAfterWrite: Proactively refreshes entries in the background before they expire, so the old value is served while the new one loads. (3) Probabilistic early expiration: Each request has a small random chance of refreshing the cache before actual expiration, spreading the load. (4) Request coalescing: Multiple concurrent requests for the same key are collapsed into a single database query.

Q5: How do you handle cache warming at application startup?

Answer

Cache warming pre-populates the cache with frequently accessed data before the application starts serving traffic. Approaches: (1) Use @PostConstruct or ApplicationRunner to load hot data into the cache at startup. (2) Use @Scheduled with initialDelay = 0 to warm the cache immediately and refresh periodically. (3) For distributed caches, use a dedicated cache-warming service that runs independently. (4) For Caffeine, use CacheLoader with LoadingCache to define how entries are loaded on first access. Warming prevents the initial cold-cache penalty where the first users experience slow responses.

"What I cannot create, I do not understand." — Richard Feynman