Caching - Spring Boot Multi-Layer Approach
Caching is one of the most effective optimization techniques that dramatically improves system performance. By temporarily storing data in fast-accessible locations, it minimizes expensive computation and I/O operations. Spring Boot provides comprehensive support for multi-layered caching strategies.
Quick Decision
| Situation | Use Cache | Watch Out |
|---|---|---|
| Data is read often and changes rarely | Yes | TTL and invalidation must be clear |
| Computation is expensive | Yes | Cache key must be designed correctly |
| Data varies by authorization | Be careful | Cross-user leakage risk |
| Strong correctness after write | Limited | Stale reads may be unacceptable |
Production Checklist
- Problem: Which expensive operation does the cache reduce?
- Solution: Are key, TTL, invalidation, warmup, and fallback behavior clear?
- Trade-off: Latency drops; inconsistency, memory, and operational cost are added.
- Failure mode: Cache stampede, hot keys, stale data, eviction storms, and Redis outages should be handled.
- Measurement: Track hit ratio, miss latency, eviction count, memory usage, and backend load.
- Security/cost: Tenant/user separation is required when caching authorized data; large caches create memory cost.
Cache Architecture Overview
Cache Patterns
Cache Pattern Descriptions
Cache-Aside (Lazy Loading)
- Application manually manages the cache
- Automatic data loading on cache miss
- Enables selective caching
Write-Through
- Synchronous write to both cache and database
- Guarantees data consistency
- Requires two operations for each write
Write-Behind (Write-Back)
- Immediate write to cache, asynchronous write to database
- Provides high performance
- Risk of data loss
Distributed Cache Architecture
Application-Level Caching
Spring Cache Abstraction
Spring Cache Abstraction enables managing caching operations through declarative annotations. This approach supports clean code principles by separating caching concerns from business logic.
Key Features:
- @Cacheable: Caches method results
- @CacheEvict: Removes data from cache
- @CachePut: Updates cache on every call
- @Caching: Groups multiple cache annotations
- Conditional caching: Enables conditional caching capabilities
Cache Provider Comparison:
- Redis: Ideal for distributed caching, persistence support
- Hazelcast: In-memory data grid, clustering capabilities
- Caffeine: High-performance local cache, advanced version of Guava
- EhCache: Both local and distributed cache support
Basic Cache Configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
RedisCacheManager.Builder builder = RedisCacheManager
.RedisCacheManagerBuilder
.fromConnectionFactory(redisConnectionFactory())
.cacheDefaults(cacheConfiguration());
return builder.build();
}
@Bean
public RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer()));
}
}Cache Annotations
@Service
public class UserService {
@Cacheable(value = "users", key = "#userId")
public User getUserById(Long userId) {
log.info("Fetching user from database: {}", userId);
return userRepository.findById(userId).orElse(null);
}
@CachePut(value = "users", key = "#user.id")
public User updateUser(User user) {
User updatedUser = userRepository.save(user);
log.info("User updated and cached: {}", updatedUser.getId());
return updatedUser;
}
@CacheEvict(value = "users", key = "#userId")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
log.info("User deleted and evicted from cache: {}", userId);
}
@CacheEvict(value = "users", allEntries = true)
public void clearAllUsers() {
log.info("All users evicted from cache");
}
}Redis Distributed Caching
Redis Configuration
@Configuration
public class RedisConfig {
@Bean
public LettuceConnectionFactory redisConnectionFactory() {
RedisStandaloneConfiguration config = new RedisStandaloneConfiguration();
config.setHostName("localhost");
config.setPort(6379);
config.setPassword("password");
LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder()
.commandTimeout(Duration.ofSeconds(5))
.poolConfig(connectionPoolConfig())
.build();
return new LettuceConnectionFactory(config, clientConfig);
}
@Bean
public RedisTemplate<String, Object> redisTemplate() {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory());
// JSON serialization
GenericJackson2JsonRedisSerializer serializer = new GenericJackson2JsonRedisSerializer();
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
return template;
}
private GenericObjectPoolConfig<?> connectionPoolConfig() {
GenericObjectPoolConfig<?> config = new GenericObjectPoolConfig<>();
config.setMaxTotal(20);
config.setMaxIdle(10);
config.setMinIdle(5);
return config;
}
}Manual Cache Operations
@Service
public class CacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public void cacheObject(String key, Object value, Duration ttl) {
redisTemplate.opsForValue().set(key, value, ttl);
}
public <T> T getCachedObject(String key, Class<T> type) {
Object cached = redisTemplate.opsForValue().get(key);
return type.cast(cached);
}
public void evictFromCache(String key) {
redisTemplate.delete(key);
}
public void cacheList(String key, List<Object> list, Duration ttl) {
redisTemplate.opsForList().rightPushAll(key, list.toArray());
redisTemplate.expire(key, ttl);
}
public Set<Object> getCachedSet(String key) {
return redisTemplate.opsForSet().members(key);
}
}Cache Patterns
Cache-Aside Pattern
@Service
public class ProductService {
@Autowired
private ProductRepository productRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public Product getProduct(Long productId) {
String cacheKey = "product:" + productId;
// Check cache first
Product cachedProduct = (Product) redisTemplate.opsForValue().get(cacheKey);
if (cachedProduct != null) {
return cachedProduct;
}
// Fetch from database
Product product = productRepository.findById(productId).orElse(null);
if (product != null) {
// Add to cache
redisTemplate.opsForValue().set(cacheKey, product, Duration.ofMinutes(30));
}
return product;
}
public Product updateProduct(Product product) {
// Update database
Product updatedProduct = productRepository.save(product);
// Update cache
String cacheKey = "product:" + product.getId();
redisTemplate.opsForValue().set(cacheKey, updatedProduct, Duration.ofMinutes(30));
return updatedProduct;
}
}Write-Through Pattern
@Service
public class WriteThoughCacheService {
@Autowired
private UserRepository userRepository;
@Autowired
private RedisTemplate<String, Object> redisTemplate;
public User saveUser(User user) {
// Write to database
User savedUser = userRepository.save(user);
// Write to cache (write-through)
String cacheKey = "user:" + savedUser.getId();
redisTemplate.opsForValue().set(cacheKey, savedUser, Duration.ofHours(1));
return savedUser;
}
}Write-Behind (Write-Back) Pattern
@Service
public class WriteBehindCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Autowired
private UserRepository userRepository;
private final Queue<User> writeQueue = new ConcurrentLinkedQueue<>();
public User updateUser(User user) {
// Update cache immediately
String cacheKey = "user:" + user.getId();
redisTemplate.opsForValue().set(cacheKey, user, Duration.ofHours(1));
// Queue database write operation
writeQueue.offer(user);
return user;
}
@Scheduled(fixedDelay = 5000) // Every 5 seconds
public void flushToDatabase() {
List<User> usersToWrite = new ArrayList<>();
User user;
while ((user = writeQueue.poll()) != null) {
usersToWrite.add(user);
}
if (!usersToWrite.isEmpty()) {
userRepository.saveAll(usersToWrite);
log.info("Flushed {} users to database", usersToWrite.size());
}
}
}Multi-Level Caching
Multi-Level Cache Features
L1 Cache (Local Memory)
- Fastest access time
- Application instance specific
- Limited memory capacity
L2 Cache (Distributed)
- Cluster-wide sharing
- Higher capacity
- Network latency impact
Database
- Used as last resort
- Complete data consistency
- Slowest access time
L1 (Local) + L2 (Distributed) Cache
@Service
public class MultiLevelCacheService {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
// L1 Cache - Local in-memory cache
private final ConcurrentHashMap<String, CacheItem> localCache = new ConcurrentHashMap<>();
public User getUser(Long userId) {
String key = "user:" + userId;
// L1 Cache check
CacheItem item = localCache.get(key);
if (item != null && !item.isExpired()) {
return (User) item.getValue();
}
// L2 Cache check (Redis)
User user = (User) redisTemplate.opsForValue().get(key);
if (user != null) {
localCache.put(key, new CacheItem(user, Duration.ofMinutes(5)));
return user;
}
// Fetch from database
user = userRepository.findById(userId).orElse(null);
if (user != null) {
// Add to both caches
redisTemplate.opsForValue().set(key, user, Duration.ofMinutes(30));
localCache.put(key, new CacheItem(user, Duration.ofMinutes(5)));
}
return user;
}
@Scheduled(fixedDelay = 60000) // Every minute
public void cleanupLocalCache() {
localCache.entrySet().removeIf(entry -> entry.getValue().isExpired());
}
private static class CacheItem {
private final Object value;
private final Instant expiry;
public CacheItem(Object value, Duration ttl) {
this.value = value;
this.expiry = Instant.now().plus(ttl);
}
public Object getValue() { return value; }
public boolean isExpired() { return Instant.now().isAfter(expiry); }
}
}Cache Invalidation Strategies
Cache Invalidation Strategies
Event-Based Invalidation
- Automatic triggering on data changes
- Integration with event-driven architecture
- Selective cache clearing
Time-Based Invalidation
- TTL (Time-To-Live) based clearing
- Automatic cache refresh
- Simple and effective management
Manual Invalidation
- Admin control
- Emergency intervention
- Selective cache management
CDN Integration
Static Content Caching
@RestController
public class StaticContentController {
@GetMapping(value = "/images/{filename}", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public ResponseEntity<Resource> getImage(@PathVariable String filename) {
try {
Resource resource = resourceLoader.getResource("classpath:static/images/" + filename);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofDays(7))) // Cache for 7 days
.eTag(calculateETag(resource))
.body(resource);
} catch (Exception e) {
return ResponseEntity.notFound().build();
}
}
@GetMapping("/api/products/{id}")
public ResponseEntity<Product> getProduct(@PathVariable Long id) {
Product product = productService.getProduct(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(10)))
.eTag(String.valueOf(product.getLastModified().hashCode()))
.body(product);
}
}API Response Caching
@RestController
public class CachedApiController {
@GetMapping("/api/popular-products")
@Cacheable(value = "popular-products", unless = "#result.isEmpty()")
public ResponseEntity<List<Product>> getPopularProducts() {
List<Product> products = productService.getPopularProducts();
return ResponseEntity.ok()
.cacheControl(CacheControl.maxAge(Duration.ofMinutes(15)))
.body(products);
}
@GetMapping("/api/categories")
public ResponseEntity<List<Category>> getCategories(HttpServletRequest request) {
String etag = categoryService.getCategoriesETag();
// ETag check
if (request.getHeader("If-None-Match") != null &&
request.getHeader("If-None-Match").equals(etag)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
}
List<Category> categories = categoryService.getAllCategories();
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(Duration.ofHours(1)))
.body(categories);
}
}Performance Monitoring
Cache Metrics
@Component
public class CacheMetrics {
private final MeterRegistry meterRegistry;
private final Counter cacheHits;
private final Counter cacheMisses;
private final Timer cacheLoadTime;
public CacheMetrics(MeterRegistry meterRegistry) {
this.meterRegistry = meterRegistry;
this.cacheHits = Counter.builder("cache.hits")
.tag("cache", "users")
.register(meterRegistry);
this.cacheMisses = Counter.builder("cache.misses")
.tag("cache", "users")
.register(meterRegistry);
this.cacheLoadTime = Timer.builder("cache.load.time")
.register(meterRegistry);
}
public void recordCacheHit(String cacheName) {
cacheHits.increment(Tags.of("cache", cacheName));
}
public void recordCacheMiss(String cacheName) {
cacheMisses.increment(Tags.of("cache", cacheName));
}
public void recordCacheLoadTime(Duration duration) {
cacheLoadTime.record(duration);
}
}Cache Health Monitoring
@Component
public class CacheHealthIndicator implements HealthIndicator {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public Health health() {
try {
redisTemplate.opsForValue().set("health-check", "ping", Duration.ofSeconds(10));
String response = (String) redisTemplate.opsForValue().get("health-check");
if ("ping".equals(response)) {
return Health.up()
.withDetail("redis", "Available")
.withDetail("responseTime", measureResponseTime() + "ms")
.build();
} else {
return Health.down()
.withDetail("redis", "Invalid response")
.build();
}
} catch (Exception e) {
return Health.down()
.withDetail("redis", "Unavailable")
.withException(e)
.build();
}
}
private long measureResponseTime() {
long start = System.currentTimeMillis();
redisTemplate.opsForValue().get("health-check");
return System.currentTimeMillis() - start;
}
}Production Best Practices
Cache Warming
@Component
public class CacheWarmupService {
@Autowired
private ProductService productService;
@Autowired
private UserService userService;
@EventListener(ApplicationReadyEvent.class)
public void warmupCache() {
log.info("Starting cache warmup...");
CompletableFuture.runAsync(this::warmupPopularProducts);
CompletableFuture.runAsync(this::warmupFrequentlyAccessedUsers);
log.info("Cache warmup initiated");
}
private void warmupPopularProducts() {
List<Long> popularProductIds = getPopularProductIds();
popularProductIds.forEach(productService::getProduct);
log.info("Warmed up {} popular products", popularProductIds.size());
}
private void warmupFrequentlyAccessedUsers() {
List<Long> frequentUserIds = getFrequentlyAccessedUserIds();
frequentUserIds.forEach(userService::getUserById);
log.info("Warmed up {} frequently accessed users", frequentUserIds.size());
}
}Cache Disaster Recovery
@Service
public class CacheDisasterRecoveryService {
@Autowired
private RedisTemplate<String, Object> primaryRedis;
@Autowired
private RedisTemplate<String, Object> backupRedis;
@Retryable(value = Exception.class, maxAttempts = 3)
public Object getCachedValue(String key) {
try {
return primaryRedis.opsForValue().get(key);
} catch (Exception e) {
log.warn("Primary Redis failed, trying backup: {}", e.getMessage());
return backupRedis.opsForValue().get(key);
}
}
@Async
public void syncCaches() {
Set<String> keys = primaryRedis.keys("*");
for (String key : keys) {
try {
Object value = primaryRedis.opsForValue().get(key);
Long ttl = primaryRedis.getExpire(key);
if (ttl > 0) {
backupRedis.opsForValue().set(key, value, Duration.ofSeconds(ttl));
}
} catch (Exception e) {
log.error("Failed to sync cache key {}: {}", key, e.getMessage());
}
}
}
}These caching strategies significantly improve system performance and reduce database load. With Redis distributed caching, multi-level cache structures, and proper invalidation strategies, they provide production-ready caching solutions.
Cache-Through and Database-Through Terminology
“Cache-through” should not be treated as one precise pattern name; describe the flow explicitly:
| Pattern | Read/write flow | Source of truth |
|---|---|---|
| Cache-aside | Application reads the DB on a miss and fills the cache | DB |
| Read-through | Cache provider calls a DB loader on a miss | DB |
| Write-through | A write through the cache synchronously updates the DB | DB |
| Write-behind | Cache is updated first and the DB is updated asynchronously | DB, delayed |
| Direct DB / DB-through | Cache is bypassed and the DB is read or written directly | DB |
The cache is a speed layer. Unless explicitly designed otherwise, the database remains the source of truth. Document invalidation, TTL, stale reads, stampedes, hot keys, and failover behavior with the selected pattern.
Pattern Selection Check
- Which layer calls the DB on a cache miss?
- Can a response be returned before the DB succeeds?
- Does a failed cache write make the mutation fail?
- Are duplicate writes and retries idempotent?
- Can the origin handle traffic if the cache disappears?
