Files
spring-boot-demo/caching/docs/04-keys.md
T
asmhatre 66208bcd97 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.
2026-09-12 05:19:22 +00:00

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:

  1. One cache name per method. Free, obvious in a dump, and gives you per-method TTL and per-method metrics as a bonus.
  2. Put the method into the key: key = "'letters:' + #input".
  3. A custom KeyGenerator that includes method.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: RedisCache converts the key object through a ConversionService before writing it, so a key type it cannot convert fails at the call rather than at startup. An explicit key expression that produces a short deterministic String sidesteps 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.