Spring Boot + Redis Caching: Strategies and Implementation Guide

Introduction

Every production application eventually hits the same wall: the database becomes a bottleneck. Pages slow down, CPU usage spikes, and your users start noticing. Redis — an in-memory data store — is the most widely used solution, and Spring Boot makes integrating it almost effortless. But "almost" is where most tutorials stop. In this guide we go further: we cover all four major caching strategies, prevent cache stampedes, tune TTLs intelligently, and write tests that actually verify your cache behaviour.

We will build a product catalogue API as the running example — a realistic workload where caching delivers the most value.

By the end you will have:

  • Full Spring Boot 3.4 project wired to a local Redis instance

  • All caching annotations understood and used correctly

  • Custom JSON serialization (no Java-native serialization pitfalls)

  • Cache stampede prevention with sync = true

  • Conditional caching and key generation strategies

  • A test suite that verifies cache hits, misses, and evictions

Prerequisites

  • Java 21+
  • Maven 3.9+
  • Redis 7+ (Docker is the easiest way to run it locally)
  • MySQL 8.0+ (or H2 for tests)

Project Setup

pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
             https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.4.1</version>
        <relativePath/>
    </parent>

    <groupId>com.vimleshpandey.demo</groupId>
    <artifactId>redis-cache-demo</artifactId>
    <version>1.0.0</version>

    <properties>
        <java.version>21</java.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-jpa</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-data-redis</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-cache</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>

        <!-- MySQL -->
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>

        <!-- Lombok -->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <optional>true</optional>
        </dependency>

        <!-- Test -->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.h2database</groupId>
            <artifactId>h2</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.testcontainers</groupId>
            <artifactId>junit-jupiter</artifactId>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>com.redis</groupId>
            <artifactId>testcontainers-redis</artifactId>
            <version>2.2.2</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

application.yml

spring:
  datasource:
    url: jdbc:mysql://localhost:3306/cache_demo_db?createDatabaseIfNotExist=true&useSSL=false
    username: root
    password: your_password
    driver-class-name: com.mysql.cj.jdbc.Driver
  jpa:
    hibernate:
      ddl-auto: update
    show-sql: true
  data:
    redis:
      host: localhost
      port: 6379
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 10
          max-idle: 5
          min-idle: 2

app:
  cache:
    product-ttl-seconds: 300      # 5 min for product listings
    product-detail-ttl-seconds: 600  # 10 min for individual products
    category-ttl-seconds: 3600    # 1 hour for categories (rarely change)

Start Redis with Docker

docker run --name redis-demo -p 6379:6379 -d redis:7-alpine

Caching Strategies — Theory Before Code

Before writing a single annotation, choose the right strategy for each data type:

StrategyHow it worksBest for
Cache-aside (lazy)App checks cache; on miss, loads from DB and populates cacheRead-heavy data with unpredictable access patterns
Write-throughEvery write updates both DB and cache atomicallyData that must always be fresh on reads
Write-behindWrites go to cache first; DB is updated asynchronouslyVery write-heavy workloads where slight lag is acceptable
Read-throughCache itself fetches from DB on a miss (via a loader)When you want to hide DB logic from the application

Spring's annotation-based caching implements cache-aside natively via @Cacheable. We will implement write-through manually via @CachePut.

Redis Cache Configuration

The most important config decision is the serializer. The default JdkSerializationRedisSerializer produces unreadable binary blobs in Redis and breaks when you change class structure. Always use JSON:

package com.vimleshpandey.demo.config;

import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.cache.RedisCacheConfiguration;
import org.springframework.data.redis.cache.RedisCacheManager;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.RedisSerializationContext;
import org.springframework.data.redis.serializer.StringRedisSerializer;

import java.time.Duration;
import java.util.HashMap;
import java.util.Map;

@Configuration
@EnableCaching
public class CacheConfig {

    @Value("${app.cache.product-ttl-seconds}")
    private long productTtl;

    @Value("${app.cache.product-detail-ttl-seconds}")
    private long productDetailTtl;

    @Value("${app.cache.category-ttl-seconds}")
    private long categoryTtl;

    @Bean
    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        // Store type info so polymorphic types deserialize correctly
        mapper.activateDefaultTyping(
                LaissezFaireSubTypeValidator.instance,
                ObjectMapper.DefaultTyping.NON_FINAL,
                JsonTypeInfo.As.PROPERTY
        );

        GenericJackson2JsonRedisSerializer serializer =
                new GenericJackson2JsonRedisSerializer(mapper);

        RedisCacheConfiguration defaults = RedisCacheConfiguration.defaultCacheConfig()
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                        .fromSerializer(serializer))
                .disableCachingNullValues();

        // Per-cache TTL overrides
        Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
        cacheConfigs.put("products", defaults.entryTtl(Duration.ofSeconds(productTtl)));
        cacheConfigs.put("product", defaults.entryTtl(Duration.ofSeconds(productDetailTtl)));
        cacheConfigs.put("categories", defaults.entryTtl(Duration.ofSeconds(categoryTtl)));

        return RedisCacheManager.builder(connectionFactory)
                .cacheDefaults(defaults.entryTtl(Duration.ofMinutes(10)))
                .withInitialCacheConfigurations(cacheConfigs)
                .build();
    }
}

Domain Model

package com.vimleshpandey.demo.model;

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

import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;

@Entity
@Table(name = "products")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class Product implements Serializable {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(length = 2000)
    private String description;

    @Column(nullable = false)
    private BigDecimal price;

    @Column(nullable = false)
    private String category;

    @Column(nullable = false)
    private Integer stockQuantity;

    @Column(nullable = false, updatable = false)
    private LocalDateTime createdAt;

    @PrePersist
    void prePersist() {
        createdAt = LocalDateTime.now();
    }
}
package com.vimleshpandey.demo.repository;

import com.vimleshpandey.demo.model.Product;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.util.List;

public interface ProductRepository extends JpaRepository<Product, Long> {
    List<Product> findByCategory(String category);

    @Query("SELECT DISTINCT p.category FROM Product p ORDER BY p.category")
    List<String> findAllCategories();
}

Service Layer with Caching Annotations

package com.vimleshpandey.demo.service;

import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.repository.ProductRepository;
import jakarta.persistence.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.*;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
@Slf4j
public class ProductService {

    private final ProductRepository productRepository;

    // Cache-aside: check cache first; load DB on miss; populate cache; return.
    // sync=true prevents cache stampede: only one thread computes the value per key.
    @Cacheable(value = "product", key = "#id", sync = true)
    public Product getById(Long id) {
        log.info("Cache MISS — loading product {} from database", id);
        return productRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
    }

    @Cacheable(value = "products", key = "#category", sync = true)
    public List<Product> getByCategory(String category) {
        log.info("Cache MISS — loading category '{}' from database", category);
        return productRepository.findByCategory(category);
    }

    @Cacheable(value = "categories", key = "'all'")
    public List<String> getAllCategories() {
        log.info("Cache MISS — loading categories from database");
        return productRepository.findAllCategories();
    }

    // Write-through: update DB and refresh the cache in one operation.
    @CachePut(value = "product", key = "#result.id")
    @CacheEvict(value = {"products", "categories"}, allEntries = true)
    @Transactional
    public Product create(ProductRequest request) {
        Product product = Product.builder()
                .name(request.getName())
                .description(request.getDescription())
                .price(request.getPrice())
                .category(request.getCategory())
                .stockQuantity(request.getStockQuantity())
                .build();
        return productRepository.save(product);
    }

    // @Caching lets you compose multiple cache operations on one method.
    @Caching(
        put = { @CachePut(value = "product", key = "#id") },
        evict = { @CacheEvict(value = "products", allEntries = true) }
    )
    @Transactional
    public Product update(Long id, ProductRequest request) {
        Product product = productRepository.findById(id)
                .orElseThrow(() -> new EntityNotFoundException("Product not found: " + id));
        product.setName(request.getName());
        product.setDescription(request.getDescription());
        product.setPrice(request.getPrice());
        product.setCategory(request.getCategory());
        product.setStockQuantity(request.getStockQuantity());
        return productRepository.save(product);
    }

    @Caching(evict = {
        @CacheEvict(value = "product", key = "#id"),
        @CacheEvict(value = "products", allEntries = true),
        @CacheEvict(value = "categories", allEntries = true)
    })
    @Transactional
    public void delete(Long id) {
        productRepository.deleteById(id);
    }
}

Understanding Each Annotation

@Cacheable(value = "product", key = "#id", sync = true)

Checks the product cache for the key derived from #id. On a hit, the method body is skipped entirely — the cached value is returned directly. On a miss, the method executes, the return value is stored in Redis, and then returned.

sync = true solves the cache stampede (also called dogpile) problem. Without it, if 200 concurrent requests arrive for a cold key, all 200 will see a cache miss and hit the database simultaneously. With sync = true, only one thread queries the DB while the rest wait for the result to be cached.

@CachePut(value = "product", key = "#result.id")

Always executes the method AND updates the cache. Use this for writes that should immediately refresh a cached entry. #result refers to the method's return value — useful when the key is the auto-generated ID.

@CacheEvict(value = "products", allEntries = true)

Removes cache entries. allEntries = true clears the entire cache namespace — appropriate for list caches where a single product change may affect many queries. For individual entries, use key = "#id" without allEntries.

@Caching

Composes multiple cache operations on a single method. Essential when a write should both update one cache namespace and evict from another.

Conditional Caching

Cache only when it makes sense — don't cache empty results or results that aren't worth the overhead:

// Only cache if there are results; no point storing an empty list
@Cacheable(value = "products", key = "#category",
           condition = "#category != null",
           unless = "#result.isEmpty()")
public List<Product> getByCategory(String category) {
    return productRepository.findByCategory(category);
}

// Only cache products priced above €10 (cheap items turn over fast)
@Cacheable(value = "product", key = "#id",
           unless = "#result.price.compareTo(T(java.math.BigDecimal).TEN) < 0")
public Product getById(Long id) {
    return productRepository.findById(id).orElseThrow();
}

condition is evaluated before the method executes. unless is evaluated after — it has access to #result.

Custom Key Generator

For complex keys that span multiple parameters, implement KeyGenerator:

package com.vimleshpandey.demo.config;

import org.springframework.cache.interceptor.KeyGenerator;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.stream.Collectors;

@Configuration
public class KeyGeneratorConfig {

    @Bean("compositeKeyGenerator")
    public KeyGenerator compositeKeyGenerator() {
        return (Object target, Method method, Object... params) ->
                target.getClass().getSimpleName() + ":" +
                method.getName() + ":" +
                Arrays.stream(params)
                      .map(Object::toString)
                      .collect(Collectors.joining("-"));
    }
}

Usage: @Cacheable(value = "products", keyGenerator = "compositeKeyGenerator")

REST Controller

package com.vimleshpandey.demo.controller;

import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.net.URI;
import java.util.List;

@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {

    private final ProductService productService;

    @GetMapping("/{id}")
    public ResponseEntity<Product> getById(@PathVariable Long id) {
        return ResponseEntity.ok(productService.getById(id));
    }

    @GetMapping("/category/{category}")
    public ResponseEntity<List<Product>> getByCategory(@PathVariable String category) {
        return ResponseEntity.ok(productService.getByCategory(category));
    }

    @GetMapping("/categories")
    public ResponseEntity<List<String>> getAllCategories() {
        return ResponseEntity.ok(productService.getAllCategories());
    }

    @PostMapping
    public ResponseEntity<Product> create(@Valid @RequestBody ProductRequest request) {
        Product created = productService.create(request);
        return ResponseEntity.created(URI.create("/api/products/" + created.getId()))
                .body(created);
    }

    @PutMapping("/{id}")
    public ResponseEntity<Product> update(@PathVariable Long id,
                                          @Valid @RequestBody ProductRequest request) {
        return ResponseEntity.ok(productService.update(id, request));
    }

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

DTO

package com.vimleshpandey.demo.dto;

import jakarta.validation.constraints.*;
import lombok.Data;

import java.math.BigDecimal;

@Data
public class ProductRequest {
    @NotBlank
    private String name;

    private String description;

    @NotNull @DecimalMin("0.01")
    private BigDecimal price;

    @NotBlank
    private String category;

    @NotNull @Min(0)
    private Integer stockQuantity;
}

Cache Monitoring with Actuator

Add spring-boot-starter-actuator and expose cache metrics:

management:
  endpoints:
    web:
      exposure:
        include: health, info, metrics, caches
  metrics:
    cache:
      instrument-all: true

Now hit GET /actuator/caches to see all active caches and GET /actuator/metrics/cache.gets for hit/miss ratios.

Testing

Testing with Testcontainers gives you real Redis behaviour — no mocks, no surprises:

package com.vimleshpandey.demo;

import com.vimleshpandey.demo.dto.ProductRequest;
import com.vimleshpandey.demo.model.Product;
import com.vimleshpandey.demo.service.ProductService;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.cache.CacheManager;
import org.springframework.test.context.DynamicPropertyRegistry;
import org.springframework.test.context.DynamicPropertySource;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;

import com.redis.testcontainers.RedisContainer;

import java.math.BigDecimal;

import static org.assertj.core.api.Assertions.assertThat;

@SpringBootTest
@Testcontainers
class ProductCacheTest {

    @Container
    static RedisContainer redis = new RedisContainer(
            org.testcontainers.utility.DockerImageName.parse("redis:7-alpine"));

    @DynamicPropertySource
    static void redisProperties(DynamicPropertyRegistry registry) {
        registry.add("spring.data.redis.host", redis::getHost);
        registry.add("spring.data.redis.port", () -> redis.getMappedPort(6379));
    }

    @Autowired ProductService productService;
    @Autowired CacheManager cacheManager;

    @Test
    void cacheHitAfterFirstLoad() {
        ProductRequest req = new ProductRequest();
        req.setName("Test Widget");
        req.setDescription("A test product");
        req.setPrice(BigDecimal.valueOf(29.99));
        req.setCategory("Electronics");
        req.setStockQuantity(100);

        Product created = productService.create(req);
        Long id = created.getId();

        // First call should have populated the cache via @CachePut
        var cachedValue = cacheManager.getCache("product").get(id);
        assertThat(cachedValue).isNotNull();
    }

    @Test
    void cacheEvictedOnDelete() {
        ProductRequest req = new ProductRequest();
        req.setName("Doomed Product");
        req.setDescription("Will be deleted");
        req.setPrice(BigDecimal.valueOf(9.99));
        req.setCategory("Misc");
        req.setStockQuantity(1);

        Product created = productService.create(req);
        Long id = created.getId();

        // Load into cache
        productService.getById(id);
        assertThat(cacheManager.getCache("product").get(id)).isNotNull();

        // Delete should evict from cache
        productService.delete(id);
        assertThat(cacheManager.getCache("product").get(id)).isNull();
    }
}

Running the Application

# Start Redis
docker run --name redis-demo -p 6379:6379 -d redis:7-alpine

# Create MySQL database
mysql -u root -p -e "CREATE DATABASE cache_demo_db;"

# Run
./mvnw spring-boot:run

# Create a product (also caches it via @CachePut)
curl -X POST http://localhost:8080/api/products \
  -H "Content-Type: application/json" \
  -d '{"name":"Laptop","description":"Fast laptop","price":999.99,"category":"Electronics","stockQuantity":50}'

# Get the product — first call: DB hit (logged). Second call: cache hit (no log).
curl http://localhost:8080/api/products/1
curl http://localhost:8080/api/products/1

# Inspect Redis directly
docker exec redis-demo redis-cli KEYS "*"
docker exec redis-demo redis-cli GET "product::1"

Common Pitfalls and Tips

1. Never cache mutable state without TTL — Always set a TTL via entryTtl in RedisCacheConfiguration. An entry with no TTL lives forever and will return stale data after a background DB update.

2. @Cacheable on internal calls silently does nothing — Spring's caching uses a proxy around your service. If method A calls method B in the same class, the proxy is bypassed and B's @Cacheable annotation is ignored. Move the cached method to a separate service or inject the service into itself using @Lazy.

3. All cached classes must be serializable — Ensure your entities/DTOs implement Serializable (or rely entirely on the JSON serializer). With GenericJackson2JsonRedisSerializer, you need a no-arg constructor on cached classes.

4. KeySpace collisions — By default, Spring prefixes keys with the cache name: product::1. This means you can safely reuse numeric IDs across different cache namespaces. But if you use string keys that overlap (e.g., "all" in multiple caches), double-check each cache name is unique.

5. Don't cache paginated results naively@Cacheable on a Page result serializes the entire PageImpl, including metadata. Cache the underlying List instead and handle pagination at the controller level.

6. Use allEntries = true judiciously@CacheEvict(allEntries = true) clears every key in the named cache namespace. For a large product listing cache, this is the right call on a write. But avoid it on caches with thousands of unrelated keys — a targeted key = "#id" eviction is cheaper.

7. Test cache behaviour, not just business logic — Use CacheManager.getCache(name).get(key) in tests to assert cache presence/absence. Don't assume annotations work — verify them.

Conclusion

Redis caching transforms read-heavy Spring Boot applications. With the patterns in this guide you can:

  • Keep database query counts dramatically lower under high load
  • Serve category listings from memory in microseconds instead of milliseconds
  • Prevent cache stampedes on popular items with sync = true
  • Apply fine-grained TTLs per data type so caches expire at the right rate
  • Verify cache behaviour with real Redis via Testcontainers

The next steps from here are:

  • Cache warming — pre-populate Redis on startup for your most-hit keys

  • Redis Cluster — switch from a single Redis node to a cluster for HA

  • Caffeine L1 cache — add a local in-process cache in front of Redis for ultra-hot data to eliminate network round-trips entirely

  • Cache metrics dashboard — export hit/miss counters to Grafana via Actuator's Micrometer integration