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.
99 lines
4.2 KiB
Markdown
99 lines
4.2 KiB
Markdown
[← self-invocation](03-self-invocation.md) · [next: eviction →](05-eviction.md)
|
|
|
|
# 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.
|