Files
asmhatre a9867c0423 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: 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.
2026-09-12 05:35:13 +00:00

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.