In high-throughput distributed applications, direct database access is almost always your primary performance bottleneck. While an optimized PostgreSQL or MySQL query takes between 15ms and 80ms (accounting for index traversal, query planning, connection pooling, and disk I/O), an in-memory Redis lookup finishes in under 1 millisecond.
Spring Boot provides a powerful declarative caching abstraction, but running it in production requires far more than slapping @EnableCaching on your main class. In this production-grade tutorial using Java 21, Spring Boot 3.x, and Spring Data Redis, you will build a robust caching architecture: custom JSON serialization with Jackson (avoiding unreadable JDK binary blobs), custom per-cache TTL expiration, Docker Compose orchestration, and bulletproof defenses against Cache Avalanche, Cache Penetration, and Cache Stampede.
Building on top of a relational database? Check out our previous guide on Spring Boot PostgreSQL CRUD API with JPA & Hibernate to establish your database and entity layer.
TL;DR: The 3-Minute Production Redis Cache Snippet
Here is the essential production pattern: Add spring-boot-starter-data-redis, configure JSON serialization in RedisCacheManager, and annotate service methods with @Cacheable:
@Service
public class ProductService {
private final ProductRepository productRepository;
public ProductService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
// 1. Read through cache (Cache-Aside)
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductResponse getProductById(Long id) {
return productRepository.findById(id)
.map(ProductResponse::fromEntity)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
}
// 2. Update database and refresh cache entry
@CachePut(value = "products", key = "#result.id()")
public ProductResponse updateProduct(Long id, ProductUpdateRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
product.update(request.name(), request.price());
return ProductResponse.fromEntity(productRepository.save(product));
}
// 3. Invalidate cache on deletion
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
productRepository.deleteById(id);
}
}
- 1. Why Redis Caching Matters (Latency Breakdown)
- 2. How Spring Cache Abstraction & Cache-Aside Work
- 3. Local Redis with Docker Compose
- 4. Project Dependencies (build.gradle & pom.xml)
- 5. Connection & Lettuce Pool Configuration
- 6. Custom RedisConfig (JSON Serialization & Per-Cache TTL)
- 7. Implementing @Cacheable, @CachePut & @CacheEvict
- 8. REST Controller Endpoints
- 9. Production Pitfalls: Avalanche, Penetration & Stampede
- 10. Testing with Redis CLI & Performance Benchmarks
- 11. Frequently Asked Questions & Interview Tips
1. Why Redis Caching Matters (Latency Breakdown)
When an API handles thousands of simultaneous requests per second, recurring read queries (such as product details, user profiles, feature flags, or catalog listings) needlessly saturate your relational database. Database CPU spikes, thread pools deplete, and connection limits trigger cascading timeouts.
Redis operates strictly in RAM using single-threaded, non-blocking asynchronous event loops. The speed difference is dramatic:
| Metric | PostgreSQL (Indexed Read) | Redis In-Memory Cache | Speed Gain |
|---|---|---|---|
| Average Latency | 12ms – 65ms | 0.3ms – 1.2ms | ~25x – 60x faster |
| Throughput (Single Node) | ~3,000 – 7,000 QPS | 100,000+ QPS | ~15x higher |
| I/O Bottleneck | Disk / Buffer Cache / Locks | RAM Memory Bus only | Zero Disk Wait |
2. How Spring Cache Abstraction & Cache-Aside Work
Spring Boot uses the Cache-Aside (Lazy Loading) pattern implemented via Spring AOP proxies:
- Request arrives: The caller invokes a service method annotated with
@Cacheable. - Proxy interception: Spring's cache aspect intercepts the invocation and computes the cache key (e.g.
products::42). - Cache Lookup: If the key is found in Redis (Cache Hit), Spring bypasses the method body entirely and returns the cached value.
- Database Read: If the key is not in Redis (Cache Miss), the target service method executes, queries PostgreSQL, and stores the resulting object in Redis before returning it to the caller.
3. Local Redis with Docker Compose
To spin up Redis locally with a persistent volume and a web-based GUI management tool (Redis Commander), create a docker-compose.yml file:
version: '3.8'
services:
redis:
image: redis:7.2-alpine
container_name: digitaldrift-redis
restart: always
ports:
- "6379:6379"
command: redis-server --save 60 1 --loglevel warning --requirepass "secret123"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "secret123", "ping"]
interval: 5s
timeout: 3s
retries: 5
redis-commander:
image: rediscommander/redis-commander:latest
container_name: digitaldrift-redis-commander
environment:
- REDIS_HOSTS=local:redis:6379:0:secret123
ports:
- "8081:8081"
depends_on:
- redis
volumes:
redis_data:
Start Redis by running:
docker compose up -d
You can access Redis Commander at http://localhost:8081 to inspect keys, values, and memory consumption in real time.
4. Project Dependencies (build.gradle & pom.xml)
Add spring-boot-starter-data-redis and commons-pool2 (which enables high-performance connection pooling for the default Lettuce driver).
Gradle (build.gradle)
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.apache.commons:commons-pool2'
// JSON serialization with Java 8/21 Date/Time support
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
runtimeOnly 'org.postgresql:postgresql'
testImplementation 'org.springframework.boot:spring-boot-starter-test'
}
Maven (pom.xml)
<dependencies>
<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.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
</dependency>
</dependencies>
5. Connection & Lettuce Pool Configuration
Configure Redis connection parameters and Lettuce pool sizing in src/main/resources/application.yml:
spring:
cache:
type: redis
data:
redis:
host: localhost
port: 6379
password: secret123
timeout: 2000ms
lettuce:
pool:
max-active: 16
max-idle: 8
min-idle: 4
max-wait: 1000ms
# Custom application cache settings
app:
cache:
default-ttl: 600s
product-ttl: 300s
user-ttl: 3600s
6. Custom RedisConfig (JSON Serialization & Per-Cache TTL)
By default, Spring Boot serializes objects into Redis using Java's built-in JdkSerializationRedisSerializer. This produces unreadable hexadecimal binaries (\xac\xed\x00\x05...), causes InvalidClassException whenever your DTO schema changes, and prevents other microservices from parsing the cached data.
The production solution is a custom RedisCacheManager configured with GenericJackson2JsonRedisSerializer:
package com.digitaldrift.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.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 RedisConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Configure Jackson ObjectMapper for clean polymorphic JSON handling
ObjectMapper mapper = new ObjectMapper();
mapper.registerModule(new JavaTimeModule());
mapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper);
// Default Cache Configuration: 10 minutes TTL, no null caching
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer));
// Tailored per-cache TTL configurations
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
cacheConfigurations.put("products", defaultConfig.entryTtl(Duration.ofMinutes(5)));
cacheConfigurations.put("categories", defaultConfig.entryTtl(Duration.ofHours(24)));
cacheConfigurations.put("user_sessions", defaultConfig.entryTtl(Duration.ofMinutes(30)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.build();
}
}
7. Implementing @Cacheable, @CachePut & @CacheEvict
Now let's examine the three core annotations you will use in your service layer:
1. @Cacheable — Read-through caching
Checks the cache first. If found, returns it immediately without querying the database:
// Caches the return value under key: "products::101"
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductResponse getProductById(Long id) {
log.info("Fetching product #{} directly from PostgreSQL...", id);
return productRepository.findById(id)
.map(ProductResponse::fromEntity)
.orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
}
2. @CachePut — Updating cache without bypassing execution
Always runs the method (saving the update to PostgreSQL) and updates Redis with the new result:
@CachePut(value = "products", key = "#result.id()")
public ProductResponse updateProduct(Long id, ProductUpdateRequest request) {
Product product = productRepository.findById(id)
.orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
product.setName(request.name());
product.setPrice(request.price());
Product saved = productRepository.save(product);
log.info("Updated product #{} in DB and refreshed Redis cache", saved.getId());
return ProductResponse.fromEntity(saved);
}
3. @CacheEvict — Invalidation upon deletion
Removes the stale key from Redis when an entity is deleted or updated:
@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
if (!productRepository.existsById(id)) {
throw new ResourceNotFoundException("Cannot delete: product #" + id + " does not exist");
}
productRepository.deleteById(id);
log.info("Deleted product #{} from DB and evicted from Redis", id);
}
// Bulk eviction: Clears the entire 'products' cache namespace
@CacheEvict(value = "products", allEntries = true)
public void clearAllProductCache() {
log.info("Cleared entire products cache partition");
}
8. REST Controller Endpoints
Expose standard REST endpoints in ProductController:
package com.digitaldrift.controller;
import com.digitaldrift.dto.ProductCreateRequest;
import com.digitaldrift.dto.ProductResponse;
import com.digitaldrift.dto.ProductUpdateRequest;
import com.digitaldrift.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/v1/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping("/{id}")
public ResponseEntity<ProductResponse> getProduct(@PathVariable Long id) {
return ResponseEntity.ok(productService.getProductById(id));
}
@PostMapping
public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(request));
}
@PutMapping("/{id}")
public ResponseEntity<ProductResponse> updateProduct(
@PathVariable Long id,
@Valid @RequestBody ProductUpdateRequest request
) {
return ResponseEntity.ok(productService.updateProduct(id, request));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteProduct(@PathVariable Long id) {
productService.deleteProduct(id);
return ResponseEntity.noContent().build();
}
}
9. Production Pitfalls: Avalanche, Penetration & Stampede
When scaling Redis in production environments, three classic caching anomalies can take down your backend:
1. Cache Avalanche (Mass Expiration)
The Problem: If you set a fixed 1-hour TTL on 100,000 product records during a midnight sync, all 100,000 keys expire at exactly 1:00 AM. 100% of user traffic instantly falls through to PostgreSQL, crashing the database.
The Fix: Introduce TTL Jitter (adding a random offset of 10%–20% to each key):
// Example: Base TTL of 60 minutes + random 0 to 12 minutes jitter
Duration ttl = Duration.ofMinutes(60).plusSeconds(ThreadLocalRandom.current().nextLong(720));
2. Cache Penetration (Querying Non-Existent Keys)
The Problem: An attacker sends millions of requests for non-existent IDs (e.g. id = -99999 or random UUIDs). Because the data is never in Redis or the DB, the cache never stores it, and every single request hits PostgreSQL.
The Fix: Cache empty/null results with a very short TTL (e.g., 60 seconds), or use a Bloom Filter in front of your service.
3. Cache Stampede / Dog-piling
The Problem: When an extremely popular hot key (like the homepage banner or flash sale inventory) expires, thousands of concurrent threads experience a cache miss simultaneously and all query PostgreSQL at the exact same millisecond.
The Fix: Enable Spring's synchronized cache lock:
@Cacheable(value = "products", key = "#id", sync = true)
public ProductResponse getHotProduct(Long id) {
// Only ONE thread executes this database lookup; all other threads wait and receive the cached result
return productRepository.findById(id).map(ProductResponse::fromEntity).orElseThrow();
}
10. Testing with Redis CLI & Performance Benchmarks
Verify your keys and inspection payloads using the Redis command line:
# Connect to Redis container
docker exec -it digitaldrift-redis redis-cli -a secret123
# List active keys
127.0.0.1:6379> KEYS "products::*"
1) "products::1"
2) "products::2"
# Inspect remaining TTL in seconds
127.0.0.1:6379> TTL "products::1"
(integer) 284
# Inspect the clean JSON payload stored by Jackson
127.0.0.1:6379> GET "products::1"
"{\"@class\":\"com.digitaldrift.dto.ProductResponse\",\"id\":1,\"name\":\"Mechanical Keyboard\",\"price\":129.99}"
Latency Benchmark (cURL)
Measure the response time before and after caching:
# 1st Request (Cache Miss — hits PostgreSQL)
curl -o /dev/null -s -w 'Total Time: %{time_total}s\n' http://localhost:8080/api/v1/products/1
# Total Time: 0.048210s (48ms)
# 2nd Request (Cache Hit — served by Redis)
curl -o /dev/null -s -w 'Total Time: %{time_total}s\n' http://localhost:8080/api/v1/products/1
# Total Time: 0.001140s (1.1ms)
11. Frequently Asked Questions & Interview Tips
Q: What is the difference between Redis and Memcached?
Memcached is a pure multi-threaded in-memory key-value store. Redis is a rich in-memory data structures server supporting strings, hashes, lists, sets, sorted sets, streams, Pub/Sub, persistence (RDB/AOF), and cluster clustering. For modern Spring Boot applications, Redis is the industry standard.
Q: How do you handle Redis connection failures gracefully?
By default, a failed Redis connection throws a RedisConnectionFailureException. In production, configure a custom CacheErrorHandler:
public class CustomCacheErrorHandler implements CacheErrorHandler {
private static final Logger log = LoggerFactory.getLogger(CustomCacheErrorHandler.class);
@Override
public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
log.warn("Redis unavailable for GET on key {}. Falling back to DB.", key, ex);
}
@Override
public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
log.warn("Redis unavailable for PUT on key {}. Writing to DB only.", key, ex);
}
@Override
public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
log.warn("Redis unavailable for EVICT on key {}.", key, ex);
}
@Override
public void handleCacheClearError(RuntimeException ex, Cache cache) {
log.warn("Redis unavailable for CLEAR on cache {}.", cache.getName(), ex);
}
}
Q: Can I cache paginated queries with @Cacheable?
Yes! Use SpEL (Spring Expression Language) to incorporate page number and page size into the key:
@Cacheable(value = "product_pages", key = "'page_' + #pageable.pageNumber + '_size_' + #pageable.pageSize")
public Page<ProductResponse> getProducts(Pageable pageable) { ... }
Conclusion & Next Steps
Implementing Redis caching in Spring Boot 3 transforms your backend from a fragile service vulnerable to database bottlenecks into a lightning-fast, production-ready distributed system.
To continue expanding your Spring Boot architecture:
- Pair Redis with robust error handling from Spring Boot Global Exception Handling.
- Protect your endpoints with Spring Boot JWT Authentication.
- Optimize your underlying tables using Spring Boot Pagination and Sorting.