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.9 KiB
← versus Hibernate L2 · next: diagnostics →
10. Caching and transactions
The ordering
The caching interceptor runs inside the transaction interceptor. A cache write therefore happens at method exit — before the commit, and without any knowledge of whether the commit will succeed.
docs/output/13-transactions.txt puts an ordinary write-through update inside an outer
transaction that then fails:
nameOf(1) -> Alice
An outer @Transactional method calls the @CachePut update, which succeeds,
and then fails on the next step. The transaction rolls back.
what the cache serves : Alice Cooper
what the database has : Alice
The cache is now holding a name no transaction ever committed. Nothing will correct it until the entry expires or something evicts it — and on the default provider nothing expires.
The same shape with @CacheEvict is self-healing:
after the rollback, nameOf(2) -> Bob
database loads: 2 <- the entry was evicted, so this one reloaded
That asymmetry is the practical takeaway. An eviction that fires too early costs a lookup; a put that fires too early costs correctness. When in doubt, evict.
TransactionAwareCacheManagerProxy
Wrapping the manager defers every put and evict to a post-commit synchronisation:
@Bean
CacheManager cacheManager() {
return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
}
docs/output/19-transaction-aware.txt runs the identical rollback and the cache still says
Alice. Several CacheManager implementations expose the same thing as
setTransactionAware(true) (they extend AbstractTransactionSupportingCacheManager);
TransactionAwareCacheManagerProxy is the generic wrapper, and it lives in
spring-context-support, not spring-context.
What it does not cover
- Reads are never deferred. A
@Cacheablelookup inside the transaction sees whatever the shared cache holds. - Outside a transaction it is a pass-through, so the same method reached from an unmanaged path writes immediately.
- It does not make the cache transactional. Two concurrent transactions still race at commit time; last writer wins, and it may be the one whose value is older.
A correction worth recording
spring-framework#23192, "@CacheEvict beforeInvocation with transaction does not work", reported
that beforeInvocation = true was swallowed by the transaction-aware decorator — the evict got
deferred to commit, which is the opposite of what the attribute asks for. It is still quoted as
current behaviour. The issue is closed against milestone 5.2 RC1, and on 7.0.9 it does not
reproduce. The measurement in
docs/output/19-transaction-aware.txt came back the other way round, and
docs/output/22-decorator-bytecode.txt shows why:
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
AbstractCacheInvoker.doEvict(Cache, Object, boolean) picks evictIfPresent for the immediate
path, and TransactionAwareCacheDecorator only registers a synchronisation in evict.
evictIfPresent delegates straight to the target cache.
This was written the wrong way round first and the run corrected it, which is a reasonable advertisement for running the thing.
The pattern that actually holds up
For anything that must not serve uncommitted state:
- Evict, do not put —
@CacheEvict(beforeInvocation = true). - Make the cache transaction-aware if you also need the eviction not to happen on rollback.
- Give every mutable cache a TTL, so the worst case is bounded even when all of the above is wrong.
Related reading: @Transactional: propagation, isolation and the silent
failures covers the
interceptor ordering from the transaction side.