Spring Boot Caching with Redis: Performance Guide

Introduction
Caching is one of the most effective ways to improve application performance. By storing frequently accessed data in memory, you can reduce database load, decrease response times, and scale your application more efficiently.
In this tutorial, you'll learn how to implement caching in Spring Boot using Redis, the industry-standard in-memory data store. We'll build upon the Employee Management System from previous tutorials and add a robust caching layer.
What You'll Learn:
✅ Spring Cache abstraction fundamentals
✅ Redis setup and Spring Boot integration
✅ Implementing cache strategies (Cache-Aside pattern)
✅ Cache invalidation and TTL management
✅ Monitoring cache performance
✅ Solving N+1 query problems
✅ Distributed caching best practices
Prerequisites:
- Completed Database Integration tutorial
- Basic understanding of REST APIs
- Java 17+ installed
- Docker for running Redis locally
Why Redis for Caching?
New to Redis? Check out our Learning Redis: The Complete Beginner's Guide to understand Redis fundamentals before diving into Spring Boot integration.
Redis is the go-to choice for caching in production applications:
Performance
- In-memory storage with microsecond latency
- Can handle millions of operations per second
- Significantly faster than database queries
Features
- Built-in TTL (Time-To-Live) support
- Multiple data structures (strings, hashes, lists, sets)
- Pub/Sub for cache invalidation
- Persistence options for durability
Scalability
- Distributed caching across multiple nodes
- Horizontal scaling with Redis Cluster
- Master-replica replication
Ecosystem
- Excellent Spring Boot integration
- Wide community support
- Battle-tested in production
Project Setup
Step 1: Add Dependencies
Add Redis and caching dependencies to your pom.xml:
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Boot Starter Data JPA -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Spring Boot Starter Cache -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<!-- Spring Boot Starter Data Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- PostgreSQL Driver -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>Step 2: Start Redis with Docker
New to Docker? Check out our Learning Docker: Why It Matters guide to understand containerization basics.
Create a docker-compose.yml file:
version: '3.8'
services:
redis:
image: redis:7-alpine
container_name: employee-redis
ports:
- "6379:6379"
volumes:
- redis-data:/data
command: redis-server --appendonly yes
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 5
volumes:
redis-data:Start Redis:
docker-compose up -dVerify Redis is running:
docker-compose ps
redis-cli ping # Should return PONGStep 3: Configure Redis Connection
Update application.yml:
spring:
application:
name: employee-management
datasource:
url: jdbc:postgresql://localhost:5432/employeedb
username: postgres
password: postgres
jpa:
hibernate:
ddl-auto: validate
show-sql: true
properties:
hibernate:
format_sql: true
# Redis Configuration
data:
redis:
host: localhost
port: 6379
timeout: 2000ms
lettuce:
pool:
max-active: 8
max-idle: 8
min-idle: 2
# Cache Configuration
cache:
type: redis
redis:
time-to-live: 600000 # 10 minutes in milliseconds
cache-null-values: false
key-prefix: "employee::"
logging:
level:
org.hibernate.SQL: DEBUG
org.hibernate.type.descriptor.sql.BasicBinder: TRACE
org.springframework.cache: DEBUGUnderstanding Spring Cache Abstraction
Spring Cache provides a unified API for caching, regardless of the underlying cache provider. The key annotations are:
Core Annotations
@Cacheable - Caches method results
@Cacheable(value = "employees", key = "#id")
public Employee findById(Long id) {
// Executed only if cache miss
return repository.findById(id).orElseThrow();
}@CachePut - Updates cache
@CachePut(value = "employees", key = "#employee.id")
public Employee update(Employee employee) {
return repository.save(employee);
}@CacheEvict - Removes from cache
@CacheEvict(value = "employees", key = "#id")
public void delete(Long id) {
repository.deleteById(id);
}@Caching - Combines multiple cache operations
@Caching(
evict = {
@CacheEvict(value = "employees", key = "#id"),
@CacheEvict(value = "employeeList", allEntries = true)
}
)
public void delete(Long id) {
repository.deleteById(id);
}Implementing Caching Layer
Step 1: Enable Caching
Create a cache configuration class:
package com.example.employee.config;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator;
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 CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Configure ObjectMapper for Redis serialization
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.registerModule(new JavaTimeModule());
objectMapper.activateDefaultTyping(
BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(Object.class)
.build(),
ObjectMapper.DefaultTyping.NON_FINAL,
JsonTypeInfo.As.PROPERTY
);
// Create JSON serializer
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
// Default cache configuration
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer())
)
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(serializer)
)
.disableCachingNullValues();
// Custom cache configurations
Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
// Employees cache - 5 minutes TTL
cacheConfigurations.put("employees",
defaultConfig.entryTtl(Duration.ofMinutes(5)));
// Employee list cache - 2 minutes TTL
cacheConfigurations.put("employeeList",
defaultConfig.entryTtl(Duration.ofMinutes(2)));
// Department cache - 15 minutes TTL
cacheConfigurations.put("departments",
defaultConfig.entryTtl(Duration.ofMinutes(15)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigurations)
.transactionAware()
.build();
}
}Step 2: Update Domain Models
Ensure your entities are serializable:
package com.example.employee.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.time.LocalDate;
@Entity
@Table(name = "employees")
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Employee implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String firstName;
@Column(nullable = false)
private String lastName;
@Column(nullable = false, unique = true)
private String email;
@Column(nullable = false)
private String position;
@Column(nullable = false)
private Double salary;
@Column(nullable = false)
private LocalDate hireDate;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "department_id")
@JsonIgnoreProperties({"employees", "hibernateLazyInitializer", "handler"})
private Department department;
}package com.example.employee.model;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
@Entity
@Table(name = "departments")
@Data
@NoArgsConstructor
@AllArgsConstructor
@JsonIgnoreProperties(ignoreUnknown = true)
public class Department implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String name;
@Column(length = 500)
private String description;
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL)
@JsonIgnoreProperties("department")
private List<Employee> employees = new ArrayList<>();
}Step 3: Implement Cached Service Layer
package com.example.employee.service;
import com.example.employee.model.Employee;
import com.example.employee.repository.EmployeeRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.cache.annotation.Caching;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
@Service
@RequiredArgsConstructor
@Slf4j
public class EmployeeService {
private final EmployeeRepository employeeRepository;
/**
* Get employee by ID - Cached
* Cache key: employee::{id}
*/
@Cacheable(value = "employees", key = "#id")
@Transactional(readOnly = true)
public Employee getEmployeeById(Long id) {
log.info("Fetching employee from database: {}", id);
return employeeRepository.findById(id)
.orElseThrow(() -> new RuntimeException("Employee not found: " + id));
}
/**
* Get all employees - Cached
* Cache key: employee::all
*/
@Cacheable(value = "employeeList", key = "'all'")
@Transactional(readOnly = true)
public List<Employee> getAllEmployees() {
log.info("Fetching all employees from database");
return employeeRepository.findAll();
}
/**
* Get employees by department - Cached
* Cache key: employee::department::{departmentId}
*/
@Cacheable(value = "employeeList", key = "'department::' + #departmentId")
@Transactional(readOnly = true)
public List<Employee> getEmployeesByDepartment(Long departmentId) {
log.info("Fetching employees by department from database: {}", departmentId);
return employeeRepository.findByDepartmentId(departmentId);
}
/**
* Create employee - Invalidates employee list cache
*/
@CacheEvict(value = "employeeList", allEntries = true)
@Transactional
public Employee createEmployee(Employee employee) {
log.info("Creating new employee: {}", employee.getEmail());
return employeeRepository.save(employee);
}
/**
* Update employee - Updates cache
*/
@Caching(
put = @CachePut(value = "employees", key = "#employee.id"),
evict = @CacheEvict(value = "employeeList", allEntries = true)
)
@Transactional
public Employee updateEmployee(Employee employee) {
log.info("Updating employee: {}", employee.getId());
if (!employeeRepository.existsById(employee.getId())) {
throw new RuntimeException("Employee not found: " + employee.getId());
}
return employeeRepository.save(employee);
}
/**
* Delete employee - Evicts from cache
*/
@Caching(evict = {
@CacheEvict(value = "employees", key = "#id"),
@CacheEvict(value = "employeeList", allEntries = true)
})
@Transactional
public void deleteEmployee(Long id) {
log.info("Deleting employee: {}", id);
if (!employeeRepository.existsById(id)) {
throw new RuntimeException("Employee not found: " + id);
}
employeeRepository.deleteById(id);
}
/**
* Clear all employee caches
*/
@Caching(evict = {
@CacheEvict(value = "employees", allEntries = true),
@CacheEvict(value = "employeeList", allEntries = true)
})
public void clearAllCaches() {
log.info("Clearing all employee caches");
}
}Step 4: Create REST Controller
package com.example.employee.controller;
import com.example.employee.model.Employee;
import com.example.employee.service.EmployeeService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/employees")
@RequiredArgsConstructor
public class EmployeeController {
private final EmployeeService employeeService;
@GetMapping("/{id}")
public ResponseEntity<Employee> getEmployee(@PathVariable Long id) {
Employee employee = employeeService.getEmployeeById(id);
return ResponseEntity.ok(employee);
}
@GetMapping
public ResponseEntity<List<Employee>> getAllEmployees() {
List<Employee> employees = employeeService.getAllEmployees();
return ResponseEntity.ok(employees);
}
@GetMapping("/department/{departmentId}")
public ResponseEntity<List<Employee>> getEmployeesByDepartment(
@PathVariable Long departmentId) {
List<Employee> employees = employeeService.getEmployeesByDepartment(departmentId);
return ResponseEntity.ok(employees);
}
@PostMapping
public ResponseEntity<Employee> createEmployee(@RequestBody Employee employee) {
Employee created = employeeService.createEmployee(employee);
return ResponseEntity.status(HttpStatus.CREATED).body(created);
}
@PutMapping("/{id}")
public ResponseEntity<Employee> updateEmployee(
@PathVariable Long id,
@RequestBody Employee employee) {
employee.setId(id);
Employee updated = employeeService.updateEmployee(employee);
return ResponseEntity.ok(updated);
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> deleteEmployee(@PathVariable Long id) {
employeeService.deleteEmployee(id);
return ResponseEntity.noContent().build();
}
@DeleteMapping("/cache/clear")
public ResponseEntity<String> clearCache() {
employeeService.clearAllCaches();
return ResponseEntity.ok("All caches cleared successfully");
}
}Testing Cache Behavior
Test 1: Cache Hit vs Cache Miss
Start your application and test caching:
# First request - Cache MISS (hits database)
curl http://localhost:8080/api/employees/1
# Second request - Cache HIT (from Redis)
curl http://localhost:8080/api/employees/1
# Check logs - you'll see only ONE database queryExpected Log Output:
# First request
Fetching employee from database: 1
Hibernate: select ... from employees where id=?
# Second request
(No database query - served from cache)Test 2: Cache Invalidation
# Get employee (cache miss)
curl http://localhost:8080/api/employees/1
# Get again (cache hit)
curl http://localhost:8080/api/employees/1
# Update employee (cache updated)
curl -X PUT http://localhost:8080/api/employees/1 \
-H "Content-Type: application/json" \
-d '{"firstName":"John","lastName":"Updated","email":"john@example.com",...}'
# Get again (cache hit with updated data)
curl http://localhost:8080/api/employees/1Test 3: Verify Redis Keys
# Connect to Redis
redis-cli
# List all keys
KEYS *
# Expected output:
# 1) "employee::employees::1"
# 2) "employee::employeeList::all"
# Get cached value
GET "employee::employees::1"
# Check TTL
TTL "employee::employees::1"
# Clear specific cache
DEL "employee::employees::1"Cache Strategies
1. Cache-Aside (Lazy Loading)
Pattern: Application checks cache first, loads from DB on miss
@Cacheable(value = "employees", key = "#id")
public Employee getEmployeeById(Long id) {
// Spring handles:
// 1. Check cache
// 2. If miss, execute method
// 3. Store result in cache
return repository.findById(id).orElseThrow();
}Pros:
- Only caches requested data
- Cache failures don't affect application
- Simple to implement
Cons:
- Initial request is slow (cache miss)
- Cache stampede risk
2. Write-Through
Pattern: Update cache whenever database is updated
@CachePut(value = "employees", key = "#employee.id")
public Employee updateEmployee(Employee employee) {
Employee updated = repository.save(employee);
// Spring automatically updates cache
return updated;
}Pros:
- Cache is always consistent
- No cache misses on reads
Cons:
- Write latency increases
- May cache unused data
3. Write-Behind (Write-Back)
Pattern: Update cache immediately, persist to DB asynchronously
@Async
@CachePut(value = "employees", key = "#employee.id")
public CompletableFuture<Employee> updateEmployee(Employee employee) {
// Update cache immediately
// Persist to DB asynchronously
return CompletableFuture.completedFuture(repository.save(employee));
}Pros:
- Very fast writes
- Reduces database load
Cons:
- Risk of data loss
- Complex implementation
- Requires careful error handling
Solving N+1 Query Problem
The N+1 problem occurs when you fetch a collection and then access related entities:
Problem Example
// Without optimization
@GetMapping("/employees-with-departments")
public List<Employee> getEmployeesWithDepartments() {
List<Employee> employees = employeeRepository.findAll(); // 1 query
// Accessing department triggers N additional queries
employees.forEach(e -> e.getDepartment().getName()); // N queries
return employees;
}
// Result: 1 + N queries (terrible performance!)Solution 1: Entity Graph with Caching
@EntityGraph(attributePaths = {"department"})
@Query("SELECT e FROM Employee e")
List<Employee> findAllWithDepartment();
@Cacheable(value = "employeesWithDepartment", key = "'all'")
@Transactional(readOnly = true)
public List<Employee> getAllEmployeesWithDepartment() {
return employeeRepository.findAllWithDepartment(); // Single JOIN query
}Solution 2: DTO Projection with Caching
public record EmployeeDTO(
Long id,
String firstName,
String lastName,
String email,
String departmentName
) {}
@Query("""
SELECT new com.example.employee.dto.EmployeeDTO(
e.id, e.firstName, e.lastName, e.email, d.name
)
FROM Employee e
LEFT JOIN e.department d
""")
List<EmployeeDTO> findAllEmployeeDTOs();
@Cacheable(value = "employeeDTOs", key = "'all'")
public List<EmployeeDTO> getAllEmployeeDTOs() {
return employeeRepository.findAllEmployeeDTOs();
}Cache Monitoring and Metrics
Add Actuator for Cache Metrics
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>Update application.yml:
management:
endpoints:
web:
exposure:
include: health,metrics,caches
endpoint:
health:
show-details: always
metrics:
enable:
cache: trueCreate Cache Statistics Endpoint
package com.example.employee.controller;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.data.redis.cache.RedisCache;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
@RestController
@RequestMapping("/api/cache")
@RequiredArgsConstructor
public class CacheController {
private final CacheManager cacheManager;
private final RedisTemplate<String, Object> redisTemplate;
@GetMapping("/stats")
public Map<String, Object> getCacheStats() {
Map<String, Object> stats = new HashMap<>();
// Get cache names
stats.put("cacheNames", cacheManager.getCacheNames());
// Get cache sizes
Map<String, Long> cacheSizes = new HashMap<>();
cacheManager.getCacheNames().forEach(cacheName -> {
var cache = cacheManager.getCache(cacheName);
if (cache instanceof RedisCache redisCache) {
String pattern = cacheName + "::*";
Long size = redisTemplate.keys(pattern).size();
cacheSizes.put(cacheName, size);
}
});
stats.put("cacheSizes", cacheSizes);
// Redis info
Map<String, Object> redisInfo = new HashMap<>();
redisInfo.put("connected", Objects.requireNonNull(
redisTemplate.getConnectionFactory()).getConnection().ping());
stats.put("redis", redisInfo);
return stats;
}
@GetMapping("/keys")
public Map<String, Object> getCacheKeys() {
Map<String, Object> result = new HashMap<>();
cacheManager.getCacheNames().forEach(cacheName -> {
String pattern = cacheName + "::*";
var keys = redisTemplate.keys(pattern);
result.put(cacheName, keys);
});
return result;
}
}View Cache Metrics
# Cache statistics
curl http://localhost:8080/api/cache/stats
# Cache keys
curl http://localhost:8080/api/cache/keys
# Actuator metrics
curl http://localhost:8080/actuator/metrics/cache.gets
curl http://localhost:8080/actuator/metrics/cache.putsAdvanced Caching Patterns
1. Conditional Caching
Cache only specific conditions:
@Cacheable(
value = "employees",
key = "#id",
condition = "#id > 0", // Only cache positive IDs
unless = "#result == null" // Don't cache null results
)
public Employee getEmployeeById(Long id) {
return repository.findById(id).orElse(null);
}2. Custom Cache Key Generator
@Configuration
public class CacheConfig {
@Bean
public KeyGenerator customKeyGenerator() {
return (target, method, params) -> {
StringBuilder key = new StringBuilder();
key.append(target.getClass().getSimpleName());
key.append("_").append(method.getName());
for (Object param : params) {
key.append("_").append(param.toString());
}
return key.toString();
};
}
}
// Usage
@Cacheable(value = "employees", keyGenerator = "customKeyGenerator")
public Employee getEmployeeById(Long id) {
return repository.findById(id).orElseThrow();
}3. Cache Warming
Pre-load cache on startup:
package com.example.employee.config;
import com.example.employee.service.EmployeeService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.stereotype.Component;
@Component
@RequiredArgsConstructor
@Slf4j
public class CacheWarmer {
private final EmployeeService employeeService;
@EventListener(ApplicationReadyEvent.class)
public void warmUpCache() {
log.info("Warming up cache...");
try {
// Pre-load frequently accessed data
employeeService.getAllEmployees();
log.info("Cache warming completed successfully");
} catch (Exception e) {
log.error("Cache warming failed", e);
}
}
}4. Time-Based Cache Refresh
Auto-refresh cache before TTL expires:
@Scheduled(fixedRate = 480000) // 8 minutes (before 10-minute TTL)
@CacheEvict(value = "employeeList", key = "'all'")
public void refreshEmployeeCache() {
log.info("Refreshing employee cache");
getAllEmployees(); // Will reload and cache
}Production Best Practices
1. Connection Pool Configuration
spring:
data:
redis:
lettuce:
pool:
max-active: 20 # Maximum connections
max-idle: 10 # Maximum idle connections
min-idle: 5 # Minimum idle connections
max-wait: 2000ms # Maximum wait time for connection
shutdown-timeout: 200ms2. Error Handling
@Slf4j
@Component
public class CacheErrorHandler implements org.springframework.cache.interceptor.CacheErrorHandler {
@Override
public void handleCacheGetError(RuntimeException exception, Cache cache, Object key) {
log.error("Cache GET error - cache: {}, key: {}", cache.getName(), key, exception);
// Don't throw - allow fallback to database
}
@Override
public void handleCachePutError(RuntimeException exception, Cache cache, Object key, Object value) {
log.error("Cache PUT error - cache: {}, key: {}", cache.getName(), key, exception);
// Don't throw - write to database succeeded
}
@Override
public void handleCacheEvictError(RuntimeException exception, Cache cache, Object key) {
log.error("Cache EVICT error - cache: {}, key: {}", cache.getName(), key, exception);
}
@Override
public void handleCacheClearError(RuntimeException exception, Cache cache) {
log.error("Cache CLEAR error - cache: {}", cache.getName(), exception);
}
}Register error handler:
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new CacheErrorHandler();
}
}3. Cache Stampede Prevention
@Cacheable(value = "employees", key = "#id", sync = true)
public Employee getEmployeeById(Long id) {
// sync=true ensures only one thread loads from DB
// Other threads wait for the result
return repository.findById(id).orElseThrow();
}4. Multi-Level Caching
Combine local and distributed caching:
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
// Caffeine (local) + Redis (distributed)
return new CompositeCacheManager(
caffeineCacheManager(), // L1: Local cache
redisCacheManager(connectionFactory) // L2: Distributed cache
);
}Common Pitfalls and Solutions
❌ Problem 1: Caching Mutable Objects
// BAD: Returns cached mutable object
@Cacheable("employees")
public Employee getEmployee(Long id) {
return repository.findById(id).orElseThrow();
}
// Caller modifies cached object
Employee emp = service.getEmployee(1L);
emp.setSalary(100000.0); // Cache is now corrupted!✅ Solution: Return defensive copies
@Cacheable("employees")
public Employee getEmployee(Long id) {
Employee emp = repository.findById(id).orElseThrow();
return Employee.builder()
.id(emp.getId())
.firstName(emp.getFirstName())
// ... copy all fields
.build();
}❌ Problem 2: Not Serializing Lazy-Loaded Entities
// BAD: Department is lazy-loaded
@Entity
public class Employee {
@ManyToOne(fetch = FetchType.LAZY)
private Department department;
}
// Cache stores proxy, fails on deserialization✅ Solution: Use DTOs or eager fetch for cached entities
// Option 1: DTO
public record EmployeeDTO(Long id, String name, String departmentName) {}
// Option 2: Eager fetch for cached queries
@EntityGraph(attributePaths = {"department"})
Employee findByIdForCache(Long id);❌ Problem 3: Circular References
// BAD: Causes infinite recursion during serialization
@Entity
public class Employee {
@ManyToOne
private Department department;
}
@Entity
public class Department {
@OneToMany(mappedBy = "department")
private List<Employee> employees; // Circular reference!
}✅ Solution: Use @JsonIgnoreProperties
@Entity
public class Employee {
@ManyToOne
@JsonIgnoreProperties("employees")
private Department department;
}Performance Testing
Create Load Test Script
#!/bin/bash
# load-test.sh
echo "Starting load test..."
# Test cache miss (first request)
echo "Test 1: Cache Miss"
time curl -s http://localhost:8080/api/employees/1 > /dev/null
# Test cache hit (subsequent requests)
echo "Test 2: Cache Hit"
for i in {1..100}; do
curl -s http://localhost:8080/api/employees/1 > /dev/null
done
# Measure response time
echo "Test 3: Response Time Comparison"
echo "Without cache (cleared):"
curl -X DELETE http://localhost:8080/api/cache/clear
time curl -s http://localhost:8080/api/employees/1 > /dev/null
echo "With cache:"
time curl -s http://localhost:8080/api/employees/1 > /dev/nullExpected Results:
- Without cache: ~50-100ms (database query)
- With cache: ~5-10ms (Redis lookup)
- 10-20x performance improvement!
Summary and Key Takeaways
Congratulations! You've successfully implemented a production-ready caching layer in Spring Boot with Redis.
What We Covered:
✅ Spring Cache abstraction (@Cacheable, @CachePut, @CacheEvict)
✅ Redis setup and Spring Boot integration
✅ Cache configuration with custom TTL
✅ Cache-Aside pattern implementation
✅ N+1 query problem solutions
✅ Cache monitoring and metrics
✅ Advanced patterns (cache warming, conditional caching)
✅ Production best practices
✅ Common pitfalls and solutions
Performance Gains:
- 10-20x faster response times
- Reduced database load
- Better scalability
- Improved user experience
Next Steps:
- Practice: Add caching to your existing projects
- Experiment: Try different TTL values and cache strategies
- Monitor: Set up Grafana dashboards for cache metrics
- Optimize: Profile your application and identify cache opportunities
- Learn More: Explore Redis advanced features (Pub/Sub, Streams, Modules)
Frequently Asked Questions
Q: When should I use caching?
A: Cache data that is:
- Frequently read, rarely written
- Expensive to compute or query
- Same across multiple users (or user-specific but accessed frequently)
Q: How do I choose TTL values?
A: Consider:
- How often data changes (lower TTL for dynamic data)
- Staleness tolerance (can users see slightly old data?)
- Memory constraints (longer TTL = more memory)
- Start with 5-10 minutes and adjust based on monitoring
Q: Should I cache everything?
A: No! Don't cache:
- Rapidly changing data
- User-specific sensitive data (unless necessary)
- Large objects (> 1MB)
- Data with complex invalidation logic
Q: What if Redis goes down?
A: With proper error handling (CacheErrorHandler), your application will:
- Log cache errors
- Fall back to database
- Continue functioning (slower, but available)
Q: How do I handle cache consistency in distributed systems?
A: Use:
- Redis Pub/Sub for cache invalidation messages
- Short TTL values
- Event-driven invalidation
- Consider eventual consistency
Q: Can I use multiple Redis instances?
A: Yes! Use Redis Cluster or Sentinel:
spring:
data:
redis:
cluster:
nodes:
- redis-1:6379
- redis-2:6379
- redis-3:6379Additional Resources
Documentation:
Related Tutorials:
- Learning Redis: The Complete Beginner's Guide - Master Redis fundamentals
- Database Integration with JPA
- Performance Optimization Guide (Coming Soon)
- Monitoring with Actuator (Coming Soon)
Tools:
- RedisInsight - GUI for Redis
- Apache JMeter - Load testing
- Spring Boot Actuator - Metrics
Happy caching! 🚀 If you found this tutorial helpful, check out the complete Spring Boot Learning Roadmap.
📬 Subscribe to Newsletter
Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.
We respect your privacy. Unsubscribe at any time.
💬 Comments
Sign in to leave a comment
We'll never post without your permission.