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:
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user