Back to blog

Spring Boot Caching with Redis: Performance Guide

javaspring-bootrediscachingperformancebackend
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:


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 -d

Verify Redis is running:

docker-compose ps
redis-cli ping  # Should return PONG

Step 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: DEBUG

Understanding 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 query

Expected 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/1

Test 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: true

Create 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.puts

Advanced 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: 200ms

2. 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/null

Expected 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:

  1. Practice: Add caching to your existing projects
  2. Experiment: Try different TTL values and cache strategies
  3. Monitor: Set up Grafana dashboards for cache metrics
  4. Optimize: Profile your application and identify cache opportunities
  5. 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:

  1. Log cache errors
  2. Fall back to database
  3. 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:6379

Additional Resources

Documentation:

Related Tutorials:

Tools:


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.