Java Spring Boot Backend Database

Spring Boot Redis Cache: Complete Guide with @Cacheable & Spring Data Redis

Spring Boot Redis Cache: Complete Guide with @Cacheable & Spring Data Redis

In high-throughput distributed applications, direct database access is almost always your primary performance bottleneck. While an optimized PostgreSQL or MySQL query takes between 15ms and 80ms (accounting for index traversal, query planning, connection pooling, and disk I/O), an in-memory Redis lookup finishes in under 1 millisecond.

Spring Boot provides a powerful declarative caching abstraction, but running it in production requires far more than slapping @EnableCaching on your main class. In this production-grade tutorial using Java 21, Spring Boot 3.x, and Spring Data Redis, you will build a robust caching architecture: custom JSON serialization with Jackson (avoiding unreadable JDK binary blobs), custom per-cache TTL expiration, Docker Compose orchestration, and bulletproof defenses against Cache Avalanche, Cache Penetration, and Cache Stampede.

Building on top of a relational database? Check out our previous guide on Spring Boot PostgreSQL CRUD API with JPA & Hibernate to establish your database and entity layer.
⚡

TL;DR: The 3-Minute Production Redis Cache Snippet

Here is the essential production pattern: Add spring-boot-starter-data-redis, configure JSON serialization in RedisCacheManager, and annotate service methods with @Cacheable:

@Service
public class ProductService {

    private final ProductRepository productRepository;

    public ProductService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }

    // 1. Read through cache (Cache-Aside)
    @Cacheable(value = "products", key = "#id", unless = "#result == null")
    public ProductResponse getProductById(Long id) {
        return productRepository.findById(id)
            .map(ProductResponse::fromEntity)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
    }

    // 2. Update database and refresh cache entry
    @CachePut(value = "products", key = "#result.id()")
    public ProductResponse updateProduct(Long id, ProductUpdateRequest request) {
        Product product = productRepository.findById(id)
            .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));
        product.update(request.name(), request.price());
        return ProductResponse.fromEntity(productRepository.save(product));
    }

    // 3. Invalidate cache on deletion
    @CacheEvict(value = "products", key = "#id")
    public void deleteProduct(Long id) {
        productRepository.deleteById(id);
    }
}
Table of Contents

1. Why Redis Caching Matters (Latency Breakdown)

When an API handles thousands of simultaneous requests per second, recurring read queries (such as product details, user profiles, feature flags, or catalog listings) needlessly saturate your relational database. Database CPU spikes, thread pools deplete, and connection limits trigger cascading timeouts.

Redis operates strictly in RAM using single-threaded, non-blocking asynchronous event loops. The speed difference is dramatic:

Metric PostgreSQL (Indexed Read) Redis In-Memory Cache Speed Gain
Average Latency 12ms – 65ms 0.3ms – 1.2ms ~25x – 60x faster
Throughput (Single Node) ~3,000 – 7,000 QPS 100,000+ QPS ~15x higher
I/O Bottleneck Disk / Buffer Cache / Locks RAM Memory Bus only Zero Disk Wait

2. How Spring Cache Abstraction & Cache-Aside Work

Spring Boot uses the Cache-Aside (Lazy Loading) pattern implemented via Spring AOP proxies:

  1. Request arrives: The caller invokes a service method annotated with @Cacheable.
  2. Proxy interception: Spring's cache aspect intercepts the invocation and computes the cache key (e.g. products::42).
  3. Cache Lookup: If the key is found in Redis (Cache Hit), Spring bypasses the method body entirely and returns the cached value.
  4. Database Read: If the key is not in Redis (Cache Miss), the target service method executes, queries PostgreSQL, and stores the resulting object in Redis before returning it to the caller.

3. Local Redis with Docker Compose

To spin up Redis locally with a persistent volume and a web-based GUI management tool (Redis Commander), create a docker-compose.yml file:

version: '3.8'

services:
  redis:
    image: redis:7.2-alpine
    container_name: digitaldrift-redis
    restart: always
    ports:
      - "6379:6379"
    command: redis-server --save 60 1 --loglevel warning --requirepass "secret123"
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD", "redis-cli", "-a", "secret123", "ping"]
      interval: 5s
      timeout: 3s
      retries: 5

  redis-commander:
    image: rediscommander/redis-commander:latest
    container_name: digitaldrift-redis-commander
    environment:
      - REDIS_HOSTS=local:redis:6379:0:secret123
    ports:
      - "8081:8081"
    depends_on:
      - redis

volumes:
  redis_data:

Start Redis by running:

docker compose up -d

You can access Redis Commander at http://localhost:8081 to inspect keys, values, and memory consumption in real time.

4. Project Dependencies (build.gradle & pom.xml)

Add spring-boot-starter-data-redis and commons-pool2 (which enables high-performance connection pooling for the default Lettuce driver).

Gradle (build.gradle)

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    implementation 'org.springframework.boot:spring-boot-starter-cache'
    implementation 'org.apache.commons:commons-pool2'

    // JSON serialization with Java 8/21 Date/Time support
    implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'

    runtimeOnly 'org.postgresql:postgresql'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

Maven (pom.xml)

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-cache</artifactId>
    </dependency>
    <dependency>
        <groupId>org.apache.commons</groupId>
        <artifactId>commons-pool2</artifactId>
    </dependency>
</dependencies>

5. Connection & Lettuce Pool Configuration

Configure Redis connection parameters and Lettuce pool sizing in src/main/resources/application.yml:

spring:
  cache:
    type: redis
  data:
    redis:
      host: localhost
      port: 6379
      password: secret123
      timeout: 2000ms
      lettuce:
        pool:
          max-active: 16
          max-idle: 8
          min-idle: 4
          max-wait: 1000ms

# Custom application cache settings
app:
  cache:
    default-ttl: 600s
    product-ttl: 300s
    user-ttl: 3600s

6. Custom RedisConfig (JSON Serialization & Per-Cache TTL)

By default, Spring Boot serializes objects into Redis using Java's built-in JdkSerializationRedisSerializer. This produces unreadable hexadecimal binaries (\xac\xed\x00\x05...), causes InvalidClassException whenever your DTO schema changes, and prevents other microservices from parsing the cached data.

The production solution is a custom RedisCacheManager configured with GenericJackson2JsonRedisSerializer:

package com.digitaldrift.config;

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

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

@Configuration
@EnableCaching
public class RedisConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
        // Configure Jackson ObjectMapper for clean polymorphic JSON handling
        ObjectMapper mapper = new ObjectMapper();
        mapper.registerModule(new JavaTimeModule());
        mapper.activateDefaultTyping(
            LaissezFaireSubTypeValidator.instance,
            ObjectMapper.DefaultTyping.NON_FINAL,
            JsonTypeInfo.As.PROPERTY
        );

        GenericJackson2JsonRedisSerializer jsonSerializer = new GenericJackson2JsonRedisSerializer(mapper);

        // Default Cache Configuration: 10 minutes TTL, no null caching
        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
            .entryTtl(Duration.ofMinutes(10))
            .disableCachingNullValues()
            .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
            .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(jsonSerializer));

        // Tailored per-cache TTL configurations
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        cacheConfigurations.put("products", defaultConfig.entryTtl(Duration.ofMinutes(5)));
        cacheConfigurations.put("categories", defaultConfig.entryTtl(Duration.ofHours(24)));
        cacheConfigurations.put("user_sessions", defaultConfig.entryTtl(Duration.ofMinutes(30)));

        return RedisCacheManager.builder(connectionFactory)
            .cacheDefaults(defaultConfig)
            .withInitialCacheConfigurations(cacheConfigurations)
            .build();
    }
}

7. Implementing @Cacheable, @CachePut & @CacheEvict

Now let's examine the three core annotations you will use in your service layer:

1. @Cacheable — Read-through caching

Checks the cache first. If found, returns it immediately without querying the database:

// Caches the return value under key: "products::101"
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductResponse getProductById(Long id) {
    log.info("Fetching product #{} directly from PostgreSQL...", id);
    return productRepository.findById(id)
        .map(ProductResponse::fromEntity)
        .orElseThrow(() -> new ResourceNotFoundException("Product not found with id: " + id));
}

2. @CachePut — Updating cache without bypassing execution

Always runs the method (saving the update to PostgreSQL) and updates Redis with the new result:

@CachePut(value = "products", key = "#result.id()")
public ProductResponse updateProduct(Long id, ProductUpdateRequest request) {
    Product product = productRepository.findById(id)
        .orElseThrow(() -> new ResourceNotFoundException("Product not found: " + id));

    product.setName(request.name());
    product.setPrice(request.price());

    Product saved = productRepository.save(product);
    log.info("Updated product #{} in DB and refreshed Redis cache", saved.getId());
    return ProductResponse.fromEntity(saved);
}

3. @CacheEvict — Invalidation upon deletion

Removes the stale key from Redis when an entity is deleted or updated:

@CacheEvict(value = "products", key = "#id")
public void deleteProduct(Long id) {
    if (!productRepository.existsById(id)) {
        throw new ResourceNotFoundException("Cannot delete: product #" + id + " does not exist");
    }
    productRepository.deleteById(id);
    log.info("Deleted product #{} from DB and evicted from Redis", id);
}

// Bulk eviction: Clears the entire 'products' cache namespace
@CacheEvict(value = "products", allEntries = true)
public void clearAllProductCache() {
    log.info("Cleared entire products cache partition");
}

8. REST Controller Endpoints

Expose standard REST endpoints in ProductController:

package com.digitaldrift.controller;

import com.digitaldrift.dto.ProductCreateRequest;
import com.digitaldrift.dto.ProductResponse;
import com.digitaldrift.dto.ProductUpdateRequest;
import com.digitaldrift.service.ProductService;
import jakarta.validation.Valid;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

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

    private final ProductService productService;

    public ProductController(ProductService productService) {
        this.productService = productService;
    }

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

    @PostMapping
    public ResponseEntity<ProductResponse> createProduct(@Valid @RequestBody ProductCreateRequest request) {
        return ResponseEntity.status(HttpStatus.CREATED).body(productService.createProduct(request));
    }

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

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

9. Production Pitfalls: Avalanche, Penetration & Stampede

When scaling Redis in production environments, three classic caching anomalies can take down your backend:

1. Cache Avalanche (Mass Expiration)

The Problem: If you set a fixed 1-hour TTL on 100,000 product records during a midnight sync, all 100,000 keys expire at exactly 1:00 AM. 100% of user traffic instantly falls through to PostgreSQL, crashing the database.

The Fix: Introduce TTL Jitter (adding a random offset of 10%–20% to each key):

// Example: Base TTL of 60 minutes + random 0 to 12 minutes jitter
Duration ttl = Duration.ofMinutes(60).plusSeconds(ThreadLocalRandom.current().nextLong(720));

2. Cache Penetration (Querying Non-Existent Keys)

The Problem: An attacker sends millions of requests for non-existent IDs (e.g. id = -99999 or random UUIDs). Because the data is never in Redis or the DB, the cache never stores it, and every single request hits PostgreSQL.

The Fix: Cache empty/null results with a very short TTL (e.g., 60 seconds), or use a Bloom Filter in front of your service.

3. Cache Stampede / Dog-piling

The Problem: When an extremely popular hot key (like the homepage banner or flash sale inventory) expires, thousands of concurrent threads experience a cache miss simultaneously and all query PostgreSQL at the exact same millisecond.

The Fix: Enable Spring's synchronized cache lock:

@Cacheable(value = "products", key = "#id", sync = true)
public ProductResponse getHotProduct(Long id) {
    // Only ONE thread executes this database lookup; all other threads wait and receive the cached result
    return productRepository.findById(id).map(ProductResponse::fromEntity).orElseThrow();
}

10. Testing with Redis CLI & Performance Benchmarks

Verify your keys and inspection payloads using the Redis command line:

# Connect to Redis container
docker exec -it digitaldrift-redis redis-cli -a secret123

# List active keys
127.0.0.1:6379> KEYS "products::*"
1) "products::1"
2) "products::2"

# Inspect remaining TTL in seconds
127.0.0.1:6379> TTL "products::1"
(integer) 284

# Inspect the clean JSON payload stored by Jackson
127.0.0.1:6379> GET "products::1"
"{\"@class\":\"com.digitaldrift.dto.ProductResponse\",\"id\":1,\"name\":\"Mechanical Keyboard\",\"price\":129.99}"

Latency Benchmark (cURL)

Measure the response time before and after caching:

# 1st Request (Cache Miss — hits PostgreSQL)
curl -o /dev/null -s -w 'Total Time: %{time_total}s\n' http://localhost:8080/api/v1/products/1
# Total Time: 0.048210s (48ms)

# 2nd Request (Cache Hit — served by Redis)
curl -o /dev/null -s -w 'Total Time: %{time_total}s\n' http://localhost:8080/api/v1/products/1
# Total Time: 0.001140s (1.1ms)

11. Frequently Asked Questions & Interview Tips

Q: What is the difference between Redis and Memcached?

Memcached is a pure multi-threaded in-memory key-value store. Redis is a rich in-memory data structures server supporting strings, hashes, lists, sets, sorted sets, streams, Pub/Sub, persistence (RDB/AOF), and cluster clustering. For modern Spring Boot applications, Redis is the industry standard.

Q: How do you handle Redis connection failures gracefully?

By default, a failed Redis connection throws a RedisConnectionFailureException. In production, configure a custom CacheErrorHandler:

public class CustomCacheErrorHandler implements CacheErrorHandler {
    private static final Logger log = LoggerFactory.getLogger(CustomCacheErrorHandler.class);

    @Override
    public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
        log.warn("Redis unavailable for GET on key {}. Falling back to DB.", key, ex);
    }

    @Override
    public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
        log.warn("Redis unavailable for PUT on key {}. Writing to DB only.", key, ex);
    }

    @Override
    public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
        log.warn("Redis unavailable for EVICT on key {}.", key, ex);
    }

    @Override
    public void handleCacheClearError(RuntimeException ex, Cache cache) {
        log.warn("Redis unavailable for CLEAR on cache {}.", cache.getName(), ex);
    }
}

Q: Can I cache paginated queries with @Cacheable?

Yes! Use SpEL (Spring Expression Language) to incorporate page number and page size into the key:

@Cacheable(value = "product_pages", key = "'page_' + #pageable.pageNumber + '_size_' + #pageable.pageSize")
public Page<ProductResponse> getProducts(Pageable pageable) { ... }

Conclusion & Next Steps

Implementing Redis caching in Spring Boot 3 transforms your backend from a fragile service vulnerable to database bottlenecks into a lightning-fast, production-ready distributed system.

To continue expanding your Spring Boot architecture: