Spring Boot Caching

Spring Boot provides multiple ways for caching of data from in memory cache to distributed cache support. Here we are discussing spring boot caching with redis.

Redis integration with Spring Boot provides a powerful, high-performance caching solution that significantly improves application performance and scalability.


Why Redis for Caching?

Redis isn't just any cache; it's a superb choice for modern applications because it's:

  • โšก Ultra-Fast: Being an in-memory data store, reads and writes are exceptionally fast.

  • ๐ŸŒ Distributed & Scalable: It runs as a separate server, allowing multiple instances of your app to share one consistent cache, and can be clustered for high availability.

  • ๐Ÿงฐ Rich with Features: Redis offers Time-To-Live (TTL) settings, smart eviction policies, and versatile data structures that go far beyond simple key-value pairs.


Level 1: Core Redis Configuration ๐ŸŒŠ

Step 1: Add the Dependencies

You'll need two key starters in your pom.xml to get Spring's caching abstraction and Redis support.

XML

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

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

Step 2: Configure Redis Connection

Next, tell your application how to connect to your Redis server. Add these properties to your application.properties file.

Properties

# Redis server host
spring.data.redis.host=localhost
# Redis server port
spring.data.redis.port=6379

# Optional: Add a password if your Redis is secured
# spring.data.redis.password=yourpassword

# Connection timeout
spring.data.redis.timeout=2000ms

# Lettuce connection pool settings
spring.data.redis.lettuce.pool.max-active=8
spring.data.redis.lettuce.pool.max-idle=8

# application.properties for a Redis Cluster for distributed redis cluster
spring.data.redis.cluster.nodes=redis1:6379,redis2:6379,redis3:6379
spring.data.redis.cluster.max-redirects=3

Step 3: Enable Caching

The final step is to activate Spring's caching capabilities. Just add the @EnableCaching annotation to your main application class.

Java

@SpringBootApplication
@EnableCaching // <-- This little annotation does all the magic! โœจ
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Level 2: The Magic of Cache Annotations โœจ

Spring's cache abstraction provides simple yet powerful annotations to manage your cache without writing boilerplate code.

@Cacheable:

Key Idea: If a cache entry exists, return it. If not, run the method, cache the result, and then return it.

@Service
public class ProductService {
    
    // The result of this method will be stored in a cache named "products"
    // with a key equal to the 'id' parameter.
    @Cacheable(value = "products", key = "#id")
    public Product findById(Long id) {

        System.out.println("Fetching product from DB for ID: " + id);

        return productRepository.findById(id).orElse(null);
    }

     @Cacheable(value = "products", key = "#category + '_' + #page")
    public List<Product> findByCategory(String category, int page) {
        return productRepository.findByCategoryWithPagination(category, page);
    }

}

@CachePut: Always Update the Cache

The @CachePut annotation always executes the method and updates the cache with the result:

Key Idea: Always run the method and update the cache with the new result.

@Service
public class ProductService {

    // This method will always execute, and the returned Product
    // will update the entry in the "products" cache.
    @CachePut(value = "products", key = "#product.id")
    public Product updateProduct(Product product) {
        System.out.println("Updating product in DB and cache...");
        return productRepository.save(product);
    }
}

@CacheEvict: Clearing Out the Old

The @CacheEvict annotation removes entries from the cache:

Key Idea: Run the method and remove an entry from the cache.

@Service
public class ProductService {

    // Removes the product with the matching 'id' from the cache.
    @CacheEvict(value = "products", key = "#id")
    public void deleteProduct(Long id) {
        System.out.println("Deleting product from DB and cache...");
        productRepository.deleteById(id);
    }

    // Use allEntries=true to clear the entire cache.
    // Useful for bulk update scenarios.
    @CacheEvict(value = "products", allEntries = true)
    public void clearAllProductCache() {
        System.out.println("Clearing all products from the cache!");
    }
}

@Caching and @CacheConfig:

Sometimes you need to perform multiple cache operations at once. @Caching lets you combine them. To avoid repeating value = "products" everywhere, you can use @CacheConfig at the class level.

@Service
@CacheConfig(cacheNames = "products") // Common cache settings for the class
public class ProductService {
    
    @Cacheable(key = "#id")
    public Product findById(Long id) { /* ... */ }

    // Evict a single product AND a related category cache
    @Caching(evict = {
        @CacheEvict(key = "#product.id"),
        @CacheEvict(value = "productsByCategory", key = "#product.category")
    })
    public Product updateProductWithCategoryChange(Product product) {
        return productRepository.save(product);
    }
}

Key Generators

By default, Spring creates a key from your method parameters. For more complex scenarios, you can use Spring Expression Language (SpEL) or create a custom KeyGenerator.

SpEL-Based Keys

SpEL gives you incredible flexibility right inside the annotation. We can use class methods, variables and method response from anther class .

// Key = "user_123_ADMIN"
@Cacheable(value = "users", key = "'user_' + #user.id + '_' + #user.role.name")
public UserProfile getUserProfile(User user) { /* ... */ }

private final String CACHE_NAME = "quickReplies";

// calling static method of class as key 
private final String CACHE_KEY = "T(com.glut.app.util.JwtUtil).getLoggedInUserName()";

@Cacheable(value = CACHE_NAME, key = CACHE_KEY)

Custom KeyGenerator

For a reusable, complex key strategy, implement the KeyGenerator interface.

@Component("customKeyGenerator")
public class CustomKeyGenerator implements KeyGenerator {
    @Override
    public Object generate(Object target, Method method, Object... params) {
        // Your custom logic to build a unique key string
        return target.getClass().getSimpleName() + "_"
             + method.getName() + "_"
             + StringUtils.arrayToDelimitedString(params, "_");
    }
}

// Then use it in your annotation
@Cacheable(value = "products", keyGenerator = "customKeyGenerator")
public Product findProductByComplexCriteria(String category, String brand) { /* ... */ }


Level 3: Custom Configuration โš™๏ธ

Different serialization approaches offer various trade-offs:

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
            // Set a default 10-minute TTL for all cache entries
            .entryTtl(Duration.ofMinutes(10)) 
            // Don't cache 'null' values
            .disableCachingNullValues() 
            
            // Use String serializer for keys
            .serializeKeysWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new StringRedisSerializer())) 
            
            // Use JSON serializer for values
            .serializeValuesWith(RedisSerializationContext.SerializationPair
                .fromSerializer(new GenericJackson2JsonRedisSerializer()));
            
            
         // Custom configurations for specific caches
        Map<String, RedisCacheConfiguration> cacheConfigurations = Map.of(
            "userCache", defaultConfig.entryTtl(Duration.ofMinutes(20)),
            "productCache", defaultConfig.entryTtl(Duration.ofMinutes(5))
        );

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .withInitialCacheConfigurations(cacheConfigurations)
            
            //Cache operations will only be executed if the surrounding database transaction commits successfully
            .transactionAware() // Make cache operations transactional!
            .build();
    }
}

 // Custom configurations for specific caches
@Bean
public RedisCacheManagerBuilderCustomizer redisCacheManagerBuilderCustomizer() {
    return (builder) -> builder
        .withCacheConfiguration("userCache", // Specific config for "userCache"
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(20)))
        .withCacheConfiguration("productCache", // Specific config for "productCache"
            RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(5)));
}

//Type-Safe Serialization
//For specific object types, configure dedicated serializers:

@Bean
public Jackson2JsonRedisSerializer<User> userSerializer() {
    Jackson2JsonRedisSerializer<User> serializer = 
        new Jackson2JsonRedisSerializer<>(User.class);
    ObjectMapper mapper = new ObjectMapper();
    mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
    mapper.enableDefaultTyping(ObjectMapper.DefaultTyping.NON_FINAL);
    serializer.setObjectMapper(mapper);
    return serializer;
}


Level 4: Production-Ready Patterns ๐Ÿš€

Now let's level up with advanced patterns to make your caching robust, performant, and reliable in a production environment.

Cache Eviction Strategies and Policies

Configure Redis eviction policies for memory management:

@Bean
public RedisConnectionFactory connectionFactory() {
    LettuceConnectionFactory factory = new LettuceConnectionFactory();
    // Set eviction policy through Redis configuration
    return factory;
}

Redis supports several eviction policies:

  • allkeys-lru: Remove least recently used keys

  • allkeys-lfu: Remove least frequently used keys

  • volatile-lru: Remove LRU keys with expiration set

  • volatile-ttl: Remove keys with shortest TTL

Programmatic Cache Management

Direct cache manipulation when needed:

@Service
public class CacheManagementService {
    
    @Autowired
    private CacheManager cacheManager;
    
    public void evictUserCache(Long userId) {
        Cache userCache = cacheManager.getCache("users");
        if (userCache != null) {
            userCache.evict(userId);
        }
    }
    
    public void clearAllCaches() {
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                cache.clear();
            }
        });
    }
}

Conditional Eviction

Implement conditional cache eviction:

@CacheEvict(value = "products", key = "#product.id",  condition = "#product.status == 'DISCONTINUED'")
public Product updateProductStatus(Product product) {
    return productRepository.save(product);
}

@CacheEvict(value = "users", key = "#user.id", unless = "#user.isVip == true")
public void updateUser(User user) {
    userRepository.save(user);
}

Distributed Caching Scenarios

Multi-Instance Cache Synchronization

Redis provides distributed caching capabilities that ensure cache consistency across multiple application instances:

@Configuration
public class DistributedCacheConfig {
    
    @Bean
    public RedisCacheManager distributedCacheManager(RedisConnectionFactory connectionFactory) {
        
        RedisCacheConfiguration config = RedisCacheConfiguration
            .defaultCacheConfig()
            .prefixCacheNameWith("app1::")
            .entryTtl(Duration.ofMinutes(30))
            .serializeValuesWith(RedisSerializationContext.SerializationPair
            .fromSerializer(new GenericJackson2JsonRedisSerializer()));
        
        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(config)
            .build();
    }
}

Redis Cluster Configuration

For high-availability scenarios, configure Redis Cluster:

spring.data.redis.cluster.nodes=redis1:6379,redis2:6379,redis3:6379
spring.data.redis.cluster.max-redirects=3
spring.data.redis.lettuce.cluster.refresh.adaptive=true
spring.data.redis.lettuce.cluster.refresh.period=60s
@Configuration
public class RedisClusterConfig {
    
    @Bean
    public LettuceConnectionFactory redisConnectionFactory() {
        RedisClusterConfiguration clusterConfig = 
            new RedisClusterConfiguration(Arrays.asList(
                "redis1:6379", "redis2:6379", "redis3:6379"));
        
        return new LettuceConnectionFactory(clusterConfig);
    }
}

Redis Cluster automatically partitions data across multiple nodes and provides fault tolerance

Error Handling & Resilience

What if your Redis server goes down? By default, your application will throw an exception and fail the request. You can define a custom CacheErrorHandler to simply log the error and allow the application to proceed by fetching data from the database, ensuring your app remains resilient.

@Configuration
public class CacheErrorHandlingConfig extends CachingConfigurerSupport {
    @Override
    public CacheErrorHandler errorHandler() {
        return new CacheErrorHandler() {
            @Override
            public void handleCacheGetError(RuntimeException exception, 
                    Cache cache, Object key) {
                log.warn("Cache get error for key {}: {}", key, exception.getMessage());
                // Continue without cache
            }
            
            @Override
            public void handleCachePutError(RuntimeException exception, 
                    Cache cache, Object key, Object value) {
                log.warn("Cache put error for key {}: {}", key, exception.getMessage());
                // Continue without caching
            }
            
            @Override
            public void handleCacheEvictError(RuntimeException exception, 
                    Cache cache, Object key) {
                log.warn("Cache evict error for key {}: {}", key, exception.getMessage());
            }
            
            @Override
            public void handleCacheClearError(RuntimeException exception, Cache cache) {
                log.warn("Cache clear error: {}", exception.getMessage());
            }
        };
    }


// Handle serialization problems effectively
  @Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
    RedisTemplate<String, Object> template = new RedisTemplate<>();
    template.setConnectionFactory(factory);
    
    // Handle serialization errors gracefully
    template.setDefaultSerializer(new GenericJackson2JsonRedisSerializer() {
        @Override
        public byte[] serialize(Object source) throws SerializationException {
            try {
                return super.serialize(source);
            } catch (Exception e) {
                log.warn("Serialization failed for object: {}", source, e);
                return new JdkSerializationRedisSerializer().serialize(source);
            }
        }
    });
    
    return template;
} 


}
//This ensures application resilience when Redis is unavailable

Performance Optimization and Monitoring

Cache Statistics and Metrics

Enable cache statistics for monitoring:

spring.cache.redis.enable-statistics=true
management.endpoints.web.exposure.include=metrics,prometheus
management.endpoint.metrics.enabled=true
@Component
public class CacheMetricsConfig {
    
    @Autowired
    private CacheMetricsRegistrar cacheMetricsRegistrar;
    
    @Autowired
    private CacheManager cacheManager;
    
    @EventListener(ApplicationStartedEvent.class)
    public void registerCacheMetrics() {
        cacheManager.getCacheNames().forEach(cacheName -> {
            Cache cache = cacheManager.getCache(cacheName);
            if (cache != null) {
                cacheMetricsRegistrar.bindCacheToRegistry(cache);
            }
        });
    }
    
    // additional config
    @Autowired
    private MeterRegistry meterRegistry;
    
    @EventListener
    @Async
    public void handleCacheHit(CacheHitEvent event) {
        Counter.builder("cache.hit")
            .tag("cache", event.getCacheName())
            .register(meterRegistry)
            .increment();
    }
    
    @EventListener
    @Async
    public void handleCacheMiss(CacheMissEvent event) {
        Counter.builder("cache.miss")
            .tag("cache", event.getCacheName())
            .register(meterRegistry)
            .increment();
    }
    
    
}

This configuration exposes cache hit/miss ratios, cache size, and eviction metrics through Spring Boot Actuator

You can now access the /actuator/metrics/cache.gets endpoint to see your hit/miss ratio. A high hit ratio (e.g., >80%) means your cache is working well!

Cache Warming Strategies

Implement cache warming for critical data:

@Component
public class CacheWarmupService {
    
    @Autowired
    private ProductService productService;
    
    @EventListener(ApplicationReadyEvent.class)
    public void warmupCache() {
        log.info("Starting cache warmup...");
        
        // Warm up popular products
        List<Long> popularProductIds = getPopularProductIds();
        popularProductIds.parallelStream()
            .forEach(productService::findById);
        
        log.info("Cache warmup completed for {} products", popularProductIds.size());
    }
    
    @Scheduled(fixedRate = 300000) // 5 minutes
    public void refreshCriticalData() {
        // Refresh time-sensitive cached data
        criticalDataService.refreshCache();
    }
}

Advanced Use Cases

  • Cache Warming: Pre-load critical data into the cache on application startup using an @EventListener(ApplicationReadyEvent.class) to ensure fast responses from the very first request.

  • Multi-Level Caching: Combine a fast, in-memory local cache (like Caffeine) with a distributed Redis cache. This provides lightning-fast reads for the hottest data while still offering distributed consistency.

  • Session Management: Offload HTTP session storage to Redis using spring-session-data-redis to enable scalable, stateless application instances.


Level 5: Security & Testing ๐Ÿ›ก๏ธ

Finally, let's secure our connections and ensure our caching logic is bug-free.

Securing Your Redis Connection

In production, you should always secure your Redis instance with a password and SSL/TLS.

Properties

# Enable password authentication
spring.data.redis.password=your-very-secure-password
# Enable SSL for the connection
spring.data.redis.ssl.enabled=true

Testing Your Cache Logic with Testcontainers

Never guess if your caching worksโ€”test it! Testcontainers makes it incredibly easy to spin up a real Redis container for your integration tests. This ensures your code works with a genuine Redis instance.

Java

@SpringBootTest
@Testcontainers // Enable Testcontainers support
class RedisCacheIntegrationTest {
    
    @Container // Start a Redis container for the test
    static GenericContainer<?> redis = new GenericContainer<>("redis:7-alpine")
            .withExposedPorts(6379);
    
    @DynamicPropertySource // Dynamically set the Redis host and port for the test
    static void configureProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", redis::getFirstMappedPort);
    }
    
    @Autowired
    private ProductService productService;
    
    @Test
    void testCachingBehavior() {
        // First call - should be a cache miss
        Product product1 = productService.findById(1L);
        // Second call - should be a cache hit!
        Product product2 = productService.findById(1L);
        
        // Assert that the object is the same, proving it came from the cache
        assertThat(product2).isSameAs(product1);
    }
}

Conclusion ๐ŸŽ‰

You've made it! You now have a solid understanding of how to implement powerful, performant, and resilient caching in your Spring Boot applications using Redis.

We've covered:

  • The Basics: Setup and simple annotations like @Cacheable.

  • Customization: Configuring TTL, serialization, and key generation.

  • Production Patterns: Distributed clustering, transactions, error handling, and monitoring.

  • Testing & Security: Writing reliable tests and securing your instance.

By applying these patterns, you can take your application's performance to the next level. Happy coding!

Last updated