Spring Boot 4Production & Microservices
Caching with Spring Cache & Redis
@Cacheable, @CachePut and @CacheEvict, cache keys, Caffeine vs Redis, TTLs, serialization and the cache stampede problem.
Spring Boot
The cache abstraction
Spring’s cache abstraction adds caching to methods declaratively, independent of the storage technology. Like @Transactional, it’s implemented with AOP proxies.
@Configuration
@EnableCaching
class CacheConfig {}
@Service
class ProductService {
@Cacheable(cacheNames = "products", key = "#sku")
public ProductView get(String sku) { // runs only on a cache miss
return repository.findBySku(sku).map(ProductView::from).orElseThrow();
}
@CachePut(cacheNames = "products", key = "#result.sku") // always runs, refreshes the entry
public ProductView update(String sku, UpdateProduct cmd) { ... }
@CacheEvict(cacheNames = "products", key = "#sku") // remove the entry
public void delete(String sku) { ... }
@CacheEvict(cacheNames = "products", allEntries = true)
@Scheduled(cron = "0 0 3 * * *")
public void nightlyFlush() {}
}
sequenceDiagram
participant C as Caller
participant P as ProductService proxy
participant K as Cache
participant S as ProductService
participant DB as Database
C->>P: get("BOOK-1")
P->>K: lookup products::BOOK-1
alt hit
K-->>P: ProductView
else miss
P->>S: get("BOOK-1")
S->>DB: SELECT …
DB-->>S: row
S-->>P: ProductView
P->>K: put products::BOOK-1
end
P-->>C: ProductView
Key options
@Cacheable(cacheNames = "prices", key = "#sku + ':' + #currency")
@Cacheable(cacheNames = "quotes", key = "#cart.id", condition = "#cart.items.size() < 50", unless = "#result == null")
@Cacheable(cacheNames = "rates", sync = true) // only one thread computes a missing entry (per JVM)
The default key is built from all method parameters — make sure they have sensible equals/hashCode (records are ideal).
Choosing a provider
| Provider | Scope | Strengths | Watch out for |
|---|---|---|---|
| Caffeine | In-process (per instance) | Nanosecond reads, size/TTL eviction, no network | Each instance has its own copy; inconsistent across pods |
| Redis | Shared, out-of-process | Consistent across instances, survives restarts, large capacity | Network hop (~0.5–1 ms), serialization, another system to run |
| Two-level (Caffeine + Redis) | Both | Hot data local, shared invalidation | Complexity |
Boot picks a provider automatically from the classpath (spring-boot-starter-cache + Caffeine, or spring-boot-starter-data-redis).
Caffeine
spring:
cache:
cache-names: products, prices
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStats
Redis with per-cache TTLs
spring:
data:
redis:
host: redis
cache:
redis:
time-to-live: 10m
key-prefix: "shop:"
@Bean
RedisCacheManagerBuilderCustomizer cacheTtls() {
return builder -> builder
.withCacheConfiguration("products", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("prices", RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofMinutes(1)));
}
Pitfalls
- Self-invocation — calling a
@Cacheablemethod from the same bean bypasses the cache (proxy). - Stale data — every write path must evict or update; prefer short TTLs over complex invalidation.
- Cache stampede — a hot key expires and hundreds of requests hit the database at once. Mitigate with
sync = true(per instance), jittered TTLs, refresh-ahead (CaffeinerefreshAfterWrite), or a distributed lock. - Caching nulls / errors — use
unless = "#result == null"if absence shouldn’t be cached. - Unbounded caches — always set a maximum size or TTL.
Finished this lesson?
Mark it complete to track your progress through Spring Boot 4.