Caching Strategies in Spring Boot
Spring cache abstraction in practice: @Cacheable key design, choosing between Caffeine and Redis, per-cache TTLs, cache stampedes, and a two-level cache that survives a Redis outage.
On this page
Caching is the cheapest performance win available and the easiest way to serve wrong data confidently. The Spring cache abstraction makes the mechanics almost free, which means the interesting decisions are all about keys, invalidation and what happens when the cache itself fails.
Key Takeaways
@Cacheableis proxy-based, so self-invocation silently bypasses it.- Key design is the whole game: keys must be stable, bounded and collision-free.
- Caffeine is a local cache; Redis is a shared one. They solve different problems.
- Always set a TTL and a size bound — an unbounded cache is a memory leak with good PR.
- A cache outage must degrade the application, not break it.
The abstraction
@EnableCaching installs a proxy that consults a CacheManager before invoking the method. Three
annotations cover the operations:
@Cacheable— look up first; on a miss, invoke and store.@CachePut— always invoke, then store the result. For write-through updates.@CacheEvict— remove one entry or all of them.
@Service
public class CatalogService {
@Cacheable(cacheNames = "products", key = "#sku", unless = "#result == null")
public Product findBySku(String sku) {
return repository.findBySku(sku).orElse(null);
}
// Only cache the expensive branch. condition is evaluated before the call,
// unless is evaluated after and can inspect #result.
@Cacheable(cacheNames = "search", key = "#query.cacheKey()",
condition = "#query.limit() <= 50",
unless = "#result.isEmpty()")
public List<Product> search(SearchQuery query) {
return searchEngine.execute(query);
}
@CachePut(cacheNames = "products", key = "#product.sku()")
public Product update(Product product) {
return repository.save(product);
}
@Caching(evict = {
@CacheEvict(cacheNames = "products", key = "#sku"),
@CacheEvict(cacheNames = "search", allEntries = true)
})
public void delete(String sku) {
repository.deleteBySku(sku);
}
}Note the @Caching block on delete. Removing a product invalidates one product entry but every
search result that might have contained it — there is no way to know which, so the whole search cache
goes. That asymmetry is normal and is a good argument for keeping short TTLs on derived caches.
Key design
The default key generator uses all method parameters, which is convenient and occasionally wrong. Be explicit with SpEL:
| Expression | Meaning |
|---|---|
#sku | A named parameter |
#product.sku() | A property of a parameter |
#root.methodName | The method name |
#root.args[0] | Positional argument |
T(java.util.Objects).hash(#a, #b) | A composite |
Three rules that prevent most cache bugs. Keys must be stable — the same logical input must produce the same key across instances and restarts, which rules out anything derived from object identity or the current time. Keys must be bounded — a key derived from a free-text search string will fill the cache with single-use entries. And keys must be complete — if the result depends on the caller's tenant or locale, that must be in the key, or one tenant will read another's data. That last one is a security bug, not a performance bug.
Choosing a cache manager
| Caffeine | Redis | |
|---|---|---|
| Latency | ~100ns | ~0.5–2ms |
| Shared across instances | No | Yes |
| Survives restart | No | Yes |
| Size limit | JVM heap | Redis memory |
| Failure mode | N/A | Network partition |
| Best for | Hot reference data | Sessions, cross-instance consistency |
Per-cache configuration
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
var base = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.prefixCacheNameWith("acme:")
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
// Different data ages at different rates. One global TTL is always
// wrong for something.
Map<String, RedisCacheConfiguration> perCache = Map.of(
"products", base.entryTtl(Duration.ofHours(6)),
"search", base.entryTtl(Duration.ofMinutes(2)),
"fxRates", base.entryTtl(Duration.ofSeconds(30)));
return RedisCacheManager.builder(factory)
.cacheDefaults(base)
.withInitialCacheConfigurations(perCache)
.build();
}
}Prefer JSON serialisation over JDK serialisation. JDK serialisation ties cached entries to the exact class version, so a deploy that adds a field to a cached record makes every existing entry unreadable — and, worse, deserialising untrusted bytes is a well-known remote code execution vector.
Cache stampedes
When a popular key expires, every concurrent request misses simultaneously and hits the database at once. Three defences, which combine well:
sync = trueon@Cacheable. Only one thread computes the value per instance; the rest wait. This is local, so with ten instances you still get ten queries instead of ten thousand.- Jittered TTLs. Adding a random 10% to each entry's TTL prevents everything written at deploy time from expiring in the same second.
- Refresh-ahead. Caffeine's
refreshAfterWritereturns the stale value immediately and refreshes in the background, so no request ever waits for a recomputation.
@Bean
public CaffeineCacheManager caffeineCacheManager() {
var manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.refreshAfterWrite(Duration.ofMinutes(5))
.recordStats());
return manager;
}recordStats() is worth switching on. Micrometer picks it up automatically and gives you hit ratio
per cache, which is the only way to know whether a cache is earning its complexity. A cache with a
20% hit rate is usually costing more than it saves.
Failing gracefully
By default, a Redis outage turns every cached method call into an exception. That is rarely what you
want — the database is still there, and serving slowly beats serving 500.
@Bean
public CacheErrorHandler cacheErrorHandler() {
return new SimpleCacheErrorHandler() {
private final Logger log = LoggerFactory.getLogger("cache");
@Override
public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
log.warn("cache get failed for {}/{} — falling through to source", cache.getName(), key);
// Swallowing means a miss, which means the method runs normally.
}
@Override
public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
log.warn("cache put failed for {}/{}", cache.getName(), key);
}
};
}Pair this with a short Lettuce command timeout. Without one, a partitioned Redis makes every request wait for the default socket timeout, and the cache designed to make you fast becomes the reason you are down.
Transaction awareness
An eviction that runs before the surrounding transaction commits opens a window where another thread
repopulates the cache from the old, uncommitted state. Set
setTransactionAware(true) on the cache manager, or bind the eviction to commit explicitly:
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onProductUpdated(ProductUpdatedEvent event) {
cacheManager.getCache("products").evict(event.sku());
}What to take away
Cache things that are expensive and read far more often than written. Put the tenant and locale in the key. Bound every cache by size and time, record the hit rate so you can tell whether it is working, and make sure a cache outage degrades latency rather than availability.
Frequently Asked Questions
Why is my @Cacheable method still being executed every time?
Caffeine or Redis?
How do I stop a cache from serving stale data after a write?
Related tutorials
- Exception Handling & Error Response DesignA consistent error contract for a Spring Boot API: an exception hierarchy worth having, @ControllerAdvice done properly, RFC 7807 ProblemDetail, and validation errors clients can act on.
- Scheduling & Async ProcessingScheduled tasks and async methods done properly: fixedRate versus fixedDelay, sizing executors, exception handling that does not silently swallow, and distributed locking with ShedLock.
- Spring Boot Testing MasterclassA test strategy that stays fast: when to use @SpringBootTest versus a slice, real databases with Testcontainers and @ServiceConnection, stubbing HTTP with WireMock, and context caching.
- Spring Data JPA Deep DiveEntity mapping that scales: relationship pitfalls, diagnosing and fixing the N+1 problem, derived queries versus Specifications, pagination that stays fast, and JPA auditing.