Caching with @Cacheable and Redis in Spring Boot
1. Overview
Many applications repeatedly call the same slow query. Examples include fetching product information, configuration, or exchange rates. Spring’s Cache Abstraction solves this with a few annotations. Redis acts as a shared cache store for multiple instances.
After reading this, you’ll be able to:
- Configure Redis as a cache provider.
- Use
@Cacheable,@CachePut,@CacheEvict. - Set TTL individually for each cache and store data as JSON.
- Test cache behavior using JUnit 5 and Testcontainers.
2. Maven Dependencies
You need two starters: one for Spring Cache, one for Redis.
<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>
For testing, add Testcontainers. Spring Boot already manages the versions of these dependencies:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
3. Configuring Redis Connection
Declare the Redis address and choose Redis as the cache provider in application.yml:
spring:
data:
redis:
host: localhost
port: 6379
cache:
type: redis
Meaning of each property:
spring.data.redis.*configures the connection. Spring Boot uses the Lettuce client by default.spring.cache.type: redisexplicitly specifies the provider. If Redis is on the classpath, Spring Boot will select it automatically. Specifying it explicitly helps avoid confusion when multiple providers are available.
You can quickly run a local Redis using Docker:
docker run -d --name redis -p 6379:6379 redis:7-alpine
4. Basic Example with @Cacheable
4.1. Model and Repository Simulating a Slow Query
We use a simple record for a product:
public record Product(Long id, String name, BigDecimal price) {}
The repository below simulates a slow database. It also counts the number of calls so we can verify caching:
@Repository
public class ProductRepository {
private final Map<Long, Product> store = new ConcurrentHashMap<>(Map.of(
1L, new Product(1L, "Nón lá Huế", new BigDecimal("150000")),
2L, new Product(2L, "Áo bà ba", new BigDecimal("320000"))
));
private final AtomicInteger queryCount = new AtomicInteger();
public Optional<Product> findById(Long id) {
queryCount.incrementAndGet();
sleep(1000); // simulate query taking 1 second
return Optional.ofNullable(store.get(id));
}
public Product save(Product product) {
store.put(product.id(), product);
return product;
}
public void deleteById(Long id) {
store.remove(id);
}
public int getQueryCount() { return queryCount.get(); }
public void resetQueryCount() { queryCount.set(0); }
private static void sleep(long millis) {
try {
Thread.sleep(millis);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}
4.2. Enable Cache and Mark Methods
Enable caching with @EnableCaching. Place this annotation on a @Configuration class (we’ll write this in section 5) or on the main class. Then mark methods that need caching:
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Cacheable(cacheNames = "products", key = "#id")
public Product findById(Long id) {
return productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id));
}
}
The processing flow is as follows:
cacheNames = "products"is the cache name. On Redis, keys by default have the formproducts::<key>.key = "#id"is a SpEL expression that uses theidparameter as the key. If the method has only one parameter, you can omit this attribute.- On the first call, Spring doesn’t find the key in the cache. It runs the method, then saves the result to Redis.
- On subsequent calls with the same
id, Spring returns the result from Redis. The method is not executed. - If the method throws an exception (
ProductNotFoundException), Spring does not store anything in the cache.
Call findById(1L) twice, then check Redis:
redis-cli KEYS '*'
redis-cli TTL products::1
1) "products::1"
(integer) 1798
The first call takes about 1 second. The second call takes only a few milliseconds. TTL is about 1800 seconds because we’ll configure 30 minutes for the products cache in the next section.
5. Configuring RedisCacheManager: TTL and Serializer
By default, RedisCacheManager uses JDK serialization. This approach has three drawbacks:
- Classes must implement
Serializable. - Data in Redis is binary, so it’s hard to read.
- Data can be corrupted when you change the class structure.
By default, cache also has no TTL, meaning entries live forever. We’ll fix both of these issues:
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheConfiguration defaultCacheConfig() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
@Bean
public RedisCacheManagerBuilderCustomizer productCacheCustomizer() {
return builder -> builder.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(30))
.disableCachingNullValues()
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new Jackson2JsonRedisSerializer<>(Product.class))));
}
}
Explanation of each bean:
- The
RedisCacheConfigurationbean is the default configuration for all caches. Spring Boot automatically detects this bean. All entries have a TTL of 10 minutes,nullvalues are not stored, and values are saved as JSON. RedisCacheManagerBuilderCustomizeroverrides the configuration for theproductscache specifically. This cache has a TTL of 30 minutes and uses a type-specific serializer forProduct.class.
Why does products use Jackson2JsonRedisSerializer<>(Product.class)? GenericJackson2JsonRedisSerializer needs to store type information (@class) in the JSON. With final classes like record, this mechanism tends to return a LinkedHashMap instead of the correct type, causing ClassCastException. A type-specific serializer avoids this problem entirely.
Data in Redis is now directly readable:
redis-cli GET products::1
"{\"id\":1,\"name\":\"Nón lá Huế\",\"price\":150000}"
Note: When you explicitly declare the
RedisCacheConfigurationbean, properties likespring.cache.redis.time-to-live,spring.cache.redis.key-prefix… inapplication.ymlwill be ignored. Choose one configuration approach and don’t mix them.
6. Updating and Evicting Cache: @CachePut and @CacheEvict
Cache is only useful when the data in it is correct. When a product changes, you must update or evict the corresponding entry.
@CachePut(cacheNames = "products", key = "#product.id()")
public Product update(Product product) {
return productRepository.save(product);
}
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(Long id) {
productRepository.deleteById(id);
}
@CacheEvict(cacheNames = "products", allEntries = true)
public void clearAll() {
// example: call after bulk importing products
}
Differences between the annotations:
@CachePutalways runs the method, then writes the returned result to the cache with the key#product.id(). Therefore, the method must return the correct object to be cached. If it returnsvoid, the cache won’t have the correct data.@CacheEvictremoves the entry for the key after the method runs successfully. If you need to evict before the method runs, addbeforeInvocation = true.allEntries = trueremoves the entireproductscache. With Redis, this operation scans keys by pattern. Use it only when necessary.
7. Controlling Caching: condition, unless, sync
@Cacheable has a few more useful attributes:
@Cacheable(cacheNames = "productSearch",
key = "#keyword.toLowerCase()",
condition = "#keyword.length() >= 3",
unless = "#result.isEmpty()")
public List<Product> search(String keyword) {
// slow query...
return List.of();
}
@Cacheable(cacheNames = "products", key = "#id", sync = true)
public Product findHotProduct(Long id) {
return findFromDatabase(id); // private method in same class, read directly from database
}
Meaning of each attribute:
conditionis evaluated before the method runs. Here, keywords shorter than 3 characters won’t be searched and won’t be cached.unlessis evaluated after the method runs and can use#result. Here, empty lists won’t be cached.key = "#keyword.toLowerCase()"allows “Nón” and “nón” to share the same cache entry.sync = trueprevents cache stampede. When multiple threads miss the same key, only one thread runs the method while others wait for the result. This synchronization works within a single JVM, not as a distributed lock between instances.
The productSearch cache has no custom configuration, so it uses the default from section 5, which is a 10-minute TTL and GenericJackson2JsonRedisSerializer. For a value that is a List<Product> containing record objects, you should declare a type-specific serializer, just like with products. This prevents the ClassCastException mentioned in section 5.
8. Testing Cache Behavior
The most reliable way is to test with a real Redis instance using Testcontainers. The @ServiceConnection annotation automatically configures the host and port for Spring Boot, so you don’t need to modify application.yml.
@SpringBootTest
@Testcontainers
class ProductServiceCacheTest {
@Container
@ServiceConnection(name = "redis")
static GenericContainer<?> redis =
new GenericContainer<>(DockerImageName.parse("redis:7-alpine"))
.withExposedPorts(6379);
@Autowired
private ProductService productService;
@Autowired
private ProductRepository productRepository;
@Autowired
private CacheManager cacheManager;
@BeforeEach
void setUp() {
cacheManager.getCache("products").clear();
productRepository.resetQueryCount();
}
@Test
void whenFindByIdCalledTwice_thenRepositoryIsQueriedOnce() {
Product first = productService.findById(1L);
Product second = productService.findById(1L);
assertThat(first).isEqualTo(second);
assertThat(productRepository.getQueryCount()).isEqualTo(1);
assertThat(cacheManager.getCache("products").get(1L, Product.class))
.isEqualTo(first);
}
@Test
void whenProductUpdated_thenCacheReturnsNewValue() {
productService.findById(2L);
productService.update(new Product(2L, "Áo bà ba lụa", new BigDecimal("450000")));
Product cached = productService.findById(2L);
assertThat(cached.name()).isEqualTo("Áo bà ba lụa");
assertThat(productRepository.getQueryCount()).isEqualTo(1);
}
}
These two tests verify the following:
- First test: called twice but the repository is queried only once. The entry is also actually in the cache with the correct
Producttype. This confirms the serializer works correctly. - Second test:
@CachePutwrites the new value, so the next read retrieves from cache without calling the repository again. @BeforeEachclears the cache and resets the counter, making tests independent of each other. The container isstatic, so it starts only once for the entire class.
9. Common Pitfalls and When Not to Use Cache
Self-invocation. Spring Cache works through a proxy. If a method in ProductService calls this.findById(id), the call goes directly to the object, bypassing the proxy, so caching is not applied. That’s why the findHotProduct example in section 7 reads directly from the database instead of calling findById. Annotations on private methods also have no effect. The fix is to extract the cached method into a separate bean.
Caching null values when null caching is disabled. With disableCachingNullValues(), a method returning null will cause an error:
java.lang.IllegalArgumentException: Cache 'products' does not allow 'null' values;
Avoid storing null via '@Cacheable(unless="#result == null")' ...
You have two options: add unless = "#result == null", or throw an exception as shown in section 4.
Changing class structure. You add or rename fields in Product, but Redis still has the old JSON format. This can result in deserialization errors or missing fields. You should rename the cache (e.g., products-v2) or clear the cache when deploying.
Caching JPA entities. Entities have lazy proxies and bidirectional relationships, which are error-prone when serialized. Cache DTOs or record objects instead.
Note: Avoid caching when data requires strict consistency, such as account balance or inventory during payment. Also avoid caching when data changes continuously, as the hit ratio will be low and you’ll only incur the extra network call to Redis. If running a single instance with small data, in-memory caches like Caffeine are often faster and simpler than Redis.
10. Conclusion
You’ve configured Redis as a cache provider for Spring Boot. You use @Cacheable for reads, @CachePut for updates, @CacheEvict for eviction. You also set TTL and JSON serializers individually for each cache, then verified the behavior with tests using a real Redis instance via Testcontainers.
Three things to remember:
- Always set TTL.
- Cache DTOs instead of entities.
- Be careful with self-invocation.
Following these three rules will help you avoid most issues when deploying cache to production.