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: 19 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.
- 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.
This commit is contained in:
2026-09-12 05:19:22 +00:00
parent 7e1676c763
commit 66208bcd97
76 changed files with 3710 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
# A cache hit is a method that did not run
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
repository latency : 200 ms per lookup
--- first call (miss) ---
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed : 200 ms
repository calls : 1
--- second call (hit) ---
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
elapsed : 0 ms
repository calls : 1 <- still 1, the method body never ran
same object? : true
@@ -0,0 +1,11 @@
# @Cacheable, @CachePut and @CacheEvict on the same cache
after findBook : repository calls = 1
@CachePut wrote : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
next findBook returns : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
repository calls : 1 <- @CachePut refreshed the entry, no reload
after @CacheEvict : findBook -> Book[isbn=978-0134685991, title=Effective Java, year=2018]
repository calls : 2 <- the entry was gone, so the method ran again
after allEntries=true : repository calls = 5 <- both entries were dropped
@@ -0,0 +1,13 @@
# Four ways to call a @Cacheable method, one of which caches nothing
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
is an AOP proxy? : true
is a CGLIB proxy? : true
target class : com.ankurm.caching.selfinvocation.CatalogService
Four ISBNs, two of them repeats. A working cache does 2 lookups, not 4.
this.lookup(..) -> 4 repository calls <- no caching at all
self.getObject().lookup(..) -> 2 repository calls
AopContext.currentProxy() -> 2 repository calls
a second bean calls lookup(..) -> 2 repository calls
@@ -0,0 +1,13 @@
# Two more places the annotation is ignored without a warning
@Cacheable on a protected method, called twice -> 2 repository calls
No warning is logged. In proxy mode the annotation is only honoured on
public methods; a protected one is simply never advised.
--- @EnableCaching attributes, as the class file declares them ---
AdviceMode mode()
int order()
boolean proxyTargetClass()
There is no exposeProxy attribute, so @EnableCaching(exposeProxy = true)
- which a lot of answers recommend - does not compile.
+14
View File
@@ -0,0 +1,14 @@
# What SimpleKeyGenerator actually puts in the map
cache "shapes" after three calls with 0, 1 and 2 arguments:
cache "shapes":
key abc [String] -> one:abc
key SimpleKey [abc, 7] [SimpleKey] -> two:abc:7
key SimpleKey [] [SimpleKey] -> zero
Zero arguments -> the SimpleKey.EMPTY constant, printed as []
One argument -> that argument itself, unwrapped
Two or more -> a SimpleKey holding all of them
The method name and the declaring class appear nowhere in the key.
+25
View File
@@ -0,0 +1,25 @@
# The collision the default key generator makes easy
countLetters(String) and countDigits(String) both write into cache "shared".
countLetters("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
countDigits("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
countDigits never ran. It found the key "a1b2" already populated and
returned the answer to a different question.
cache "shared":
key a1b2 [String] -> letters=2
--- no-argument methods collide even harder ---
currentBanner() -> banner-from-currentBanner
currentFooter() -> banner-from-currentBanner <- both key on SimpleKey.EMPTY
cache "noargs":
key SimpleKey [] [SimpleKey] -> banner-from-currentBanner
--- the fix: put the method into the key ---
countLettersScoped("a1b2") -> letters=2
countDigitsScoped("a1b2") -> digits=2
cache "scoped":
key digits:a1b2 [String] -> digits=2
key letters:a1b2 [String] -> letters=2
+18
View File
@@ -0,0 +1,18 @@
# A mutable argument is an entry you cannot find again
first call : byList([java]) -> tags=[java]
cache "mutable":
key [java] [ArrayList] -> tags=[java]
the caller mutates the same list it passed in: [java, spring]
second call : byList([java, spring]) -> tags=[java, spring]
cache "mutable":
key [java, spring] [ArrayList] -> tags=[java]
key [java, spring] [ArrayList] -> tags=[java, spring]
Two entries, and their keys now print identically - because they are the
same object. The caller mutated the list it had already handed over as a
key, so the first entry sits in the map under a hashCode the map no longer
agrees with. Nothing will find it again and nothing will evict it: a leak
with a completely ordinary-looking cause.
+17
View File
@@ -0,0 +1,17 @@
# @CacheEvict runs after the method - unless you ask otherwise
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
reads of the real store: 1
--- beforeInvocation = true ---
price("sku-2") -> 100
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
price("sku-2") : 250 <- the entry went first, so the next read is honest
--- @CachePut instead: write through, no miss ---
after @CachePut, price("sku-3") -> 400
reads of the real store: 1 -> 1 <- no reload was needed
+7
View File
@@ -0,0 +1,7 @@
# condition is checked before the call, unless after it
search("spring") twice, 6 characters -> 1 invocations
search(25 chars) twice, condition false -> 2 invocations
condition = "#term.length() <= 8" is evaluated on the arguments before the
method runs, so a false condition skips the lookup and the write.
+16
View File
@@ -0,0 +1,16 @@
# A cached null is a real entry called NullValue
searchCachingNulls("xyz") returns null, called twice -> 1 invocations
cache "nulls":
key xyz -> null [org.springframework.cache.support.NullValue]
The abstraction stores org.springframework.cache.support.NullValue.INSTANCE
so a hit on null is distinguishable from a miss. This is usually what you
want - it is the cheap defence against a hot lookup for a row that is not
there - and occasionally exactly what you do not want.
--- unless = "#result == null" ---
searchNullable("xyz") twice -> 2 invocations <- the null was never stored
cache "terms":
(empty)
+11
View File
@@ -0,0 +1,11 @@
# sync = true is the difference between one slow call and sixteen
16 threads call the same key at the same instant, cold cache.
The method sleeps 300 ms.
@Cacheable("reports") -> 16 invocations
@Cacheable("syncedReports", sync = true) -> 1 invocation
Without sync, every thread that arrives during the 300 ms window misses and
runs the method. That is a cache stampede, and it is worst exactly when the
cache matters most - right after a restart or an eviction.
@@ -0,0 +1,12 @@
# @Cacheable on a CompletableFuture-returning method
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
setAsyncCacheMode(true) was called on it.
first -> async-report:q3
second -> async-report:q3
supplier invocations: 1
Since Spring Framework 6.1 the interceptor unwraps CompletableFuture and
the reactive types. ConcurrentMapCacheManager adapts to future-based
retrieval on its own; CaffeineCacheManager has to be told.
+22
View File
@@ -0,0 +1,22 @@
# A rollback does not roll the cache back
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
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 that no transaction ever committed. Nothing
will correct it until the entry expires or something evicts it.
--- the same shape with @CacheEvict ---
nameOf(2) -> Bob
after the rollback, nameOf(2) -> Bob
database loads: 2 <- the entry was evicted, so this one reloaded
An eviction that fires too early is self-healing: the next read goes to the
database and re-populates correctly. A @CachePut that fires too early is not.
+16
View File
@@ -0,0 +1,16 @@
# Caching an entity caches a detached object, lazy proxies and all
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
database loads: 1
second call returns the same instance? true
database loads: 1
--- touching the lazy collection outside the session ---
org.hibernate.LazyInitializationException
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
This is the line between the two caches. Hibernate's second-level cache
stores dehydrated entity state and rebuilds a managed entity inside a
session, so lazy associations still work. The Spring cache abstraction
stores the object your method returned, exactly as it was when the
transaction ended - detached, with whatever its proxies were holding.
@@ -0,0 +1,17 @@
# TTL and size bounds are the provider's job, not the abstraction's
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
configured : expireAfterWrite=400ms, maximumSize=3, recordStats
two calls, same key, immediately -> 1 repository calls
one more call 600 ms later -> 2 repository calls <- the entry expired
--- size bound ---
five distinct keys written, maximumSize = 3
estimated size after eviction settles : 3
stats : hits=1 misses=5 evictions=3
The Spring cache abstraction has no TTL, no size limit and no eviction
policy of its own - it is an interface over whatever you plug in. On the
default simple provider, a ConcurrentHashMap, an entry stays until something
evicts it by hand or the process ends.
@@ -0,0 +1,20 @@
# What @EnableCaching and Boot's auto-configuration put in the context
CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
caches known at startup : [asyncReports]
bean cacheInterceptor present=true
bean cacheOperationSource present=true
bean cacheAdvisor present=false
bean org.springframework.cache.config.internalCacheAdvisor present=true
CacheInterceptor beans : [cacheInterceptor]
KeyGenerator beans : []
--- where the auto-configuration class lives ---
FOUND org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration
absent org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
Boot 4 split spring-boot-autoconfigure into per-technology modules. Caching
auto-configuration now ships in spring-boot-cache, which the
spring-boot-starter-cache starter pulls in.
@@ -0,0 +1,10 @@
# The same method on the auto-configured Caffeine manager
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
The application started cleanly. Nothing warned about anything.
buildAsync("q3") ->
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)
Thrown on the first invocation, in production, at whatever hour that
endpoint first gets traffic.
@@ -0,0 +1,15 @@
# Nothing in application.yml selects a provider. Something still chose one.
spring.cache.type : (not set)
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
Caffeine is on this module's classpath because a later chapter needs TTL and
size bounds. That single dependency moved every cache in the application off
the ConcurrentHashMap-backed 'simple' provider, because Boot walks a fixed
detection order and stops at the first provider it finds:
1 Generic 2 JCache 3 Hazelcast 4 Infinispan 5 Couchbase
6 Redis 7 Caffeine 8 Cache2k 9 Simple
Nothing logs the decision at INFO. If a cache suddenly starts expiring
entries, or stops, look at what changed in the dependency tree.
@@ -0,0 +1,25 @@
# TransactionAwareCacheManagerProxy, and what it does not cover
cacheManager : org.springframework.cache.transaction.TransactionAwareCacheManagerProxy
nameOf(1) -> Alice
after the identical rollback, nameOf(1) -> Alice
The put was registered as a transaction synchronisation and dropped when the
transaction rolled back instead of committing.
--- what it does not cover: beforeInvocation = true ---
inside the same transaction, after an evict declared beforeInvocation=true,
a re-read returns : Bobby
Not the stale value. The eviction was NOT deferred, and the re-read went to
the database and saw the uncommitted row. The reason is in the bytecode:
AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent()
when immediate is true and evict() when it is false, and the decorator only
registers a post-commit synchronisation in evict() - evictIfPresent()
delegates straight to the target cache. See docs/output/22-decorator-bytecode.txt.
Two gaps do remain, and they are structural rather than measurable here:
reads are never deferred, so a @Cacheable lookup inside the transaction sees
whatever the shared cache holds; and outside a transaction the proxy is a
pass-through that writes immediately.
@@ -0,0 +1,29 @@
# Declarations that are rejected, and how late you find out
1. key and keyGenerator together
startup : FAILED - java.lang.IllegalStateException
Invalid cache annotation configuration on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$BothKeyAndGenerator$Svc.call(java.lang.String)'. Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: either set the SpEL expression used tocompute the key at runtime or set the name of the KeyGenerator bean to use.
--- 2. sync = true with unless ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation does not support the unless attribute on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncWithUnless$Svc.call(java.lang.String)] caches=[c] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='#result != null' | sync='true''
--- 3. sync = true across two caches ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation is restricted to a single cache on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncTwoCaches$Svc.call(java.lang.String)] caches=[c1, c2] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='true''
--- 4. @Cacheable and @CacheEvict on one method ---
startup : clean
first call: java.lang.IllegalStateException
A sync=true operation cannot be combined with other cache operations on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$CacheableAndEvict$Svc.call(java.lang.String)'
--- 5. a cache name that spring.cache.cache-names does not declare ---
startup : clean
first call: java.lang.IllegalArgumentException
Cannot find cache named 'unknown' for Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$UndeclaredCache$Svc.call(java.lang.String)] caches=[unknown] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='false'
Only the first of these is a compile-time-shaped mistake. The rest start a
perfectly healthy application and throw on a code path that may not be hit
for hours.
@@ -0,0 +1,49 @@
# Why beforeInvocation=true is not deferred by TransactionAwareCacheManagerProxy
$ javap -c -p org.springframework.cache.interceptor.AbstractCacheInvoker # spring-context-7.0.9.jar
protected void doEvict(org.springframework.cache.Cache, java.lang.Object, boolean);
Code:
0: iload_3
1: ifeq 15
4: aload_1
5: aload_2
6: invokeinterface #83, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
11: pop
12: goto 22
15: aload_1
16: aload_2
17: invokeinterface #87, 2 // InterfaceMethod org/springframework/cache/Cache.evict:(Ljava/lang/Object;)V
22: goto 40
25: astore 4
$ javap -c -p org.springframework.cache.transaction.TransactionAwareCacheDecorator # spring-context-support-7.0.9.jar
public void evict(java.lang.Object);
Code:
0: invokestatic #48 // Method org/springframework/transaction/support/TransactionSynchronizationManager.isSynchronizationActive:()Z
3: ifeq 21
6: new #71 // class org/springframework/cache/transaction/TransactionAwareCacheDecorator$2
9: dup
10: aload_0
11: aload_1
12: invokespecial #73 // Method org/springframework/cache/transaction/TransactionAwareCacheDecorator$2."<init>":(Lorg/springframework/cache/transaction/TransactionAwareCacheDecorator;Ljava/lang/Object;)V
15: invokestatic #59 // Method org/springframework/transaction/support/TransactionSynchronizationManager.registerSynchronization:(Lorg/springframework/transaction/support/TransactionSynchronization;)V
18: goto 31
21: aload_0
22: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
25: aload_1
public boolean evictIfPresent(java.lang.Object);
Code:
0: aload_0
1: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
4: aload_1
5: invokeinterface #80, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
10: ireturn
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
spring-framework#23192 reported beforeInvocation=true being swallowed by the
transaction-aware decorator. On 7.0.9 it is not: the immediate path uses a method
the decorator does not intercept. Note also that the decorator ships in
spring-context-support, not spring-context.
+34
View File
@@ -0,0 +1,34 @@
# The live contents of every cache, keys included
$ curl -s localhost:8080/diag/warm
warmed: books, shapes, nulls
$ curl -s localhost:8080/diag/caches | jq .
{
"cacheManager": "org.springframework.cache.concurrent.ConcurrentMapCacheManager",
"caches": {
"nulls": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"xyz [String]": "null [NullValue]"
}
},
"books": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"978-0134685991 [String]": "Book[isbn=978-0134685991, title=Effective Java, year=2018] [Book]"
}
},
"shapes": {
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
"entries": {
"SimpleKey [] [SimpleKey]": "zero [String]",
"SimpleKey [abc, 7] [SimpleKey]": "two:abc:7 [String]",
"abc [String]": "one:abc [String]"
}
}
}
}