A Spring Boot 4.1.1 module whose test suite is the evidence for the article: 20 tests producing 22 transcripts under docs/output/, plus 12 documentation chapters. Findings the build pins: - @EnableCaching has no exposeProxy attribute; the widely-copied @EnableCaching(exposeProxy = true) does not compile. - Two methods sharing a cache name and an argument type share a key space, and one silently serves the other's answers. - The documented cache-provider detection order does not match CacheType's enum order in 4.1.1: COUCHBASE before INFINISPAN, and CACHE2K before CAFFEINE. - beforeInvocation = true is NOT deferred by TransactionAwareCacheManagerProxy on 7.0.9 - doEvict picks evictIfPresent, which the decorator does not intercept. - Four of five invalid declarations start a clean context and throw at the first call. - Caffeine on the classpath silently displaces the simple provider.
3.1 KiB
← keys · next: conditions and nulls →
5. Eviction, and when it happens
@CacheEvict defaults to beforeInvocation = false, which means: evict after the method
returns normally. A method that throws does not evict.
From docs/output/08-evict-timing.txt:
price("sku-1") -> 100 (stored price is 100)
updatePrice("sku-1", 250, fail=true) threw after writing the new price.
stored price now : 250
price("sku-1") : 100 <- the cache still serves the old value
The write to the real store happened. The exception came afterwards. The eviction did not run, so the cache is now authoritative for a value that no longer exists anywhere else. Nothing will correct it: the next reader gets 100, and the one after that, until something evicts the entry or the process restarts.
beforeInvocation = true makes the same failure harmless:
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2") : 250 <- the entry went first, so the next read is honest
Choosing between the three shapes
| Shape | Cost | Correct when the write fails? |
|---|---|---|
@CacheEvict (default) |
one miss on the next read | no — stale entry survives |
@CacheEvict(beforeInvocation = true) |
one miss, plus a window where two callers can both miss | yes |
@CachePut |
no miss at all | no — and it writes a value the store may not have |
@CachePut is the fastest and the most dangerous: it puts your computed value into the cache
without reading the store back, so any transformation the database applies — a trigger, a
default, a truncation, a generated column — is invisible to every subsequent reader. Use it when
the method's return value is definitively the new state.
beforeInvocation = true is the right default for anything that mutates. The extra miss costs
one lookup; the alternative costs an incident.
allEntries = true
Clears the region in a single operation rather than key by key, which matters when the region is large or remote. Two things to know:
- It is a blunt instrument in a shared cache: one bulk import evicts everything every other caller warmed.
- On a distributed provider
clear()may be an O(n) scan or a whole-keyspace operation. Check what your provider does before putting it on a frequently called method.
Errors from the cache itself
If the cache provider throws — a Redis timeout, a serialization failure — the default
SimpleCacheErrorHandler rethrows, so a cache outage becomes an application outage. A
CacheErrorHandler registered through CachingConfigurer can log and continue instead:
@Configuration
@EnableCaching
class CacheConfig implements CachingConfigurer {
@Override
public CacheErrorHandler errorHandler() {
return new SimpleCacheErrorHandler() { /* log and swallow get/put/evict errors */ };
}
}
That is the right call for a read-through cache in front of a database, and the wrong call when the eviction is what keeps two systems consistent — a swallowed evict error is a permanently stale entry. Decide per cache, not per application.