Back to Journal
System Design

How to Build Distributed Caching Using Spring Boot

Step-by-step tutorial for building Distributed Caching with Spring Boot, from project setup through deployment.

Muneer Puthiya Purayil 21 min read

Spring Boot's caching abstraction provides a powerful, annotation-driven approach to distributed caching. In this tutorial, you'll build a complete caching system using Spring Boot 3 and Redis, starting from project setup through production deployment. By the end, you'll have a production-ready caching layer with cache-aside patterns, stampede protection, multi-level caching, and comprehensive monitoring—all following Spring Boot conventions.

Prerequisites

  • Java 21+
  • Redis 7+ running locally
  • Maven or Gradle
  • Basic familiarity with Spring Boot

Project Setup

Generate the project with Spring Initializr or add these dependencies:

xml
1<!-- pom.xml -->
2<dependencies>
3 <dependency>
4 <groupId>org.springframework.boot</groupId>
5 <artifactId>spring-boot-starter-web</artifactId>
6 </dependency>
7 <dependency>
8 <groupId>org.springframework.boot</groupId>
9 <artifactId>spring-boot-starter-data-redis</artifactId>
10 </dependency>
11 <dependency>
12 <groupId>org.springframework.boot</groupId>
13 <artifactId>spring-boot-starter-cache</artifactId>
14 </dependency>
15 <dependency>
16 <groupId>org.springframework.boot</groupId>
17 <artifactId>spring-boot-starter-actuator</artifactId>
18 </dependency>
19 <dependency>
20 <groupId>org.apache.commons</groupId>
21 <artifactId>commons-pool2</artifactId>
22 </dependency>
23 <dependency>
24 <groupId>com.github.ben-manes.caffeine</groupId>
25 <artifactId>caffeine</artifactId>
26 </dependency>
27 <dependency>
28 <groupId>org.projectlombok</groupId>
29 <artifactId>lombok</artifactId>
30 <optional>true</optional>
31 </dependency>
32 <dependency>
33 <groupId>org.springframework.boot</groupId>
34 <artifactId>spring-boot-starter-test</artifactId>
35 <scope>test</scope>
36 </dependency>
37</dependencies>
38 

Application Configuration

yaml
1# application.yml
2spring:
3 data:
4 redis:
5 host: ${REDIS_HOST:localhost}
6 port: ${REDIS_PORT:6379}
7 password: ${REDIS_PASSWORD:}
8 lettuce:
9 pool:
10 max-active: 20
11 max-idle: 10
12 min-idle: 5
13 max-wait: 5000ms
14 shutdown-timeout: 200ms
15 cache:
16 type: redis
17 redis:
18 time-to-live: 300000
19 key-prefix: "app:"
20 use-key-prefix: true
21 cache-null-values: false
22 
23management:
24 endpoints:
25 web:
26 exposure:
27 include: health,info,caches,metrics
28 

Step 1: Redis Configuration

Configure Redis with connection pooling, custom serialization, and multiple cache regions:

java
1// src/main/java/com/example/config/RedisConfig.java
2package com.example.config;
3 
4import com.fasterxml.jackson.annotation.JsonTypeInfo;
5import com.fasterxml.jackson.databind.ObjectMapper;
6import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
7import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
8import org.springframework.cache.CacheManager;
9import org.springframework.cache.annotation.EnableCaching;
10import org.springframework.context.annotation.Bean;
11import org.springframework.context.annotation.Configuration;
12import org.springframework.data.redis.cache.RedisCacheConfiguration;
13import org.springframework.data.redis.cache.RedisCacheManager;
14import org.springframework.data.redis.connection.RedisConnectionFactory;
15import org.springframework.data.redis.core.RedisTemplate;
16import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer;
17import org.springframework.data.redis.serializer.RedisSerializationContext;
18import org.springframework.data.redis.serializer.StringRedisSerializer;
19 
20import java.time.Duration;
21import java.util.Map;
22 
23@Configuration
24@EnableCaching
25public class RedisConfig {
26 
27 @Bean
28 public RedisTemplate<String, Object> redisTemplate(
29 RedisConnectionFactory connectionFactory) {
30 RedisTemplate<String, Object> template = new RedisTemplate<>();
31 template.setConnectionFactory(connectionFactory);
32 template.setKeySerializer(new StringRedisSerializer());
33 template.setValueSerializer(jsonSerializer());
34 template.setHashKeySerializer(new StringRedisSerializer());
35 template.setHashValueSerializer(jsonSerializer());
36 template.afterPropertiesSet();
37 return template;
38 }
39 
40 @Bean
41 public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
42 RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
43 .defaultCacheConfig()
44 .entryTtl(Duration.ofMinutes(5))
45 .serializeKeysWith(
46 RedisSerializationContext.SerializationPair
47 .fromSerializer(new StringRedisSerializer()))
48 .serializeValuesWith(
49 RedisSerializationContext.SerializationPair
50 .fromSerializer(jsonSerializer()))
51 .disableCachingNullValues();
52 
53 // Per-cache TTL configuration
54 Map<String, RedisCacheConfiguration> cacheConfigs = Map.of(
55 "products", defaultConfig.entryTtl(Duration.ofMinutes(1)),
56 "product-detail", defaultConfig.entryTtl(Duration.ofMinutes(5)),
57 "categories", defaultConfig.entryTtl(Duration.ofHours(1)),
58 "user-sessions", defaultConfig.entryTtl(Duration.ofHours(24))
59 );
60 
61 return RedisCacheManager.builder(connectionFactory)
62 .cacheDefaults(defaultConfig)
63 .withInitialCacheConfigurations(cacheConfigs)
64 .transactionAware()
65 .build();
66 }
67 
68 private GenericJackson2JsonRedisSerializer jsonSerializer() {
69 ObjectMapper mapper = new ObjectMapper();
70 mapper.registerModule(new JavaTimeModule());
71 mapper.activateDefaultTyping(
72 LaissezFaireSubTypeValidator.instance,
73 ObjectMapper.DefaultTyping.NON_FINAL,
74 JsonTypeInfo.As.PROPERTY
75 );
76 return new GenericJackson2JsonRedisSerializer(mapper);
77 }
78}
79 

Step 2: Domain Models

java
1// src/main/java/com/example/model/Product.java
2package com.example.model;
3 
4import lombok.AllArgsConstructor;
5import lombok.Builder;
6import lombok.Data;
7import lombok.NoArgsConstructor;
8 
9import java.io.Serializable;
10import java.math.BigDecimal;
11 
12@Data
13@Builder
14@NoArgsConstructor
15@AllArgsConstructor
16public class Product implements Serializable {
17 private Long id;
18 private String name;
19 private BigDecimal price;
20 private String category;
21 private Integer stock;
22}
23 
java
1// src/main/java/com/example/model/ProductPage.java
2package com.example.model;
3 
4import lombok.AllArgsConstructor;
5import lombok.Data;
6import lombok.NoArgsConstructor;
7 
8import java.io.Serializable;
9import java.util.List;
10 
11@Data
12@NoArgsConstructor
13@AllArgsConstructor
14public class ProductPage implements Serializable {
15 private List<Product> items;
16 private int total;
17 private int page;
18 private int perPage;
19}
20 

Step 3: Cache Service with Stampede Protection

Build a service that wraps Spring's cache with distributed locking:

java
1// src/main/java/com/example/cache/CacheLockService.java
2package com.example.cache;
3 
4import lombok.RequiredArgsConstructor;
5import lombok.extern.slf4j.Slf4j;
6import org.springframework.data.redis.core.RedisTemplate;
7import org.springframework.data.redis.core.script.DefaultRedisScript;
8import org.springframework.stereotype.Service;
9 
10import java.time.Duration;
11import java.util.Collections;
12import java.util.UUID;
13import java.util.concurrent.Callable;
14 
15@Slf4j
16@Service
17@RequiredArgsConstructor
18public class CacheLockService {
19 
20 private final RedisTemplate<String, Object> redisTemplate;
21 
22 private static final String RELEASE_SCRIPT = """
23 if redis.call("get", KEYS[1]) == ARGV[1] then
24 return redis.call("del", KEYS[1])
25 else
26 return 0
27 end
28 """;
29 
30 public <T> T executeWithLock(
31 String lockKey,
32 Duration lockTtl,
33 Duration waitTimeout,
34 Callable<T> action
35 ) throws Exception {
36 String lockValue = UUID.randomUUID().toString();
37 long deadline = System.currentTimeMillis() + waitTimeout.toMillis();
38 
39 while (System.currentTimeMillis() < deadline) {
40 Boolean acquired = redisTemplate.opsForValue()
41 .setIfAbsent("lock:" + lockKey, lockValue, lockTtl);
42 
43 if (Boolean.TRUE.equals(acquired)) {
44 try {
45 return action.call();
46 } finally {
47 releaseLock(lockKey, lockValue);
48 }
49 }
50 
51 Thread.sleep(50);
52 }
53 
54 throw new RuntimeException("Lock acquisition timeout for: " + lockKey);
55 }
56 
57 private void releaseLock(String lockKey, String lockValue) {
58 DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_SCRIPT, Long.class);
59 redisTemplate.execute(
60 script,
61 Collections.singletonList("lock:" + lockKey),
62 lockValue
63 );
64 }
65}
66 

Step 4: Product Service with Caching

Combine Spring's @Cacheable annotations with manual cache control:

java
1// src/main/java/com/example/service/ProductService.java
2package com.example.service;
3 
4import com.example.cache.CacheLockService;
5import com.example.model.Product;
6import com.example.model.ProductPage;
7import lombok.RequiredArgsConstructor;
8import lombok.extern.slf4j.Slf4j;
9import org.springframework.cache.annotation.CacheEvict;
10import org.springframework.cache.annotation.Cacheable;
11import org.springframework.cache.annotation.Caching;
12import org.springframework.stereotype.Service;
13 
14import java.math.BigDecimal;
15import java.time.Duration;
16import java.util.ArrayList;
17import java.util.List;
18import java.util.stream.IntStream;
19 
20@Slf4j
21@Service
22@RequiredArgsConstructor
23public class ProductService {
24 
25 private final CacheLockService lockService;
26 
27 // Simulated database
28 private final List<Product> products = new ArrayList<>(
29 IntStream.rangeClosed(1, 100).mapToObj(i ->
30 Product.builder()
31 .id((long) i)
32 .name("Product " + i)
33 .price(BigDecimal.valueOf(9.99 + i * 5.5))
34 .category(new String[]{"electronics", "clothing", "books"}[i % 3])
35 .stock(i * 10)
36 .build()
37 ).toList()
38 );
39 
40 @Cacheable(
41 value = "products",
42 key = "'list:' + #category + ':' + #page + ':' + #perPage"
43 )
44 public ProductPage findAll(String category, int page, int perPage) {
45 log.info("Cache miss — fetching products from database");
46 simulateDbLatency();
47 
48 List<Product> filtered = products;
49 if (category != null && !category.isEmpty()) {
50 filtered = filtered.stream()
51 .filter(p -> p.getCategory().equals(category))
52 .toList();
53 }
54 
55 int start = (page - 1) * perPage;
56 int end = Math.min(start + perPage, filtered.size());
57 List<Product> pageItems = filtered.subList(start, end);
58 
59 return new ProductPage(pageItems, filtered.size(), page, perPage);
60 }
61 
62 @Cacheable(value = "product-detail", key = "#id")
63 public Product findById(Long id) {
64 log.info("Cache miss — fetching product {} from database", id);
65 simulateDbLatency();
66 
67 return products.stream()
68 .filter(p -> p.getId().equals(id))
69 .findFirst()
70 .orElseThrow(() -> new RuntimeException("Product not found: " + id));
71 }
72 
73 public Product findByIdWithLock(Long id) throws Exception {
74 return lockService.executeWithLock(
75 "product:" + id,
76 Duration.ofSeconds(5),
77 Duration.ofSeconds(5),
78 () -> findById(id)
79 );
80 }
81 
82 @Caching(evict = {
83 @CacheEvict(value = "product-detail", key = "#id"),
84 @CacheEvict(value = "products", allEntries = true)
85 })
86 public Product update(Long id, Product updated) {
87 log.info("Updating product {} and invalidating cache", id);
88 
89 int index = products.stream()
90 .map(Product::getId)
91 .toList()
92 .indexOf(id);
93 
94 if (index == -1) {
95 throw new RuntimeException("Product not found: " + id);
96 }
97 
98 updated.setId(id);
99 products.set(index, updated);
100 return updated;
101 }
102 
103 private void simulateDbLatency() {
104 try { Thread.sleep(50); } catch (InterruptedException ignored) {}
105 }
106}
107 

Need a second opinion on your system design architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Step 5: REST Controller

java
1// src/main/java/com/example/controller/ProductController.java
2package com.example.controller;
3 
4import com.example.model.Product;
5import com.example.model.ProductPage;
6import com.example.service.ProductService;
7import lombok.RequiredArgsConstructor;
8import org.springframework.http.ResponseEntity;
9import org.springframework.web.bind.annotation.*;
10 
11@RestController
12@RequestMapping("/api/products")
13@RequiredArgsConstructor
14public class ProductController {
15 
16 private final ProductService productService;
17 
18 @GetMapping
19 public ResponseEntity<ProductPage> findAll(
20 @RequestParam(required = false) String category,
21 @RequestParam(defaultValue = "1") int page,
22 @RequestParam(defaultValue = "20") int perPage
23 ) {
24 return ResponseEntity.ok(productService.findAll(category, page, perPage));
25 }
26 
27 @GetMapping("/{id}")
28 public ResponseEntity<Product> findById(@PathVariable Long id) {
29 return ResponseEntity.ok(productService.findById(id));
30 }
31 
32 @GetMapping("/{id}/locked")
33 public ResponseEntity<Product> findByIdLocked(@PathVariable Long id) throws Exception {
34 return ResponseEntity.ok(productService.findByIdWithLock(id));
35 }
36 
37 @PutMapping("/{id}")
38 public ResponseEntity<Product> update(
39 @PathVariable Long id,
40 @RequestBody Product product
41 ) {
42 return ResponseEntity.ok(productService.update(id, product));
43 }
44}
45 

Step 6: Multi-Level Caching with Caffeine

Add an in-process L1 cache using Caffeine in front of Redis:

java
1// src/main/java/com/example/config/MultiLevelCacheConfig.java
2package com.example.config;
3 
4import com.github.benmanes.caffeine.cache.Caffeine;
5import org.springframework.cache.CacheManager;
6import org.springframework.cache.caffeine.CaffeineCacheManager;
7import org.springframework.context.annotation.Bean;
8import org.springframework.context.annotation.Configuration;
9import org.springframework.context.annotation.Primary;
10import org.springframework.data.redis.cache.RedisCacheManager;
11import org.springframework.data.redis.connection.RedisConnectionFactory;
12 
13import java.time.Duration;
14 
15@Configuration
16public class MultiLevelCacheConfig {
17 
18 @Bean
19 public CacheManager caffeineCacheManager() {
20 CaffeineCacheManager manager = new CaffeineCacheManager();
21 manager.setCaffeine(Caffeine.newBuilder()
22 .maximumSize(10_000)
23 .expireAfterWrite(Duration.ofSeconds(30))
24 .recordStats());
25 return manager;
26 }
27}
28 
java
1// src/main/java/com/example/cache/MultiLevelCacheService.java
2package com.example.cache;
3 
4import lombok.extern.slf4j.Slf4j;
5import org.springframework.beans.factory.annotation.Qualifier;
6import org.springframework.cache.Cache;
7import org.springframework.cache.CacheManager;
8import org.springframework.stereotype.Service;
9 
10import java.util.concurrent.Callable;
11 
12@Slf4j
13@Service
14public class MultiLevelCacheService {
15 
16 private final CacheManager l1CacheManager; // Caffeine
17 private final CacheManager l2CacheManager; // Redis
18 
19 public MultiLevelCacheService(
20 @Qualifier("caffeineCacheManager") CacheManager l1,
21 @Qualifier("cacheManager") CacheManager l2
22 ) {
23 this.l1CacheManager = l1;
24 this.l2CacheManager = l2;
25 }
26 
27 @SuppressWarnings("unchecked")
28 public <T> T get(String cacheName, String key, Class<T> type, Callable<T> loader) {
29 // Check L1
30 Cache l1Cache = l1CacheManager.getCache(cacheName);
31 if (l1Cache != null) {
32 Cache.ValueWrapper l1Value = l1Cache.get(key);
33 if (l1Value != null) {
34 log.debug("L1 cache hit: {}:{}", cacheName, key);
35 return (T) l1Value.get();
36 }
37 }
38 
39 // Check L2
40 Cache l2Cache = l2CacheManager.getCache(cacheName);
41 if (l2Cache != null) {
42 Cache.ValueWrapper l2Value = l2Cache.get(key);
43 if (l2Value != null) {
44 log.debug("L2 cache hit: {}:{}", cacheName, key);
45 T value = (T) l2Value.get();
46 // Populate L1
47 if (l1Cache != null) {
48 l1Cache.put(key, value);
49 }
50 return value;
51 }
52 }
53 
54 // Cache miss — load from source
55 try {
56 T value = loader.call();
57 if (l1Cache != null) l1Cache.put(key, value);
58 if (l2Cache != null) l2Cache.put(key, value);
59 return value;
60 } catch (Exception e) {
61 throw new RuntimeException("Failed to load value for " + key, e);
62 }
63 }
64 
65 public void evict(String cacheName, String key) {
66 Cache l1 = l1CacheManager.getCache(cacheName);
67 Cache l2 = l2CacheManager.getCache(cacheName);
68 if (l1 != null) l1.evict(key);
69 if (l2 != null) l2.evict(key);
70 }
71}
72 

Step 7: Health Check and Monitoring

java
1// src/main/java/com/example/controller/CacheHealthController.java
2package com.example.controller;
3 
4import lombok.RequiredArgsConstructor;
5import org.springframework.data.redis.connection.RedisConnectionFactory;
6import org.springframework.data.redis.core.RedisTemplate;
7import org.springframework.http.ResponseEntity;
8import org.springframework.web.bind.annotation.GetMapping;
9import org.springframework.web.bind.annotation.RestController;
10 
11import java.util.Map;
12import java.util.Properties;
13 
14@RestController
15@RequiredArgsConstructor
16public class CacheHealthController {
17 
18 private final RedisTemplate<String, Object> redisTemplate;
19 private final RedisConnectionFactory connectionFactory;
20 
21 @GetMapping("/health/cache")
22 public ResponseEntity<Map<String, Object>> cacheHealth() {
23 try {
24 long start = System.nanoTime();
25 redisTemplate.opsForValue().get("health:ping");
26 double latencyMs = (System.nanoTime() - start) / 1_000_000.0;
27 
28 Properties info = connectionFactory.getConnection()
29 .serverCommands().info("memory");
30 
31 return ResponseEntity.ok(Map.of(
32 "status", "healthy",
33 "latencyMs", String.format("%.2f", latencyMs),
34 "usedMemory", info.getProperty("used_memory_human", "unknown"),
35 "connectedClients", info.getProperty("connected_clients", "unknown")
36 ));
37 } catch (Exception e) {
38 return ResponseEntity.ok(Map.of(
39 "status", "degraded",
40 "error", e.getMessage()
41 ));
42 }
43 }
44}
45 

Step 8: Testing

java
1// src/test/java/com/example/service/ProductServiceTest.java
2package com.example.service;
3 
4import com.example.model.Product;
5import com.example.model.ProductPage;
6import org.junit.jupiter.api.Test;
7import org.springframework.beans.factory.annotation.Autowired;
8import org.springframework.boot.test.context.SpringBootTest;
9import org.springframework.cache.CacheManager;
10import org.springframework.test.context.DynamicPropertyRegistry;
11import org.springframework.test.context.DynamicPropertySource;
12import org.testcontainers.containers.GenericContainer;
13import org.testcontainers.junit.jupiter.Container;
14import org.testcontainers.junit.jupiter.Testcontainers;
15 
16import static org.assertj.core.api.Assertions.assertThat;
17 
18@SpringBootTest
19@Testcontainers
20class ProductServiceTest {
21 
22 @Container
23 static GenericContainer<?> redis =
24 new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
25 
26 @DynamicPropertySource
27 static void redisProperties(DynamicPropertyRegistry registry) {
28 registry.add("spring.data.redis.host", redis::getHost);
29 registry.add("spring.data.redis.port", redis::getFirstMappedPort);
30 }
31 
32 @Autowired
33 private ProductService productService;
34 
35 @Autowired
36 private CacheManager cacheManager;
37 
38 @Test
39 void findAll_shouldCacheResults() {
40 // First call — cache miss
41 ProductPage result1 = productService.findAll(null, 1, 20);
42 assertThat(result1.getItems()).hasSize(20);
43 assertThat(result1.getTotal()).isEqualTo(100);
44 
45 // Second call — cache hit (no DB latency)
46 long start = System.currentTimeMillis();
47 ProductPage result2 = productService.findAll(null, 1, 20);
48 long elapsed = System.currentTimeMillis() - start;
49 
50 assertThat(result2.getItems()).isEqualTo(result1.getItems());
51 assertThat(elapsed).isLessThan(10); // Cache hit should be < 10ms
52 }
53 
54 @Test
55 void findById_shouldCacheAndEvict() {
56 Product product = productService.findById(1L);
57 assertThat(product.getName()).isEqualTo("Product 1");
58 
59 // Verify cache populated
60 assertThat(cacheManager.getCache("product-detail").get(1L)).isNotNull();
61 
62 // Update triggers eviction
63 productService.update(1L, Product.builder()
64 .name("Updated Product 1")
65 .price(product.getPrice())
66 .category(product.getCategory())
67 .stock(product.getStock())
68 .build());
69 
70 // Cache should be evicted
71 assertThat(cacheManager.getCache("product-detail").get(1L)).isNull();
72 }
73}
74 

Run tests:

bash
mvn test -Dtest=ProductServiceTest

Step 9: Production Deployment

Dockerfile

dockerfile
1FROM eclipse-temurin:21-jre-alpine
2WORKDIR /app
3COPY target/*.jar app.jar
4EXPOSE 8080
5ENTRYPOINT ["java", "-XX:MaxRAMPercentage=75.0", "-jar", "app.jar"]
6 

Redis Tuning for Production

Key Redis configuration for caching workloads:

1maxmemory 1gb
2maxmemory-policy allkeys-lru
3tcp-keepalive 60
4timeout 300
5save ""
6appendonly no
7 

Disabling persistence (save "", appendonly no) is appropriate for pure caching workloads—data can always be regenerated from the source database.

Conclusion

Spring Boot's caching abstraction combined with Redis provides a clean, annotation-driven approach to distributed caching. The @Cacheable, @CacheEvict, and @Caching annotations handle 80% of caching needs with minimal code. For the remaining 20%—stampede protection, multi-level caching, pattern-based invalidation—you build targeted services that integrate naturally with Spring's DI container.

The multi-level approach using Caffeine (L1) and Redis (L2) is particularly effective for Spring Boot applications. Caffeine eliminates network round-trips for hot data, reducing p99 latency from 2ms to 0.1ms. Redis provides the shared cache that keeps all application instances consistent.

Start with Spring's built-in annotations and the Redis cache manager. Add the lock service when you identify hot keys causing database pressure. Introduce Caffeine L1 caching when Redis network latency appears in your performance profiles. Monitor cache hit rates through Actuator metrics and adjust TTLs based on real traffic patterns.

FAQ

Need expert help?

Building with system design?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026