[← versus Hibernate L2](09-versus-hibernate-l2.md) · [next: diagnostics →](11-diagnostics.md) # 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: ```java @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](https://ankurm.com/transactional-propagation-isolation-silent-failures/) covers the interceptor ordering from the transaction side.