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,98 @@
|
||||
[← 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.
|
||||
Reference in New Issue
Block a user