DEV Community

Dev Cookies
Dev Cookies

Posted on

Spring Boot Redis Multi-Cache: A Complete Guide

Spring Boot Redis Multi-Cache: A Complete Guide

πŸš€ Introduction

Caching is essential for high-performance applications, but enterprise-scale systems require more than simple @Cacheable annotations. This guide demonstrates how to implement multiple cache regions, fine-grained invalidation strategies, and TTL-based cache expiration in Spring Boot with Redis.

What You'll Learn

  • Configure multiple named caches in Spring Boot
  • Master @Cacheable, @CachePut, and @CacheEvict annotations
  • Implement clean, scalable, domain-driven caching
  • Connect Spring Boot with Redis for distributed caching
  • Configure TTL (Time-to-Live) for automatic cache expiration

πŸ“š Project Overview

We'll build a REST API featuring:

  • User and UserProfile entities
  • Separate cache regions: users and profiles
  • Smart cache updates and cross-cache invalidation
  • Configurable TTL per cache region

βš™οΈ Technology Stack

  • Spring Boot 3.x
  • Redis with Lettuce driver
  • Spring Cache Abstraction
  • Spring Data JPA with H2 database
  • Lombok for boilerplate reduction
  • Docker for Redis deployment

🧱 Project Structure

spring-redis-multi-cache/
β”œβ”€β”€ src/main/java/com/example/demo/
β”‚   β”œβ”€β”€ controller/
β”‚   β”‚   └── UserController.java
β”‚   β”œβ”€β”€ domain/
β”‚   β”‚   β”œβ”€β”€ User.java
β”‚   β”‚   └── UserProfile.java
β”‚   β”œβ”€β”€ service/
β”‚   β”‚   β”œβ”€β”€ UserService.java
β”‚   β”‚   └── UserProfileService.java
β”‚   β”œβ”€β”€ repository/
β”‚   β”‚   β”œβ”€β”€ UserRepository.java
β”‚   β”‚   └── UserProfileRepository.java
β”‚   β”œβ”€β”€ config/
β”‚   β”‚   └── RedisConfig.java
β”‚   └── SpringRedisMultiCacheApp.java
β”œβ”€β”€ src/main/resources/
β”‚   └── application.properties
└── docker-compose.yml
Enter fullscreen mode Exit fullscreen mode

1️⃣ Domain Layer

User Entity

package com.example.demo.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "users")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class User {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false, unique = true)
    private String email;
}
Enter fullscreen mode Exit fullscreen mode

UserProfile Entity

package com.example.demo.domain;

import jakarta.persistence.*;
import lombok.*;

@Entity
@Table(name = "user_profiles")
@Getter @Setter
@NoArgsConstructor @AllArgsConstructor @Builder
public class UserProfile {
    @Id
    private Long id;

    private String address;
    private String phone;
    private String bio;

    // This ID corresponds to User ID (one-to-one relationship)
}
Enter fullscreen mode Exit fullscreen mode

2️⃣ Repository Layer

UserRepository

package com.example.demo.repository;

import com.example.demo.domain.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByEmail(String email);
}
Enter fullscreen mode Exit fullscreen mode

UserProfileRepository

package com.example.demo.repository;

import com.example.demo.domain.UserProfile;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserProfileRepository extends JpaRepository<UserProfile, Long> {
}
Enter fullscreen mode Exit fullscreen mode

3️⃣ Service Layer - Multi-Cache Implementation

UserService

package com.example.demo.service;

import com.example.demo.domain.User;
import com.example.demo.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserService {

    private final UserRepository userRepository;

    @Cacheable(value = "users", key = "#id")
    public Optional<User> getUserById(Long id) {
        log.info("Fetching user with id: {} from database", id);
        simulateLatency();
        return userRepository.findById(id);
    }

    @Cacheable(value = "users", key = "#email")
    public Optional<User> getUserByEmail(String email) {
        log.info("Fetching user with email: {} from database", email);
        simulateLatency();
        return userRepository.findByEmail(email);
    }

    @CachePut(value = "users", key = "#user.id")
    public User saveUser(User user) {
        log.info("Saving user: {}", user.getName());
        return userRepository.save(user);
    }

    @CacheEvict(value = "users", key = "#id")
    public void deleteUser(Long id) {
        log.info("Deleting user with id: {}", id);
        userRepository.deleteById(id);
    }

    @CacheEvict(value = "users", allEntries = true)
    public void clearUserCache() {
        log.info("Clearing all user cache entries");
    }

    private void simulateLatency() {
        try {
            Thread.sleep(2000); // Simulate database latency
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

UserProfileService

package com.example.demo.service;

import com.example.demo.domain.UserProfile;
import com.example.demo.repository.UserProfileRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
@Slf4j
public class UserProfileService {

    private final UserProfileRepository profileRepository;

    @Cacheable(value = "profiles", key = "#id")
    public Optional<UserProfile> getProfileById(Long id) {
        log.info("Fetching profile with id: {} from database", id);
        simulateLatency();
        return profileRepository.findById(id);
    }

    /**
     * Updates profile and invalidates related user cache
     * This ensures consistency when profile changes might affect user data
     */
    @Caching(
        put = {
            @CachePut(value = "profiles", key = "#profile.id")
        },
        evict = {
            @CacheEvict(value = "users", key = "#profile.id")
        }
    )
    public UserProfile updateProfile(UserProfile profile) {
        log.info("Updating profile for user id: {}", profile.getId());
        return profileRepository.save(profile);
    }

    /**
     * Deletes profile and clears both profile and user caches
     */
    @Caching(evict = {
        @CacheEvict(value = "profiles", key = "#id"),
        @CacheEvict(value = "users", key = "#id")
    })
    public void deleteProfile(Long id) {
        log.info("Deleting profile with id: {}", id);
        profileRepository.deleteById(id);
    }

    @CacheEvict(value = "profiles", allEntries = true)
    public void clearProfileCache() {
        log.info("Clearing all profile cache entries");
    }

    private void simulateLatency() {
        try {
            Thread.sleep(2000); // Simulate database latency
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

4️⃣ REST Controller

package com.example.demo.controller;

import com.example.demo.domain.User;
import com.example.demo.domain.UserProfile;
import com.example.demo.service.UserService;
import com.example.demo.service.UserProfileService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/v1")
@RequiredArgsConstructor
public class UserController {

    private final UserService userService;
    private final UserProfileService profileService;

    // User endpoints
    @GetMapping("/users/{id}")
    public ResponseEntity<User> getUser(@PathVariable Long id) {
        return userService.getUserById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @GetMapping("/users/email/{email}")
    public ResponseEntity<User> getUserByEmail(@PathVariable String email) {
        return userService.getUserByEmail(email)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PostMapping("/users")
    public ResponseEntity<User> createUser(@RequestBody User user) {
        User savedUser = userService.saveUser(user);
        return ResponseEntity.ok(savedUser);
    }

    @PutMapping("/users/{id}")
    public ResponseEntity<User> updateUser(@PathVariable Long id, @RequestBody User user) {
        user.setId(id);
        User updatedUser = userService.saveUser(user);
        return ResponseEntity.ok(updatedUser);
    }

    @DeleteMapping("/users/{id}")
    public ResponseEntity<Void> deleteUser(@PathVariable Long id) {
        userService.deleteUser(id);
        return ResponseEntity.noContent().build();
    }

    // Profile endpoints
    @GetMapping("/profiles/{id}")
    public ResponseEntity<UserProfile> getProfile(@PathVariable Long id) {
        return profileService.getProfileById(id)
                .map(ResponseEntity::ok)
                .orElse(ResponseEntity.notFound().build());
    }

    @PutMapping("/profiles/{id}")
    public ResponseEntity<UserProfile> updateProfile(@PathVariable Long id, @RequestBody UserProfile profile) {
        profile.setId(id);
        UserProfile updatedProfile = profileService.updateProfile(profile);
        return ResponseEntity.ok(updatedProfile);
    }

    @DeleteMapping("/profiles/{id}")
    public ResponseEntity<Void> deleteProfile(@PathVariable Long id) {
        profileService.deleteProfile(id);
        return ResponseEntity.noContent().build();
    }

    // Cache management endpoints
    @PostMapping("/cache/users/clear")
    public ResponseEntity<Void> clearUserCache() {
        userService.clearUserCache();
        return ResponseEntity.ok().build();
    }

    @PostMapping("/cache/profiles/clear")
    public ResponseEntity<Void> clearProfileCache() {
        profileService.clearProfileCache();
        return ResponseEntity.ok().build();
    }
}
Enter fullscreen mode Exit fullscreen mode

5️⃣ Redis Configuration with TTL

package com.example.demo.config;

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.Map;

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        // JSON serializer for values
        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer();

        // String serializer for keys
        StringRedisSerializer stringSerializer = new StringRedisSerializer();

        // Default cache configuration
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(stringSerializer))
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer))
                .entryTtl(Duration.ofMinutes(30)) // Default TTL: 30 minutes
                .disableCachingNullValues()
                .prefixCacheNameWith("app:cache:");

        // Per-cache configurations with different TTLs
        Map<String, RedisCacheConfiguration> cacheConfigurations = Map.of(
            "users", defaultConfig
                .entryTtl(Duration.ofMinutes(5))
                .prefixCacheNameWith("app:users:"),
            "profiles", defaultConfig
                .entryTtl(Duration.ofMinutes(10))
                .prefixCacheNameWith("app:profiles:")
        );

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaultConfig)
                .withInitialCacheConfigurations(cacheConfigurations)
                .transactionAware()
                .build();
    }
}
Enter fullscreen mode Exit fullscreen mode

6️⃣ Application Configuration

application.properties

# Application
spring.application.name=spring-redis-multi-cache
server.port=8080

# Database (H2 for demo)
spring.datasource.url=jdbc:h2:mem:testdb
spring.datasource.driver-class-name=org.h2.Driver
spring.datasource.username=sa
spring.datasource.password=
spring.h2.console.enabled=true

# JPA
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true

# Redis
spring.cache.type=redis
spring.redis.host=localhost
spring.redis.port=6379
spring.redis.database=0
spring.redis.timeout=2000ms

# Logging
logging.level.com.example.demo=DEBUG
logging.level.org.springframework.cache=DEBUG
logging.level.org.springframework.data.redis=DEBUG
Enter fullscreen mode Exit fullscreen mode

application.yml (Alternative)

spring:
  application:
    name: spring-redis-multi-cache
  datasource:
    url: jdbc:h2:mem:testdb
    driver-class-name: org.h2.Driver
    username: sa
    password: ""
  h2:
    console:
      enabled: true
  jpa:
    hibernate:
      ddl-auto: create-drop
    show-sql: true
    properties:
      hibernate:
        format_sql: true
  cache:
    type: redis
  redis:
    host: localhost
    port: 6379
    database: 0
    timeout: 2000ms

server:
  port: 8080

logging:
  level:
    com.example.demo: DEBUG
    org.springframework.cache: DEBUG
    org.springframework.data.redis: DEBUG
Enter fullscreen mode Exit fullscreen mode

7️⃣ Docker Setup

docker-compose.yml

version: '3.8'
services:
  redis:
    image: redis:7-alpine
    container_name: redis-cache
    ports:
      - "6379:6379"
    command: redis-server --appendonly yes
    volumes:
      - redis_data:/data

volumes:
  redis_data:
Enter fullscreen mode Exit fullscreen mode

Start Redis

# Using Docker Compose
docker-compose up -d

# Or using Docker directly
docker run --name redis-cache -p 6379:6379 -d redis:7-alpine
Enter fullscreen mode Exit fullscreen mode

8️⃣ Testing the Cache Implementation

Cache Behavior Matrix

Operation Endpoint Cache Action TTL Notes
GET /api/v1/users/{id} users cache @Cacheable 5 min Cached on first access
GET /api/v1/users/email/{email} users cache @Cacheable 5 min Cached with email as key
POST/PUT /api/v1/users users cache @CachePut 5 min Updates cache after save
DELETE /api/v1/users/{id} users cache @CacheEvict - Removes from cache
GET /api/v1/profiles/{id} profiles cache @Cacheable 10 min Cached on first access
PUT /api/v1/profiles/{id} profiles + users @CachePut + @CacheEvict 10 min Updates profile, evicts user
DELETE /api/v1/profiles/{id} profiles + users @CacheEvict both - Removes from both caches

Testing Script

#!/bin/bash

# Base URL
BASE_URL="http://localhost:8080/api/v1"

# Test user creation
echo "Creating user..."
curl -X POST $BASE_URL/users \
  -H "Content-Type: application/json" \
  -d '{"name":"John Doe","email":"john@example.com"}'

# Test caching (first call - slow)
echo "First call (cache miss)..."
time curl -X GET $BASE_URL/users/1

# Test caching (second call - fast)
echo "Second call (cache hit)..."
time curl -X GET $BASE_URL/users/1

# Test profile creation and cross-cache invalidation
echo "Creating profile..."
curl -X PUT $BASE_URL/profiles/1 \
  -H "Content-Type: application/json" \
  -d '{"address":"123 Main St","phone":"555-1234","bio":"Software Developer"}'

# Test profile caching
echo "Getting profile (cache miss)..."
time curl -X GET $BASE_URL/profiles/1

echo "Getting profile again (cache hit)..."
time curl -X GET $BASE_URL/profiles/1
Enter fullscreen mode Exit fullscreen mode

9️⃣ Redis Cache Inspection

Using Redis CLI

# Connect to Redis
docker exec -it redis-cache redis-cli

# List all keys
keys *

# Check TTL for specific key
ttl "app:users:1"

# Get cached value
get "app:users:1"

# Monitor cache operations in real-time
monitor
Enter fullscreen mode Exit fullscreen mode

Expected Redis Keys

app:users:1
app:users:john@example.com
app:profiles:1
Enter fullscreen mode Exit fullscreen mode

πŸ”§ Advanced Configuration Options

Custom Cache Key Generator

@Component
public class CustomCacheKeyGenerator implements KeyGenerator {

    @Override
    public Object generate(Object target, Method method, Object... params) {
        StringBuilder key = new StringBuilder();
        key.append(target.getClass().getSimpleName()).append(":");
        key.append(method.getName()).append(":");

        for (Object param : params) {
            key.append(param.toString()).append(":");
        }

        return key.toString();
    }
}
Enter fullscreen mode Exit fullscreen mode

Cache Event Listener

@Component
@EventListener
public class CacheEventListener {

    private static final Logger logger = LoggerFactory.getLogger(CacheEventListener.class);

    @EventListener
    public void handleCacheHit(CacheEvent event) {
        logger.info("Cache hit: {} - {}", event.getCacheName(), event.getKey());
    }

    @EventListener
    public void handleCacheMiss(CacheEvent event) {
        logger.info("Cache miss: {} - {}", event.getCacheName(), event.getKey());
    }
}
Enter fullscreen mode Exit fullscreen mode

🎯 Key Takeaways

Cache Strategy Best Practices

  1. Separate Cache Regions: Use different cache names for different domains
  2. Appropriate TTL: Set TTL based on data volatility and business requirements
  3. Cross-Cache Invalidation: Use @Caching for complex invalidation scenarios
  4. Key Design: Use meaningful, unique keys that won't collide
  5. Null Value Handling: Disable caching of null values to avoid cache pollution

Performance Considerations

  • Users Cache: 5-minute TTL for frequently changing data
  • Profiles Cache: 10-minute TTL for less volatile data
  • JSON Serialization: More readable and cross-platform compatible
  • Connection Pooling: Lettuce provides efficient connection management

Production Readiness

  • Transaction Awareness: Cache operations participate in Spring transactions
  • Error Handling: Graceful fallback when cache is unavailable
  • Monitoring: Enable cache metrics and logging
  • Security: Configure Redis AUTH in production environments

πŸš€ Next Steps

Production Enhancements

  1. Redis Cluster: Implement Redis Sentinel or Cluster for high availability
  2. Cache Warming: Pre-populate critical cache entries on startup
  3. Metrics Integration: Add Micrometer metrics for cache hit/miss ratios
  4. Security: Configure Redis AUTH and SSL/TLS
  5. Backup Strategy: Implement Redis persistence and backup procedures

Advanced Features

  • Conditional Caching: Use SpEL expressions for dynamic cache conditions
  • Cache Synchronization: Implement distributed cache synchronization
  • Multi-Level Caching: Combine local and distributed caching
  • Cache Aside Pattern: Manual cache management for complex scenarios

This comprehensive guide provides a solid foundation for implementing enterprise-grade caching in Spring Boot applications with Redis.

Top comments (0)