Files
spring-boot-demo/caching/docs/10-transactions.md
T
asmhatre a9867c0423 Add caching: the Spring cache abstraction, keys, eviction timing and the self-invocation trap
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.
2026-09-12 05:35:13 +00:00

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 @Cacheable lookup 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:

  1. Evict, do not put — @CacheEvict(beforeInvocation = true).
  2. Make the cache transaction-aware if you also need the eviction not to happen on rollback.
  3. 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.