Caching: @Cacheable, Redis, and the Invalidation Problem
Spring's cache abstraction, swapping ConcurrentMap for Caffeine or Redis without code changes, and the traps that make caching harder than it looks.
The Fastest Query Is the One You Skip
Query tuning has a floor: a well-indexed lookup on a warm database is maybe a millisecond, and you can't do better while still asking the database. Caching removes the question.
It also introduces a second copy of the truth, which is the entire difficulty. A cache is a correctness liability traded for latency, and the trade is only worth making deliberately — on data that is read far more than written, and where being slightly stale is acceptable.
The Abstraction
Spring's caching is annotation-driven and provider-agnostic:
@Configuration
@EnableCaching
public class CacheConfig { }@Service
public class ProductService {
@Cacheable("products")
public Product findById(Long id) {
return repository.findById(id).orElseThrow(); // runs only on a miss
}
@CachePut(value = "products", key = "#product.id")
public Product update(Product product) {
return repository.save(product); // always runs, then caches
}
@CacheEvict(value = "products", key = "#id")
public void delete(Long id) {
repository.deleteById(id); // removes the entry
}
@CacheEvict(value = "products", allEntries = true)
public void reindexAll() { } // clears the cache
}| Annotation | Method runs? | Cache effect |
|---|---|---|
@Cacheable | Only on a miss | Stores the result |
@CachePut | Always | Overwrites the entry |
@CacheEvict | Always | Removes entry (or all) |
@Caching | — | Combines several of the above |
This is the cache-aside pattern: check the cache, fall through to the source on a miss, store the result. Spring implements it so you don't hand-roll it.
Caching is the same proxy mechanism as @Transactional, so it inherits the same two limitations from the transactions guide: a self-invocation is not cached, and a non-public method is not advised. A @Cacheable method called from within the same bean silently queries every time.
Keys
By default the key is derived from all method parameters. Fine for findById(Long), wrong as soon as a parameter doesn't belong in the key:
@Cacheable(value = "products", key = "#id")
public Product findById(Long id, boolean includeArchived) { } // key ignores the flag — a bugTwo calls differing only in includeArchived collide. Either include it or don't cache the method.
SpEL gives you control:
@Cacheable(value = "products", key = "#id")
@Cacheable(value = "products", key = "#product.id")
@Cacheable(value = "products", key = "#root.methodName + ':' + #id")
@Cacheable(value = "search", key = "#criteria.sku + ':' + #criteria.status")And conditions decide whether to engage the cache at all:
@Cacheable(value = "products", condition = "#id != null", unless = "#result == null")
public Product findById(Long id) { }condition is evaluated before the call (so it can't see the result); unless is evaluated after (so it can). unless = "#result == null" is the common one — not caching misses.
Caching null is how you cache a bug. Without unless = "#result == null", a lookup for a product that doesn't exist yet caches the absence; create the product and the cache keeps saying it isn't there until the TTL expires. The inverse — deliberately caching negatives to absorb lookups for keys that don't exist — is a valid technique, but it needs a short dedicated TTL, not your default one.
Choosing a Provider
Spring's abstraction means the provider is a dependency and some configuration, not a code change.
No provider on the classpath gives you ConcurrentMapCacheManager — an unbounded ConcurrentHashMap per cache. Zero setup, no eviction, no TTL, no size limit.
The default cache manager has no maximum size and no expiry. Every distinct key is retained for the life of the process. Cache something unbounded — a per-user key, a search string — and it is a memory leak that ends in OutOfMemoryError. Acceptable for a fixed, small keyspace; never for user input.
Caffeine — the right in-process cache. Bounded, expiring, and fast:
dependencies {
implementation 'com.github.ben-manes.caffeine:caffeine'
}spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStatsRedis — a shared cache across instances:
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
}spring:
cache:
type: redis
redis:
time-to-live: 10m
cache-null-values: false
data:
redis:
host: redis.internal
port: 6379Which one
| Caffeine | Redis | |
|---|---|---|
| Latency | Nanoseconds | ~1ms network |
| Shared across instances | No | Yes |
| Survives restart | No | Yes |
| Eviction can be coordinated | No | Yes |
| Extra infrastructure | None | A Redis to operate |
The deciding question is whether a stale entry on one instance is acceptable. With Caffeine and four instances you have four independent caches: evicting on the instance that handled the write leaves three serving stale data until their TTLs expire. For reference data that changes daily, fine. For anything a user can change and immediately re-read, not fine — they'll see their own edit vanish depending on which instance answers.
Redis costs a network hop and a service to run, and gives one coherent view. A common arrangement is both: Caffeine for small, hot, immutable data; Redis for everything shared.
Check yourself
A service runs on four instances with Caffeine caching of user profiles. A user updates their profile; the handling instance evicts its entry. The user refreshes and sees the old data intermittently. Why?
Serialisation on Redis
Redis stores bytes, so entries must be serialised. The default is JDK serialisation, which is a poor choice: unreadable in redis-cli, requires Serializable, and breaks when a class changes shape. Use JSON:
@Bean
RedisCacheConfiguration cacheConfiguration() {
return RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()));
}Per-cache TTLs, since one value rarely suits everything:
@Bean
RedisCacheManagerBuilderCustomizer cacheCustomizer() {
return builder -> builder
.withCacheConfiguration("products",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("stockLevels",
RedisCacheConfiguration.defaultCacheConfig().entryTtl(Duration.ofSeconds(30)));
}Never cache JPA entities. A managed entity carries proxies and persistence-context state that don't serialise meaningfully, and a deserialised entity is detached in a way that produces LazyInitializationException or silent write failures. Cache DTOs — the same records you return from your API. This is the entity/DTO split from the Jackson guide paying off a third time.
What Actually Goes Wrong
Invalidation is the hard part. Every cached value has a second place it can be wrong. A write path that forgets its @CacheEvict produces a bug that is invisible in tests (where caching is usually off) and intermittent in production (until the TTL saves you).
The mitigation is to always set a TTL, even when you evict explicitly. Eviction is the fast path; TTL is the backstop for the eviction you forgot. An entry with no TTL and a missed eviction is wrong forever.
The thundering herd. A popular key expires, and every concurrent request misses simultaneously and hits the database together — a traffic spike precisely because the cache was working. @Cacheable(sync = true) makes concurrent misses for the same key wait for one computation:
@Cacheable(value = "products", sync = true)
public Product findById(Long id) { }Supported by Caffeine; not by every provider. On Redis, probabilistic early refresh or a short lock is the usual approach.
Caching writes. @Cacheable on a method with side effects means the side effects stop happening on a hit. Cache reads only.
Caching the wrong granularity. Caching findAll() under one key means any single change invalidates everything. Cache individual entities by ID and compose lists from them, or accept coarse invalidation knowingly.
Measure It
A cache with a poor hit rate is pure overhead — an extra lookup before the work you were going to do anyway. Measure:
spring:
cache:
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m,recordStatsWith Actuator and Micrometer, cache.gets (tagged result=hit|miss), cache.puts, cache.evictions and cache.size appear in your metrics. recordStats is required for Caffeine to report them.
What the numbers tell you:
- Hit rate below ~50% — the keyspace is too large, the TTL too short, or the data isn't read-heavy. Possibly not worth caching.
- Evictions climbing with a low hit rate — the cache is too small; entries are gone before reuse.
- Hit rate near 100% with few evictions — working. Consider a longer TTL.
Check yourself
A @Cacheable method with a 10-minute TTL caches search results keyed on a free-text query string. The hit rate is 3% and heap usage has grown steadily. What is the diagnosis?
When Not to Cache
Worth stating plainly, because caching is often reached for too early:
- Before measuring. A 2ms query called twice a minute needs no cache.
- Write-heavy data. Each write invalidates, so the cache rarely serves anything.
- Unbounded keyspaces. As above.
- When staleness is unacceptable. Account balances, stock levels at checkout, permissions. Here caching is a correctness bug waiting for the right timing.
- To hide a missing index. Fix the query. A cache over an unindexed query means every miss is still slow, and cold starts are brutal.
The Mental Model, Restated
- A cache is a second copy of the truth — latency bought with a correctness liability.
@EnableCachingplus@Cacheable/@CachePut/@CacheEvict, with the same proxy limits as@Transactional.- Set keys deliberately, and
unless = "#result == null"so misses aren't cached. - The default cache manager is unbounded. Use Caffeine or Redis, never the default in production.
- In-process caches don't share eviction. Redis when instances must agree.
- Cache DTOs, never entities.
- Always set a TTL, even with explicit eviction — it's the backstop.
- Measure the hit rate. Below 50%, reconsider.
What's Next
Caching reduces how often you reach the database. The final guide in this phase covers the resource that every remaining query contends for: the connection pool. HikariCP's defaults, what each timeout means, why a bigger pool is usually slower, and how to see pool exhaustion before it becomes an outage.