Files
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

97 lines
3.9 KiB
Markdown

[← 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.