Spring Boot + Redis Caching: Strategies and Implementation Guide
Introduction
Every production application eventually hits the same wall: the database becomes a bottleneck. Pages slow down, CPU usage spikes, and your users start noticing. Redis — an in-memory data store — is the most widely used solution, and Spring Boot makes integrating it almost effortless. But "almost" is where most tutorials stop. In this guide we go further: we cover all four major caching strategies, prevent cache stampedes, tune TTLs intelligently, and write tests that actually verify your cache behaviour.
We will build a product catalogue API as the running example — a realistic workload where caching delivers the most value.
By the end you will have:
- Full Spring Boot 3.4 project wired to a local Redis instance
- All caching annotations understood and used correctly
- Custom JSON serialization (no Java-native serialization pitfalls)
- Cache stampede prevention with
sync = true - Conditional caching and key generation strategies
- A test suite that verifies cache hits, misses, and evictions
Prerequisites
- Java 21+
- Maven 3.9+
- Redis 7+ (Docker is the easiest way to run it locally)
- MySQL 8.0+ (or H2 for tests)
Project Setup
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.4.1</version>
<relativePath/>
</parent>
<groupId>com.vimleshpandey.demo</groupId>
<artifactId>redis-cache-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- MySQL -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Test -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>com.redis</groupId>
<artifactId>testcontainers-redis</artifactId>
<version>2.2.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
application.yml
spring:
datasource:
url: jdbc:mysql://localhost:3306/cache_demo_db?createDatabaseIfNotExist=true&useSSL=false
username: root
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
show-sql: true
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 10
max-idle: 5
min-idle: 2
app:
cache:
product-ttl-seconds: 300 # 5 min for product listings
product-detail-ttl-seconds: 600 # 10 min for individual products
category-ttl-seconds: 3600 # 1 hour for categories (rarely change)
Start Redis with Docker
docker run --name redis-demo -p 6379:6379 -d redis:7-alpine
Caching Strategies — Theory Before Code
Before writing a single annotation, choose the right strategy for each data type:
| Strategy | How it works | Best for |
|---|---|---|
| Cache-aside (lazy) | App checks cache; on miss, loads from DB and populates cache | Read-heavy data with unpredictable access patterns |
| Write-through | Every write updates both DB and cache atomically | Data that must always be fresh on reads |
| Write-behind | Writes go to cache first; DB is updated asynchronously | Very write-heavy workloads where slight lag is acceptable |
| Read-through | Cache itself fetches from DB on a miss (via a loader) | When you want to hide DB logic from the application |
Spring's annotation-based caching implements cache-aside natively via @Cacheable. We will implement write-through manually via @CachePut.
Redis Cache Configuration
The most important config decision is the serializer. The default JdkSerializationRedisSerializer produces unreadable binary blobs in Redis and breaks when you change class structure. Always use JSON:
package com.vimleshpandey.demo.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@Configuration
@EnableCaching
public class CacheConfig {
@Value("${app.cache.product-ttl-seconds}")
private long productTtl;
@Value("${app.cache.product-detail-ttl-seconds}")
private long productDetailTtl;
@Value("${app.cache.category-ttl-seconds}")
private long categoryTtl;
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
// Store type info so polymorphic types deserialize correctly
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(mapper);
RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(serializer))
.disableCachingNullValues();
// Per-cache TTL overrides
Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
cacheConfigs.put("products", defaults.entryTtl(Duration.ofSeconds(productTtl)));
cacheConfigs.put("product", defaults.entryTtl(Duration.ofSeconds(productDetailTtl)));
cacheConfigs.put("categories", defaults.entryTtl(Duration.ofSeconds(categoryTtl)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaults.entryTtl(Duration.ofMinutes(10)))
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}
Domain Model
package com.vimleshpandey.demo.model;
import jakarta.persistence.*;
import lombok.*;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Entity
@Table(name = "products")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product implements Serializable {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String name;
@Column(length = 2000)
private String description;
@Column(nullable = false)
private BigDecimal price;
@Column(nullable = false)
private String category;
@Column(nullable = false)
private Integer stockQuantity;
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
@PrePersist
void prePersist() {
createdAt = LocalDateTime.now();
}
}
package com.vimleshpandey.demo.repository;
import com.vimleshpandey.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByCategory(String category);
@Query("SELECT DISTINCT p.category FROM Product p ORDER BY p.category")
List<String> findAllCategories();
}
Service Layer with Caching Annotations
package com.vimleshpandey.demo.service;
import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {
private final ProductRepository productRepository;
// Cache-aside: check cache first; load DB on miss; populate cache; return.
// sync=true prevents cache stampede: only one thread computes the value per key.
@Cacheable(value = "product", key = "#id", sync = true)
public Product getById(Long id) {
log.info("Cache MISS — loading product {} from database", id);
return productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
}
@Cacheable(value = "products", key = "#category", sync = true)
public List<Product> getByCategory(String category) {
log.info("Cache MISS — loading category '{}' from database", category);
return productRepository.findByCategory(category);
}
@Cacheable(value = "categories", key = "'all'")
public List<String> getAllCategories() {
log.info("Cache MISS — loading categories from database");
return productRepository.findAllCategories();
}
// Write-through: update DB and refresh the cache in one operation.
@CachePut(value = "product", key = "#result.id")
@CacheEvict(value = {"products", "categories"}, allEntries = true)
@Transactional
public Product create(ProductRequest request) {
Product product = Product.builder()
.name(request.getName())
.description(request.getDescription())
.price(request.getPrice())
.category(request.getCategory())
.stockQuantity(request.getStockQuantity())
.build();
return productRepository.save(product);
}
// @Caching lets you compose multiple cache operations on one method.
@Caching(
put = { @CachePut(value = "product", key = "#id") },
evict = { @CacheEvict(value = "products", allEntries = true) }
)
@Transactional
public Product update(Long id, ProductRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
product.setName(request.getName());
product.setDescription(request.getDescription());
product.setPrice(request.getPrice());
product.setCategory(request.getCategory());
product.setStockQuantity(request.getStockQuantity());
return productRepository.save(product);
}
@Caching(evict = {
@CacheEvict(value = "product", key = "#id"),
@CacheEvict(value = "products", allEntries = true),
@CacheEvict(value = "categories", allEntries = true)
})
@Transactional
public void delete(Long id) {
productRepository.deleteById(id);
}
}
Understanding Each Annotation
@Cacheable(value = "product", key = "#id", sync = true)
Checks the product cache for the key derived from #id. On a hit, the method body is skipped entirely — the cached value is returned directly. On a miss, the method executes, the return value is stored in Redis, and then returned.
sync = true solves the cache stampede (also called dogpile) problem. Without it, if 200 concurrent requests arrive for a cold key, all 200 will see a cache miss and hit the database simultaneously. With sync = true, only one thread queries the DB while the rest wait for the result to be cached.
@CachePut(value = "product", key = "#result.id")
Always executes the method AND updates the cache. Use this for writes that should immediately refresh a cached entry. #result refers to the method's return value — useful when the key is the auto-generated ID.
@CacheEvict(value = "products", allEntries = true)
Removes cache entries. allEntries = true clears the entire cache namespace — appropriate for list caches where a single product change may affect many queries. For individual entries, use key = "#id" without allEntries.
@Caching
Composes multiple cache operations on a single method. Essential when a write should both update one cache namespace and evict from another.
Conditional Caching
Cache only when it makes sense — don't cache empty results or results that aren't worth the overhead:
// Only cache if there are results; no point storing an empty list
@Cacheable(value = "products", key = "#category",
condition = "#category != null",
unless = "#result.isEmpty()")
public List<Product> getByCategory(String category) {
return productRepository.findByCategory(category);
}
// Only cache products priced above €10 (cheap items turn over fast)
@Cacheable(value = "product", key = "#id",
unless = "#result.price.compareTo(T(java.math.BigDecimal).TEN) < 0")
public Product getById(Long id) {
return productRepository.findById(id).orElseThrow();
}
condition is evaluated before the method executes. unless is evaluated after — it has access to #result.
Custom Key Generator
For complex keys that span multiple parameters, implement KeyGenerator:
package com.vimleshpandey.demo.config;
import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Collectors;
@Configuration
public class KeyGeneratorConfig {
@Bean("compositeKeyGenerator")
public KeyGenerator compositeKeyGenerator() {
return (Object target, Method method, Object... params) ->
target.getClass().getSimpleName() + ":" +
method.getName() + ":" +
Arrays.stream(params)
.map(Object::toString)
.collect(Collectors.joining("-"));
}
}
Usage: @Cacheable(value = "products", keyGenerator = "compositeKeyGenerator")
REST Controller
package com.vimleshpandey.demo.controller;
import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.net.URI;
import java.util.List;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService productService;
@GetMapping("/{id}")
public ResponseEntity<Product> getById(@PathVariable Long id) {
return ResponseEntity.ok(productService.getById(id));
}
@GetMapping("/category/{category}")
public ResponseEntity<List<Product>> getByCategory(@PathVariable String category) {
return ResponseEntity.ok(productService.getByCategory(category));
}
@GetMapping("/categories")
public ResponseEntity<List<String>> getAllCategories() {
return ResponseEntity.ok(productService.getAllCategories());
}
@PostMapping
public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) {
Product created = productService.create(request);
return ResponseEntity.created(URI.create("/api/products/" + created.getId()))
.body(created);
}
@PutMapping("/{id}")
public ResponseEntity<Product> update(@PathVariable Long id,
@Valid @RequestBody ProductRequest request) {
return ResponseEntity.ok(productService.update(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> delete(@PathVariable Long id) {
productService.delete(id);
return ResponseEntity.noContent().build();
}
}
DTO
package com.vimleshpandey.demo.dto;
import jakarta.validation.constraints.*;
import lombok.Data;
import java.math.BigDecimal;
@Data
public class ProductRequest {
@NotBlank
private String name;
private String description;
@NotNull @DecimalMin("0.01")
private BigDecimal price;
@NotBlank
private String category;
@NotNull @Min(0)
private Integer stockQuantity;
}
Cache Monitoring with Actuator
Add spring-boot-starter-actuator and expose cache metrics:
management:
endpoints:
web:
exposure:
include: health, info, metrics, caches
metrics:
cache:
instrument-all: true
Now hit GET /actuator/caches to see all active caches and GET /actuator/metrics/cache.gets for hit/miss ratios.
Testing
Testing with Testcontainers gives you real Redis behaviour — no mocks, no surprises:
package com.vimleshpandey.demo;
import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import com.redis.testcontainers.RedisContainer;
import java.math.BigDecimal;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest
@Testcontainers
class ProductCacheTest {
@Container
static RedisContainer redis = new RedisContainer(
org.testcontainers.utility.DockerImageName.parse("redis:7-alpine"));
@DynamicPropertySource
static void redisProperties(DynamicPropertyRegistry registry) {
registry.add("spring.data.redis.host", redis::getHost);
registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
}
@Autowired ProductService productService;
@Autowired CacheManager cacheManager;
@Test
void cacheHitAfterFirstLoad() {
ProductRequest req = new ProductRequest();
req.setName("Test Widget");
req.setDescription("A test product");
req.setPrice(BigDecimal.valueOf(29.99));
req.setCategory("Electronics");
req.setStockQuantity(100);
Product created = productService.create(req);
Long id = created.getId();
// First call should have populated the cache via @CachePut
var cachedValue = cacheManager.getCache("product").get(id);
assertThat(cachedValue).isNotNull();
}
@Test
void cacheEvictedOnDelete() {
ProductRequest req = new ProductRequest();
req.setName("Doomed Product");
req.setDescription("Will be deleted");
req.setPrice(BigDecimal.valueOf(9.99));
req.setCategory("Misc");
req.setStockQuantity(1);
Product created = productService.create(req);
Long id = created.getId();
// Load into cache
productService.getById(id);
assertThat(cacheManager.getCache("product").get(id)).isNotNull();
// Delete should evict from cache
productService.delete(id);
assertThat(cacheManager.getCache("product").get(id)).isNull();
}
}
Running the Application
# Start Redis
docker run --name redis-demo -p 6379:6379 -d redis:7-alpine
# Create MySQL database
mysql -u root -p -e "CREATE DATABASE cache_demo_db;"
# Run
./mvnw spring-boot:run
# Create a product (also caches it via @CachePut)
curl -X POST http://localhost:8080/api/products \
-H "Content-Type: application/json" \
-d '{"name":"Laptop","description":"Fast laptop","price":999.99,"category":"Electronics","stockQuantity":50}'
# Get the product — first call: DB hit (logged). Second call: cache hit (no log).
curl http://localhost:8080/api/products/1
curl http://localhost:8080/api/products/1
# Inspect Redis directly
docker exec redis-demo redis-cli KEYS "*"
docker exec redis-demo redis-cli GET "product::1"
Common Pitfalls and Tips
1. Never cache mutable state without TTL — Always set a TTL via entryTtl in RedisCacheConfiguration. An entry with no TTL lives forever and will return stale data after a background DB update.
2. @Cacheable on internal calls silently does nothing — Spring's caching uses a proxy around your service. If method A calls method B in the same class, the proxy is bypassed and B's @Cacheable annotation is ignored. Move the cached method to a separate service or inject the service into itself using @Lazy.
3. All cached classes must be serializable — Ensure your entities/DTOs implement Serializable (or rely entirely on the JSON serializer). With GenericJackson2JsonRedisSerializer, you need a no-arg constructor on cached classes.
4. KeySpace collisions — By default, Spring prefixes keys with the cache name: product::1. This means you can safely reuse numeric IDs across different cache namespaces. But if you use string keys that overlap (e.g., "all" in multiple caches), double-check each cache name is unique.
5. Don't cache paginated results naively — @Cacheable on a Page result serializes the entire PageImpl, including metadata. Cache the underlying List instead and handle pagination at the controller level.
6. Use allEntries = true judiciously — @CacheEvict(allEntries = true) clears every key in the named cache namespace. For a large product listing cache, this is the right call on a write. But avoid it on caches with thousands of unrelated keys — a targeted key = "#id" eviction is cheaper.
7. Test cache behaviour, not just business logic — Use CacheManager.getCache(name).get(key) in tests to assert cache presence/absence. Don't assume annotations work — verify them.
Conclusion
Redis caching transforms read-heavy Spring Boot applications. With the patterns in this guide you can:
- Keep database query counts dramatically lower under high load
- Serve category listings from memory in microseconds instead of milliseconds
- Prevent cache stampedes on popular items with
sync = true - Apply fine-grained TTLs per data type so caches expire at the right rate
- Verify cache behaviour with real Redis via Testcontainers
The next steps from here are:
- Cache warming — pre-populate Redis on startup for your most-hit keys
- Redis Cluster — switch from a single Redis node to a cluster for HA
- Caffeine L1 cache — add a local in-process cache in front of Redis for ultra-hot data to eliminate network round-trips entirely
- Cache metrics dashboard — export hit/miss counters to Grafana via Actuator's Micrometer integration