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.
4.2 KiB
← self-invocation · next: eviction →
4. Keys
What the default generator builds
SimpleKeyGenerator has three rules:
| Arguments | Key |
|---|---|
| none | SimpleKey.EMPTY |
| one | that argument, unwrapped |
| two or more | a SimpleKey holding all of them |
docs/output/05-key-shapes.txt shows all three in one map:
key abc [String] -> one:abc
key SimpleKey [abc, 7] [SimpleKey] -> two:abc:7
key SimpleKey [] [SimpleKey] -> zero
The method name and the declaring class appear nowhere. That is the single most consequential fact about Spring cache keys.
Before Spring 4.0 the strategy hashed the arguments, which produced real collisions
(spring-framework#14870). SimpleKey holds the arguments and compares them with equals, so
arguments need working equals and hashCode — records and value objects are fine, JPA entities
with identity-based equality usually are not.
The collision this makes easy
Two methods, one cache name, same argument type. From
docs/output/06-key-collision.txt:
countLetters("a1b2") -> letters=2 (calls: letters=1 digits=0)
countDigits("a1b2") -> letters=2 (calls: letters=1 digits=0)
countDigits never ran. It found key "a1b2" populated and returned the answer to a different
question. In production this looks like findByIsbn and findByTitle next to each other, both
annotated @Cacheable("books"), both taking a String.
No-argument methods collide harder still: every one of them keys on SimpleKey.EMPTY, so two
@Cacheable("noargs") methods with no parameters are guaranteed to shadow each other.
Three ways out, in order of preference:
- One cache name per method. Free, obvious in a dump, and gives you per-method TTL and per-method metrics as a bonus.
- Put the method into the key:
key = "'letters:' + #input". - A custom
KeyGeneratorthat includesmethod.getName(). Applies everywhere at once, which is useful in a large codebase and a blunt instrument in a small one.
SpEL you can use in key, condition and unless
| Expression | What it is |
|---|---|
#argName |
a method argument by name (needs -parameters, which Spring Boot's Maven parent sets) |
#a0, #p0 |
the same argument by index, when names are unavailable |
#root.methodName, #root.method |
the method being invoked |
#root.target, #root.targetClass |
the target object and its class |
#root.args[0] |
arguments as an array |
#root.caches[0].name |
the caches this operation resolves to |
#result |
the return value — unless, @CachePut keys, and @CacheEvict with beforeInvocation = false only |
For an Optional return, #result is the unwrapped object, not the Optional.
Keys you can lose
docs/output/07-mutable-key.txt passes an ArrayList and then mutates it:
cache "mutable":
key [java, spring] [ArrayList] -> tags=[java]
key [java, spring] [ArrayList] -> tags=[java, spring]
Two entries whose keys print identically, because they are the same object. The first entry sits
under a hashCode the map no longer agrees with: unreachable, unevictable, and it will still be
there at the next heap dump. Keys must be immutable. If an argument is a collection, copy it or
derive a string.
Keys in a distributed cache
Everything above assumes an in-process map. Once the cache is Redis or Hazelcast, two more rules apply:
- The key has to survive being turned into a string or bytes. This repository has no Redis
in it, so treat what follows as reasoning from the API rather than something measured here:
RedisCacheconverts the key object through aConversionServicebefore writing it, so a key type it cannot convert fails at the call rather than at startup. An explicitkeyexpression that produces a short deterministicStringsidesteps the question entirely, and it is what you want in a shared cache anyway. - The key must be stable across deployments. Anything derived from a hash code, an enum ordinal, or a class name you might refactor will silently miss for the whole cache after a rename, and the old entries will sit there until they expire.