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,45 @@
|
||||
[← README](../README.md) · [next: the three annotations →](02-the-three-annotations.md)
|
||||
|
||||
# 1. What the Spring cache abstraction actually is
|
||||
|
||||
It is an interceptor and a map interface. That is the whole idea, and holding onto it explains
|
||||
almost every surprise later.
|
||||
|
||||
When a bean carries `@Cacheable`, Spring does not modify the class. It places an AOP proxy in
|
||||
front of it and puts a `CacheInterceptor` in the chain. On each call the interceptor:
|
||||
|
||||
1. asks a `KeyGenerator` for a key,
|
||||
2. asks a `Cache` (looked up from a `CacheManager` by name) whether it holds that key,
|
||||
3. returns the stored value if it does, and otherwise calls the real method and stores the result.
|
||||
|
||||
`org.springframework.cache.Cache` is a small interface — `get`, `put`, `evict`, `evictIfPresent`,
|
||||
`clear`, `invalidate`, `retrieve`. Everything you associate with a cache product — expiry, size
|
||||
limits, eviction policy, replication, persistence, statistics — lives behind that interface in a
|
||||
provider. The abstraction itself has none of it. The reference documentation is explicit about
|
||||
this in its "How can I set the TTL/TTI/eviction policy" section: you configure it on the provider.
|
||||
|
||||
## What that buys you
|
||||
|
||||
Portability of the *declaration*, not the behaviour. The same annotated method runs against a
|
||||
`ConcurrentHashMap` in a unit test, Caffeine in one deployment and Redis in another, without the
|
||||
service code changing. That is genuinely useful and it is the main reason to use it.
|
||||
|
||||
## When not to cache
|
||||
|
||||
- **The method is not slow.** A cache turns a 2 ms call into a 0.1 ms call and adds a correctness
|
||||
problem. Measure first.
|
||||
- **The data must be correct right now.** Balances, stock levels, permissions. A cache is a
|
||||
deliberate decision to serve stale data; make it deliberately.
|
||||
- **The hit rate will be low.** A cache keyed on something nearly unique — a search phrase, a
|
||||
request id — is a memory leak wearing a performance costume.
|
||||
- **The value is huge and the memory budget is not.** On the default provider nothing evicts.
|
||||
|
||||
`docs/output/01-basics.txt` has the smallest possible demonstration: 200 ms, then 0 ms, with the
|
||||
repository's invocation counter proving the method body did not run the second time.
|
||||
|
||||
## The two caches people confuse
|
||||
|
||||
If you are using JPA, you already have caching whether you asked for it or not: the persistence
|
||||
context (first level) and possibly Hibernate's second-level cache. They are a different thing
|
||||
from this, at a different layer, with different failure modes.
|
||||
[Chapter 9](09-versus-hibernate-l2.md) is the comparison.
|
||||
@@ -0,0 +1,88 @@
|
||||
[← what caching is](01-what-caching-is.md) · [next: self-invocation →](03-self-invocation.md)
|
||||
|
||||
# 2. The annotations, attribute by attribute
|
||||
|
||||
Verified against `spring-context` 7.0.9 with `javap`, so this is what the class files declare
|
||||
rather than what the documentation summarises.
|
||||
|
||||
## `@Cacheable`
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|---|---|---|
|
||||
| `value` / `cacheNames` | `String[]` | Aliases. Several names means several caches are consulted and all of them written. |
|
||||
| `key` | `String` | SpEL. Mutually exclusive with `keyGenerator`; setting both fails the context at startup. |
|
||||
| `keyGenerator` | `String` | Bean name of a `KeyGenerator`. |
|
||||
| `cacheManager` | `String` | Bean name, for when there is more than one. |
|
||||
| `cacheResolver` | `String` | Full control over which caches this operation uses. Mutually exclusive with `cacheManager`. |
|
||||
| `condition` | `String` | SpEL, evaluated **before** the call. False means no lookup and no write. |
|
||||
| `unless` | `String` | SpEL, evaluated **after**. Can see `#result`. Vetoes the write only. |
|
||||
| `sync` | `boolean` | One caller computes, the rest wait. Heavily restricted — see [chapter 7](07-sync-and-async.md). |
|
||||
|
||||
## `@CachePut`
|
||||
|
||||
Same attributes minus `sync`. Always invokes the method, always writes the result. Use it when
|
||||
you already have the new value and want to avoid the miss that an eviction would cause.
|
||||
|
||||
Do not put `@CachePut` and `@Cacheable` on the same method. The framework does not stop you, and
|
||||
the two have opposite intentions.
|
||||
|
||||
## `@CacheEvict`
|
||||
|
||||
Same as `@Cacheable` minus `unless` and `sync`, plus:
|
||||
|
||||
| Attribute | Type | Notes |
|
||||
|---|---|---|
|
||||
| `allEntries` | `boolean` | Clears the whole region in one operation instead of key by key. |
|
||||
| `beforeInvocation` | `boolean` | Default `false` — evict after a *successful* return. See [chapter 5](05-eviction.md). |
|
||||
|
||||
`void` is fine here; the annotation is a trigger and the return value is ignored.
|
||||
|
||||
## `@Caching`
|
||||
|
||||
A container for several operations of the same type on one method:
|
||||
|
||||
```java
|
||||
@Caching(evict = { @CacheEvict("primary"), @CacheEvict(cacheNames = "secondary", key = "#p0") })
|
||||
public Book importBooks(String deposit, Date date) { ... }
|
||||
```
|
||||
|
||||
## `@CacheConfig`
|
||||
|
||||
Class-level defaults for `cacheNames`, `keyGenerator`, `cacheManager` and `cacheResolver`. It
|
||||
enables nothing on its own. Precedence runs global (`CachingConfigurer`) → class
|
||||
(`@CacheConfig`) → operation, with the operation always winning.
|
||||
|
||||
## `@EnableCaching`
|
||||
|
||||
Exactly three attributes, and this is worth knowing because a popular piece of advice uses a
|
||||
fourth that does not exist:
|
||||
|
||||
```
|
||||
AdviceMode mode()
|
||||
int order()
|
||||
boolean proxyTargetClass()
|
||||
```
|
||||
|
||||
There is no `exposeProxy`. `@EnableCaching(exposeProxy = true)` does not compile.
|
||||
`docs/output/04-non-public-and-postconstruct.txt` prints the reflected attribute list.
|
||||
|
||||
Spring Boot's reference documentation advises against putting `@EnableCaching` on the main
|
||||
application class, because it makes caching mandatory for every test slice too. Put it on a
|
||||
`@Configuration` class you can exclude.
|
||||
|
||||
## Declarations that are rejected, and when
|
||||
|
||||
`docs/output/20-invalid-declarations.txt` runs five bad declarations through a real context. Only
|
||||
the first fails at startup:
|
||||
|
||||
| Declaration | Fails |
|
||||
|---|---|
|
||||
| `key` and `keyGenerator` together | at startup |
|
||||
| `sync = true` with `unless` | at the first call |
|
||||
| `sync = true` across two caches | at the first call |
|
||||
| `sync = true` combined with another cache operation | at the first call |
|
||||
| a cache name not in `spring.cache.cache-names` | at the first call |
|
||||
|
||||
Four of the five start a healthy-looking application and throw on a code path that may not run
|
||||
for hours. That is the single strongest argument for having a test that actually calls each
|
||||
cached method.
|
||||
@@ -0,0 +1,91 @@
|
||||
[← the three annotations](02-the-three-annotations.md) · [next: keys →](04-keys.md)
|
||||
|
||||
# 3. The self-invocation trap
|
||||
|
||||
The symptom: `@Cacheable` is on the method, the application starts cleanly, nothing is logged,
|
||||
and the cache is empty. Or worse, the cache works when the method is called from a controller and
|
||||
does not when it is called from a sibling method three lines away.
|
||||
|
||||
## The mechanism
|
||||
|
||||
`@EnableCaching` registers an auto-proxy creator. The bean the container hands out is a proxy —
|
||||
in this module a CGLIB subclass, printed in `docs/output/03-self-invocation.txt`:
|
||||
|
||||
```
|
||||
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
|
||||
is an AOP proxy? : true
|
||||
target class : com.ankurm.caching.selfinvocation.CatalogService
|
||||
```
|
||||
|
||||
The interceptor lives in the proxy. `this.lookup(...)` inside the target object is a plain
|
||||
virtual call on `this`, which is the target, not the proxy. The interceptor is never reached.
|
||||
|
||||
Measured over four ISBNs with two repeats, so a working cache does two lookups:
|
||||
|
||||
```
|
||||
this.lookup(..) -> 4 repository calls <- no caching at all
|
||||
self.getObject().lookup(..) -> 2 repository calls
|
||||
AopContext.currentProxy() -> 2 repository calls
|
||||
a second bean calls lookup(..) -> 2 repository calls
|
||||
```
|
||||
|
||||
## The three fixes, ranked
|
||||
|
||||
**1. Move the call to another bean.** The loop and the cached lookup belong to different
|
||||
responsibilities anyway. No Spring-specific machinery, no cycle, testable in isolation. This is
|
||||
the one to reach for.
|
||||
|
||||
**2. Inject yourself as an `ObjectProvider`.**
|
||||
|
||||
```java
|
||||
private final ObjectProvider<CatalogService> self;
|
||||
...
|
||||
CatalogService proxy = self.getObject();
|
||||
```
|
||||
|
||||
`ObjectProvider` defers the lookup, so there is no constructor cycle. `@Lazy CatalogService self`
|
||||
works the same way. It is honest about what it is doing, which is more than can be said for the
|
||||
next option.
|
||||
|
||||
**3. `AopContext.currentProxy()`.** Works, but only when the proxy was created with
|
||||
`exposeProxy` on — and, as [chapter 2](02-the-three-annotations.md) notes, `@EnableCaching` has
|
||||
no such attribute. `@EnableAspectJAutoProxy(exposeProxy = true)` is the usual advice and drags in
|
||||
AspectJ. This module flips the flag on the creator `@EnableCaching` already registered:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
static BeanFactoryPostProcessor exposeCachingProxy() {
|
||||
return beanFactory -> {
|
||||
if (beanFactory instanceof BeanDefinitionRegistry registry) {
|
||||
AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry);
|
||||
AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
The cost is a ThreadLocal read and a cast in business code, and it only works inside a call that
|
||||
was itself intercepted.
|
||||
|
||||
## The fourth option: stop using proxies
|
||||
|
||||
`@EnableCaching(mode = AdviceMode.ASPECTJ)` with compile-time or load-time weaving modifies the
|
||||
bytecode, so self-invocation and non-public methods are both intercepted. It is a real answer and
|
||||
almost nobody takes it, because the build complexity is not worth it for caching alone.
|
||||
|
||||
## Two relatives of the same bug
|
||||
|
||||
`docs/output/04-non-public-and-postconstruct.txt`:
|
||||
|
||||
- **A non-public annotated method is never advised.** In proxy mode the annotation on a
|
||||
`protected`, package-private or `private` method is silently ignored. Two calls, two repository
|
||||
hits, no warning.
|
||||
- **`@PostConstruct` runs before the proxy is in place.** A warm-up loop in an init method warms
|
||||
nothing. The reference documentation says so, and it still catches people.
|
||||
|
||||
## How to tell in ten seconds
|
||||
|
||||
Inject the bean, print `AopUtils.isAopProxy(bean)` and `bean.getClass().getName()`. If the class
|
||||
name has no `$$SpringCGLIB$$` or `$Proxy` in it, there is no interceptor and nothing downstream
|
||||
matters. If it does, but the cache is still empty, the call is not going through it — look for
|
||||
`this.`.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,75 @@
|
||||
[← keys](04-keys.md) · [next: conditions and nulls →](06-conditions-and-nulls.md)
|
||||
|
||||
# 5. Eviction, and when it happens
|
||||
|
||||
`@CacheEvict` defaults to `beforeInvocation = false`, which means: evict **after** the method
|
||||
returns **normally**. A method that throws does not evict.
|
||||
|
||||
From `docs/output/08-evict-timing.txt`:
|
||||
|
||||
```
|
||||
price("sku-1") -> 100 (stored price is 100)
|
||||
|
||||
updatePrice("sku-1", 250, fail=true) threw after writing the new price.
|
||||
stored price now : 250
|
||||
price("sku-1") : 100 <- the cache still serves the old value
|
||||
```
|
||||
|
||||
The write to the real store happened. The exception came afterwards. The eviction did not run,
|
||||
so the cache is now authoritative for a value that no longer exists anywhere else. Nothing will
|
||||
correct it: the next reader gets 100, and the one after that, until something evicts the entry or
|
||||
the process restarts.
|
||||
|
||||
`beforeInvocation = true` makes the same failure harmless:
|
||||
|
||||
```
|
||||
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
|
||||
price("sku-2") : 250 <- the entry went first, so the next read is honest
|
||||
```
|
||||
|
||||
## Choosing between the three shapes
|
||||
|
||||
| Shape | Cost | Correct when the write fails? |
|
||||
|---|---|---|
|
||||
| `@CacheEvict` (default) | one miss on the next read | **no** — stale entry survives |
|
||||
| `@CacheEvict(beforeInvocation = true)` | one miss, plus a window where two callers can both miss | yes |
|
||||
| `@CachePut` | no miss at all | no — and it writes a value the store may not have |
|
||||
|
||||
`@CachePut` is the fastest and the most dangerous: it puts *your* computed value into the cache
|
||||
without reading the store back, so any transformation the database applies — a trigger, a
|
||||
default, a truncation, a generated column — is invisible to every subsequent reader. Use it when
|
||||
the method's return value is definitively the new state.
|
||||
|
||||
`beforeInvocation = true` is the right default for anything that mutates. The extra miss costs
|
||||
one lookup; the alternative costs an incident.
|
||||
|
||||
## `allEntries = true`
|
||||
|
||||
Clears the region in a single operation rather than key by key, which matters when the region is
|
||||
large or remote. Two things to know:
|
||||
|
||||
- It is a blunt instrument in a shared cache: one bulk import evicts everything every other
|
||||
caller warmed.
|
||||
- On a distributed provider `clear()` may be an O(n) scan or a whole-keyspace operation. Check
|
||||
what your provider does before putting it on a frequently called method.
|
||||
|
||||
## Errors from the cache itself
|
||||
|
||||
If the cache provider throws — a Redis timeout, a serialization failure — the default
|
||||
`SimpleCacheErrorHandler` rethrows, so a cache outage becomes an application outage. A
|
||||
`CacheErrorHandler` registered through `CachingConfigurer` can log and continue instead:
|
||||
|
||||
```java
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
class CacheConfig implements CachingConfigurer {
|
||||
@Override
|
||||
public CacheErrorHandler errorHandler() {
|
||||
return new SimpleCacheErrorHandler() { /* log and swallow get/put/evict errors */ };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
That is the right call for a read-through cache in front of a database, and the wrong call when
|
||||
the eviction is what keeps two systems consistent — a swallowed evict error is a permanently
|
||||
stale entry. Decide per cache, not per application.
|
||||
@@ -0,0 +1,55 @@
|
||||
[← eviction](05-eviction.md) · [next: sync and async →](07-sync-and-async.md)
|
||||
|
||||
# 6. `condition`, `unless`, and what a cached `null` is
|
||||
|
||||
## The two vetoes
|
||||
|
||||
`condition` is evaluated on the arguments **before** the method runs. A false condition skips the
|
||||
lookup *and* the write — the method behaves as though it were not annotated.
|
||||
|
||||
`unless` is evaluated **after**, can see `#result`, and vetoes the write only. The lookup still
|
||||
happened, so a cached value is still returned on a hit.
|
||||
|
||||
```java
|
||||
@Cacheable(cacheNames = "terms", condition = "#term.length() <= 8")
|
||||
public String search(String term) { ... }
|
||||
```
|
||||
|
||||
`docs/output/09-conditions.txt`: six characters, two calls, one invocation. Twenty-five
|
||||
characters, two calls, two invocations.
|
||||
|
||||
The useful pattern is exactly that one — refuse to cache inputs that will never repeat. A search
|
||||
box keyed on free text has a hit rate close to zero and will happily fill the heap.
|
||||
|
||||
## `null`
|
||||
|
||||
By default a `null` return is cached. It is stored as a sentinel,
|
||||
`org.springframework.cache.support.NullValue.INSTANCE`, so that a hit on `null` is
|
||||
distinguishable from a miss. `docs/output/10-nulls.txt` shows it in the map:
|
||||
|
||||
```
|
||||
cache "nulls":
|
||||
key xyz -> null [org.springframework.cache.support.NullValue]
|
||||
```
|
||||
|
||||
This is usually what you want. Caching "not found" is the cheap defence against a hot lookup for
|
||||
a row that does not exist — the classic cache-penetration attack is a flood of requests for ids
|
||||
that are not in the database, and a cache that refuses to store misses passes every one of them
|
||||
straight through.
|
||||
|
||||
Turn it off when a `null` means "not loaded yet" rather than "not there":
|
||||
|
||||
```java
|
||||
@Cacheable(cacheNames = "terms", unless = "#result == null")
|
||||
```
|
||||
|
||||
Or at the manager: `ConcurrentMapCacheManager.setAllowNullValues(false)`, reachable through a
|
||||
`CacheManagerCustomizer`. Redis has its own switch, `spring.cache.redis.cache-null-values`,
|
||||
default `true`.
|
||||
|
||||
Note the asymmetry that catches people: `unless = "#result == null"` still performs the lookup,
|
||||
so if a `null` got into the cache some other way it will still be served. `condition` cannot help
|
||||
here — it cannot see the result.
|
||||
|
||||
For an `Optional`-returning method, `#result` is the unwrapped value, so the safe-navigation form
|
||||
is what you want: `unless = "#result?.hardback"`.
|
||||
@@ -0,0 +1,67 @@
|
||||
[← conditions and nulls](06-conditions-and-nulls.md) · [next: providers and TTL →](08-providers-and-ttl.md)
|
||||
|
||||
# 7. `sync = true`, and the async return types
|
||||
|
||||
## The stampede
|
||||
|
||||
`docs/output/11-stampede.txt`, sixteen threads hitting one cold key, method sleeping 300 ms:
|
||||
|
||||
```
|
||||
@Cacheable("reports") -> 16 invocations
|
||||
@Cacheable("syncedReports", sync = true) -> 1 invocation
|
||||
```
|
||||
|
||||
Nothing is wrong with the unsynchronised version — it is doing exactly what it was told. Every
|
||||
thread that arrives during the 300 ms window finds a miss and runs the method. The problem is
|
||||
*when* this happens: right after a deployment, right after an eviction, right when the cache
|
||||
would have been most valuable. A cache that collapses under the load it was added to survive is
|
||||
a well-known way to turn a slow endpoint into an outage.
|
||||
|
||||
`sync = true` makes one caller compute while the others block on the same computation. It is
|
||||
implemented on top of `Cache.get(key, Callable)`, so the provider has to support it; all the
|
||||
`CacheManager` implementations in the framework do.
|
||||
|
||||
## What `sync = true` will not tolerate
|
||||
|
||||
Four restrictions, all enforced at the **first call** rather than at startup. Real messages from
|
||||
`docs/output/20-invalid-declarations.txt`:
|
||||
|
||||
| Declaration | Message |
|
||||
|---|---|
|
||||
| `unless` alongside `sync` | `A sync=true operation does not support the unless attribute on ...` |
|
||||
| two cache names | `A sync=true operation is restricted to a single cache on ...` |
|
||||
| combined with another cache operation | `A sync=true operation cannot be combined with other cache operations on ...` |
|
||||
|
||||
All three are `IllegalStateException`, thrown from the interceptor, on a context that started
|
||||
cleanly. An integration test that calls the method once is the cheapest possible insurance.
|
||||
|
||||
## `CompletableFuture` and reactive types
|
||||
|
||||
Since Spring Framework 6.1 the cache annotations understand `CompletableFuture`, `Mono` and
|
||||
`Flux`. The interceptor unwraps the container and caches the emitted value.
|
||||
|
||||
The cache has to support future-based retrieval. `ConcurrentMapCacheManager` adapts on its own.
|
||||
`CaffeineCacheManager` does not, unless you say so — and the way it tells you is
|
||||
`docs/output/17-async-cache-mode-missing.txt`:
|
||||
|
||||
```
|
||||
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
The application started cleanly. Nothing warned about anything.
|
||||
|
||||
buildAsync("q3") ->
|
||||
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)
|
||||
```
|
||||
|
||||
The fix is one line on the manager:
|
||||
|
||||
```java
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||
manager.setAsyncCacheMode(true);
|
||||
```
|
||||
|
||||
With it on, `docs/output/12-async-return-types.txt` shows two calls and one supplier invocation.
|
||||
|
||||
Be careful how far you take this. The reference documentation's own caveat is worth quoting:
|
||||
annotation-driven caching "is not appropriate for sophisticated reactive interactions involving
|
||||
composition and back pressure" — a `@Cacheable` `Flux` stores a pre-collected list, which is
|
||||
rarely what a streaming endpoint wanted.
|
||||
@@ -0,0 +1,77 @@
|
||||
[← sync and async](07-sync-and-async.md) · [next: versus Hibernate L2 →](09-versus-hibernate-l2.md)
|
||||
|
||||
# 8. Providers, TTL, and the dependency that changes your cache
|
||||
|
||||
## The abstraction has no TTL
|
||||
|
||||
None. No time-to-live, no time-to-idle, no maximum size, no eviction policy. Those are provider
|
||||
features, and the reference documentation says so plainly.
|
||||
|
||||
On the default `simple` provider — a `ConcurrentHashMap` behind `ConcurrentMapCache` — an entry
|
||||
stays until something evicts it by hand or the process ends. That is fine for a lookup table with
|
||||
twelve rows and a slow leak for anything else.
|
||||
|
||||
With Caffeine configured for `expireAfterWrite=400ms, maximumSize=3`,
|
||||
`docs/output/15-providers-and-ttl.txt`:
|
||||
|
||||
```
|
||||
two calls, same key, immediately -> 1 repository calls
|
||||
one more call 600 ms later -> 2 repository calls <- the entry expired
|
||||
|
||||
five distinct keys written, maximumSize = 3
|
||||
estimated size after eviction settles : 3
|
||||
stats : hits=1 misses=5 evictions=3
|
||||
```
|
||||
|
||||
## Auto-detection, and why your provider changed
|
||||
|
||||
If there is no `CacheManager` bean and no `cacheResolver`, Spring Boot walks a fixed order and
|
||||
stops at the first provider on the classpath:
|
||||
|
||||
```
|
||||
1 Generic 2 JCache 3 Hazelcast 4 Infinispan 5 Couchbase
|
||||
6 Redis 7 Caffeine 8 Cache2k 9 Simple
|
||||
```
|
||||
|
||||
That has a consequence worth internalising. This module added Caffeine because one chapter needed
|
||||
TTL. Every cache in the application moved off the simple provider as a result, and nothing said
|
||||
so — `docs/output/18-provider-detection.txt` catches it:
|
||||
|
||||
```
|
||||
spring.cache.type : (not set)
|
||||
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
```
|
||||
|
||||
A transitive dependency on Hazelcast, added for something unrelated, will do the same thing and
|
||||
outrank Redis while it is at it. **Set `spring.cache.type` explicitly in anything you deploy.**
|
||||
|
||||
## Per-provider configuration
|
||||
|
||||
| Property | Effect |
|
||||
|---|---|
|
||||
| `spring.cache.type` | `generic`, `jcache`, `hazelcast`, `infinispan`, `couchbase`, `redis`, `caffeine`, `cache2k`, `simple`, `none` |
|
||||
| `spring.cache.cache-names` | Creates exactly these caches at startup; anything else fails at the call |
|
||||
| `spring.cache.caffeine.spec` | e.g. `maximumSize=500,expireAfterAccess=600s` |
|
||||
| `spring.cache.redis.time-to-live` | a `Duration` |
|
||||
| `spring.cache.redis.cache-null-values` | default `true` |
|
||||
| `spring.cache.redis.key-prefix`, `use-key-prefix` | keyspace hygiene in a shared Redis |
|
||||
| `spring.cache.jcache.provider`, `spring.cache.jcache.config` | JSR-107 |
|
||||
|
||||
`spring.cache.type=none` gives a `NoOpCacheManager`: every method runs every time, and the
|
||||
annotations stay where they are. Useful in tests, and the fastest way to answer "is the cache
|
||||
causing this?".
|
||||
|
||||
`spring.cache.cache-names` is worth using in production: it turns a typo in a cache name from a
|
||||
cache that silently never hits into an `IllegalArgumentException` at the first call.
|
||||
|
||||
One caveat — a single `spring.cache.caffeine.spec` applies to **every** cache. Per-cache expiry
|
||||
needs your own `CaffeineCacheManager` (or several `CacheManager` beans and `cacheManager = "..."`
|
||||
on the operations), which is another argument for one cache name per method.
|
||||
|
||||
## Where the auto-configuration lives in Boot 4
|
||||
|
||||
`org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration`, shipped in the
|
||||
`spring-boot-cache` module. Boot 4 split `spring-boot-autoconfigure` into per-technology modules;
|
||||
the Boot 3 coordinate `org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration` no
|
||||
longer resolves. `docs/output/16-autoconfiguration.txt` checks both names against the running
|
||||
classpath. It matters if you write `@ImportAutoConfiguration` or exclusions by class name.
|
||||
@@ -0,0 +1,68 @@
|
||||
[← providers and TTL](08-providers-and-ttl.md) · [next: transactions →](10-transactions.md)
|
||||
|
||||
# 9. This is not the Hibernate second-level cache
|
||||
|
||||
They are both called caching, they both make things faster, and they solve different problems at
|
||||
different layers. Mixing them up produces designs that cache the wrong thing.
|
||||
|
||||
## Three layers, three scopes
|
||||
|
||||
| | Persistence context (L1) | Hibernate second-level cache (L2) | Spring cache abstraction |
|
||||
|---|---|---|---|
|
||||
| Scope | one `EntityManager` / transaction | one `SessionFactory`, all sessions | one `CacheManager`, whatever you annotate |
|
||||
| Unit | managed entity instance | dehydrated entity state, by id | whatever object a method returned |
|
||||
| Lookup by | identity map | entity id (plus query and collection regions) | a key the `KeyGenerator` built |
|
||||
| Turned on by | always on | `hibernate.cache.use_second_level_cache` + `@Cache` | `@EnableCaching` + `@Cacheable` |
|
||||
| Invalidated by | end of transaction | Hibernate, on write, automatically | you, with `@CacheEvict` |
|
||||
| Knows about your database | yes | yes | **no** |
|
||||
|
||||
Ankur's Hibernate articles cover the first two in detail:
|
||||
[the first-level cache](https://ankurm.com/master-hibernate-7-first-level-cache-the-secret-to-high-performance-java-apps/),
|
||||
[the second-level cache and the three ways it stales your data](https://ankurm.com/mastering-hibernate-7-second-level-cache-boosting-performance-for-modern-java-apps/),
|
||||
and [configuring Ehcache 3 under it](https://ankurm.com/master-hibernate-7-ehcache-3-configuration-high-performance-caching-with-jakarta-persistence/).
|
||||
|
||||
## The difference that bites
|
||||
|
||||
Hibernate's L2 stores *dehydrated state* — a flat array of column values keyed by entity id. On a
|
||||
hit it rehydrates that state into a managed entity attached to the current session. Lazy
|
||||
associations still work, because the resulting entity is live.
|
||||
|
||||
The Spring cache abstraction stores *the object your method returned*, as-is. If that object is a
|
||||
JPA entity, what you cached is a detached entity with whatever its proxies were holding at the
|
||||
moment the transaction closed. `docs/output/14-cached-entity.txt`:
|
||||
|
||||
```
|
||||
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
|
||||
second call returns the same instance? true
|
||||
|
||||
--- touching the lazy collection outside the session ---
|
||||
org.hibernate.LazyInitializationException
|
||||
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
|
||||
```
|
||||
|
||||
Worse, it is the *same instance* every time. Every caller shares one mutable entity. One of them
|
||||
calls a setter, and every other caller sees it — and if someone merges it back, they merge a
|
||||
version number that may be stale.
|
||||
|
||||
## The rule
|
||||
|
||||
**Cache DTOs, not entities.** Map to a record inside the transaction and cache that. It is
|
||||
immutable, it serialises cleanly to a distributed cache, it has no session affinity, and it makes
|
||||
the cached shape an explicit decision rather than an accident of your mapping.
|
||||
|
||||
If you genuinely want entity caching — read-mostly reference data, keyed by id, invalidated
|
||||
automatically on write — that is precisely what Hibernate's L2 is for, and it will do it better
|
||||
than `@Cacheable` because it understands the writes.
|
||||
|
||||
## Which one to reach for
|
||||
|
||||
| You want to avoid | Use |
|
||||
|---|---|
|
||||
| re-loading the same entity by id across requests | Hibernate L2 (`@Cache` on the entity) |
|
||||
| re-running the same query that returns entities | Hibernate query cache, carefully — it needs L2 for the entities too |
|
||||
| re-running an expensive computation, HTTP call or aggregation | the Spring cache abstraction |
|
||||
| re-building a response DTO from several sources | the Spring cache abstraction |
|
||||
|
||||
The two compose. A service method cached with `@Cacheable` that internally loads entities served
|
||||
from L2 is a perfectly reasonable stack — just be clear about which layer is answering, because
|
||||
only one of them will notice when the row changes.
|
||||
@@ -0,0 +1,94 @@
|
||||
[← versus Hibernate L2](09-versus-hibernate-l2.md) · [next: diagnostics →](11-diagnostics.md)
|
||||
|
||||
# 10. Caching and transactions
|
||||
|
||||
## The ordering
|
||||
|
||||
The caching interceptor runs **inside** the transaction interceptor. A cache write therefore
|
||||
happens at method exit — before the commit, and without any knowledge of whether the commit will
|
||||
succeed.
|
||||
|
||||
`docs/output/13-transactions.txt` puts an ordinary write-through update inside an outer
|
||||
transaction that then fails:
|
||||
|
||||
```
|
||||
nameOf(1) -> Alice
|
||||
|
||||
An outer @Transactional method calls the @CachePut update, which succeeds,
|
||||
and then fails on the next step. The transaction rolls back.
|
||||
|
||||
what the cache serves : Alice Cooper
|
||||
what the database has : Alice
|
||||
```
|
||||
|
||||
The cache is now holding a name no transaction ever committed. Nothing will correct it until the
|
||||
entry expires or something evicts it — and on the default provider nothing expires.
|
||||
|
||||
The same shape with `@CacheEvict` is self-healing:
|
||||
|
||||
```
|
||||
after the rollback, nameOf(2) -> Bob
|
||||
database loads: 2 <- the entry was evicted, so this one reloaded
|
||||
```
|
||||
|
||||
That asymmetry is the practical takeaway. **An eviction that fires too early costs a lookup; a
|
||||
put that fires too early costs correctness.** When in doubt, evict.
|
||||
|
||||
## `TransactionAwareCacheManagerProxy`
|
||||
|
||||
Wrapping the manager defers every put and evict to a post-commit synchronisation:
|
||||
|
||||
```java
|
||||
@Bean
|
||||
CacheManager cacheManager() {
|
||||
return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
|
||||
}
|
||||
```
|
||||
|
||||
`docs/output/19-transaction-aware.txt` runs the identical rollback and the cache still says
|
||||
`Alice`. Several `CacheManager` implementations expose the same thing as
|
||||
`setTransactionAware(true)` (they extend `AbstractTransactionSupportingCacheManager`);
|
||||
`TransactionAwareCacheManagerProxy` is the generic wrapper, and it lives in
|
||||
**`spring-context-support`**, not `spring-context`.
|
||||
|
||||
### What it does not cover
|
||||
|
||||
- **Reads are never deferred.** A `@Cacheable` lookup inside the transaction sees whatever the
|
||||
shared cache holds.
|
||||
- **Outside a transaction it is a pass-through**, so the same method reached from an unmanaged
|
||||
path writes immediately.
|
||||
- **It does not make the cache transactional.** Two concurrent transactions still race at commit
|
||||
time; last writer wins, and it may be the one whose value is older.
|
||||
|
||||
### A correction worth recording
|
||||
|
||||
`spring-framework#23192` reported that `beforeInvocation = true` was swallowed by the
|
||||
transaction-aware decorator — the evict got deferred to commit, which is the opposite of what the
|
||||
attribute asks for. That is widely repeated and, on 7.0.9, **no longer true**. The measurement in
|
||||
`docs/output/19-transaction-aware.txt` came back the other way round, and
|
||||
`docs/output/22-decorator-bytecode.txt` shows why:
|
||||
|
||||
```
|
||||
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
|
||||
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
|
||||
```
|
||||
|
||||
`AbstractCacheInvoker.doEvict(Cache, Object, boolean)` picks `evictIfPresent` for the immediate
|
||||
path, and `TransactionAwareCacheDecorator` only registers a synchronisation in `evict`.
|
||||
`evictIfPresent` delegates straight to the target cache.
|
||||
|
||||
This was written the wrong way round first and the run corrected it, which is a reasonable
|
||||
advertisement for running the thing.
|
||||
|
||||
## The pattern that actually holds up
|
||||
|
||||
For anything that must not serve uncommitted state:
|
||||
|
||||
1. Evict, do not put — `@CacheEvict(beforeInvocation = true)`.
|
||||
2. Make the cache transaction-aware if you also need the eviction not to happen on rollback.
|
||||
3. Give every mutable cache a TTL, so the worst case is bounded even when all of the above is
|
||||
wrong.
|
||||
|
||||
Related reading: [`@Transactional`: propagation, isolation and the silent
|
||||
failures](https://ankurm.com/transactional-propagation-isolation-silent-failures/) covers the
|
||||
interceptor ordering from the transaction side.
|
||||
@@ -0,0 +1,67 @@
|
||||
[← transactions](10-transactions.md) · [next: production checklist →](12-production-checklist.md)
|
||||
|
||||
# 11. Seeing what is actually happening
|
||||
|
||||
Most caching bugs stop being mysterious the moment you can see the keys. Four things to reach
|
||||
for, in order of how quickly they answer the question.
|
||||
|
||||
## 1. Print the cache
|
||||
|
||||
The diagnostic endpoint in this module walks the `CacheManager` and dumps every entry with the
|
||||
runtime class of the key and the value. `docs/output/23-diagnostics.txt`:
|
||||
|
||||
```json
|
||||
"shapes": {
|
||||
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
|
||||
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
|
||||
"entries": {
|
||||
"SimpleKey [] [SimpleKey]": "zero [String]",
|
||||
"SimpleKey [abc, 7] [SimpleKey]": "two:abc:7 [String]",
|
||||
"abc [String]": "one:abc [String]"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A `SimpleKey []` where you expected an id, two methods writing into one key space, or a
|
||||
`NullValue` sitting where a record should be — all visible at a glance.
|
||||
|
||||
**Delete it before shipping.** It exposes cached data over HTTP with no authorisation. If you
|
||||
want something permanent, put it behind Actuator's security and return key counts rather than
|
||||
values.
|
||||
|
||||
## 2. Is the bean even proxied?
|
||||
|
||||
```java
|
||||
AopUtils.isAopProxy(bean) // false -> nothing downstream matters
|
||||
bean.getClass().getName() // ...$$SpringCGLIB$$0
|
||||
AopUtils.getTargetClass(bean)
|
||||
```
|
||||
|
||||
If it is proxied and the cache is still empty, the call is not going through the proxy. See
|
||||
[chapter 3](03-self-invocation.md).
|
||||
|
||||
## 3. Actuator
|
||||
|
||||
`management.endpoints.web.exposure.include=caches` gives `/actuator/caches`, which lists the
|
||||
cache names and their `CacheManager` — enough to confirm which provider is live and whether a
|
||||
cache name is a typo. `DELETE /actuator/caches/{name}` clears one, which is a genuinely useful
|
||||
operational lever.
|
||||
|
||||
`/actuator/metrics/cache.gets` and friends are populated automatically for providers Micrometer
|
||||
can instrument. **Caffeine only reports statistics if the cache was built with `recordStats()`**
|
||||
— without it the metrics exist and read zero, which looks exactly like a cache nobody is using.
|
||||
|
||||
## 4. Turn the cache off
|
||||
|
||||
```properties
|
||||
spring.cache.type=none
|
||||
```
|
||||
|
||||
A `NoOpCacheManager`: every method runs every time, annotations untouched. If the bug survives,
|
||||
it was never the cache. This is the fastest bisect available and it takes one property.
|
||||
|
||||
## Logging
|
||||
|
||||
`logging.level.org.springframework.cache=TRACE` logs each operation the interceptor resolves.
|
||||
It is noisy enough that it is a debugging tool rather than something to leave on, but it answers
|
||||
"did the interceptor see this call at all" definitively.
|
||||
@@ -0,0 +1,56 @@
|
||||
[← diagnostics](11-diagnostics.md) · [README](../README.md)
|
||||
|
||||
# 12. Before this goes to production
|
||||
|
||||
## Should there be a cache here at all?
|
||||
|
||||
Be honest about the answer. A cache is a correctness liability you accept in exchange for
|
||||
latency. If the method is not measurably slow, or the hit rate will be low, or the data must be
|
||||
current, the right amount of caching is none. Half the caches in a typical codebase were added
|
||||
without a measurement and are never revisited.
|
||||
|
||||
## The checklist
|
||||
|
||||
**Configuration**
|
||||
|
||||
- [ ] `spring.cache.type` is set explicitly, so a new dependency cannot change the provider
|
||||
([chapter 8](08-providers-and-ttl.md))
|
||||
- [ ] `spring.cache.cache-names` declares every cache, so a typo fails loudly
|
||||
- [ ] Every mutable cache has a TTL. It bounds the damage from every other mistake on this list
|
||||
- [ ] Every cache has a size bound, or the data set is provably small
|
||||
- [ ] `@EnableCaching` is not on the main application class
|
||||
|
||||
**Correctness**
|
||||
|
||||
- [ ] One cache name per method, or an explicit `key` that includes the method
|
||||
([chapter 4](04-keys.md))
|
||||
- [ ] Keys are immutable and serialise to something stable
|
||||
- [ ] Cached values are DTOs, not JPA entities ([chapter 9](09-versus-hibernate-l2.md))
|
||||
- [ ] Cached values are immutable, or defensively copied — the map hands every caller the same
|
||||
instance
|
||||
- [ ] Mutating methods evict rather than put, with `beforeInvocation = true`
|
||||
([chapter 5](05-eviction.md))
|
||||
- [ ] Nothing relies on `this.cachedMethod(...)` ([chapter 3](03-self-invocation.md))
|
||||
- [ ] Every cached method is called at least once by a test — four of the five invalid
|
||||
declarations in [chapter 2](02-the-three-annotations.md) only fail at the first call
|
||||
|
||||
**Operations**
|
||||
|
||||
- [ ] Hit rate and eviction count are on a dashboard (`recordStats()` for Caffeine)
|
||||
- [ ] There is a way to clear a cache without a deployment (`DELETE /actuator/caches/{name}`)
|
||||
- [ ] A `CacheErrorHandler` decision has been made per cache, not inherited by accident
|
||||
([chapter 5](05-eviction.md))
|
||||
- [ ] The behaviour with `spring.cache.type=none` has been tried at least once
|
||||
|
||||
**Distributed caches only**
|
||||
|
||||
- [ ] Values are serializable and the format survives a rolling deployment — a changed DTO shape
|
||||
with old entries still in Redis fails on read, per instance, at whatever hour
|
||||
- [ ] `spring.cache.redis.key-prefix` keeps this application out of everyone else's keyspace
|
||||
- [ ] The failure mode when the cache is unreachable has been decided: degrade or fail
|
||||
- [ ] TTLs are short enough that a missed eviction self-corrects
|
||||
|
||||
## The one-line version
|
||||
|
||||
Give every cache a TTL, evict rather than put, cache DTOs, and set `spring.cache.type`. Those
|
||||
four cover most of what goes wrong.
|
||||
@@ -0,0 +1,15 @@
|
||||
# A cache hit is a method that did not run
|
||||
|
||||
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
|
||||
repository latency : 200 ms per lookup
|
||||
|
||||
--- first call (miss) ---
|
||||
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
|
||||
elapsed : 200 ms
|
||||
repository calls : 1
|
||||
|
||||
--- second call (hit) ---
|
||||
returned : Book[isbn=978-0134685991, title=Effective Java, year=2018]
|
||||
elapsed : 0 ms
|
||||
repository calls : 1 <- still 1, the method body never ran
|
||||
same object? : true
|
||||
@@ -0,0 +1,11 @@
|
||||
# @Cacheable, @CachePut and @CacheEvict on the same cache
|
||||
|
||||
after findBook : repository calls = 1
|
||||
@CachePut wrote : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
|
||||
next findBook returns : Book[isbn=978-0134685991, title=Effective Java (3rd ed.), year=2018]
|
||||
repository calls : 1 <- @CachePut refreshed the entry, no reload
|
||||
|
||||
after @CacheEvict : findBook -> Book[isbn=978-0134685991, title=Effective Java, year=2018]
|
||||
repository calls : 2 <- the entry was gone, so the method ran again
|
||||
|
||||
after allEntries=true : repository calls = 5 <- both entries were dropped
|
||||
@@ -0,0 +1,13 @@
|
||||
# Four ways to call a @Cacheable method, one of which caches nothing
|
||||
|
||||
injected bean class : com.ankurm.caching.selfinvocation.CatalogService$$SpringCGLIB$$0
|
||||
is an AOP proxy? : true
|
||||
is a CGLIB proxy? : true
|
||||
target class : com.ankurm.caching.selfinvocation.CatalogService
|
||||
|
||||
Four ISBNs, two of them repeats. A working cache does 2 lookups, not 4.
|
||||
|
||||
this.lookup(..) -> 4 repository calls <- no caching at all
|
||||
self.getObject().lookup(..) -> 2 repository calls
|
||||
AopContext.currentProxy() -> 2 repository calls
|
||||
a second bean calls lookup(..) -> 2 repository calls
|
||||
@@ -0,0 +1,13 @@
|
||||
# Two more places the annotation is ignored without a warning
|
||||
|
||||
@Cacheable on a protected method, called twice -> 2 repository calls
|
||||
No warning is logged. In proxy mode the annotation is only honoured on
|
||||
public methods; a protected one is simply never advised.
|
||||
|
||||
--- @EnableCaching attributes, as the class file declares them ---
|
||||
AdviceMode mode()
|
||||
int order()
|
||||
boolean proxyTargetClass()
|
||||
|
||||
There is no exposeProxy attribute, so @EnableCaching(exposeProxy = true)
|
||||
- which a lot of answers recommend - does not compile.
|
||||
@@ -0,0 +1,14 @@
|
||||
# What SimpleKeyGenerator actually puts in the map
|
||||
|
||||
cache "shapes" after three calls with 0, 1 and 2 arguments:
|
||||
|
||||
cache "shapes":
|
||||
key abc [String] -> one:abc
|
||||
key SimpleKey [abc, 7] [SimpleKey] -> two:abc:7
|
||||
key SimpleKey [] [SimpleKey] -> zero
|
||||
|
||||
Zero arguments -> the SimpleKey.EMPTY constant, printed as []
|
||||
One argument -> that argument itself, unwrapped
|
||||
Two or more -> a SimpleKey holding all of them
|
||||
|
||||
The method name and the declaring class appear nowhere in the key.
|
||||
@@ -0,0 +1,25 @@
|
||||
# The collision the default key generator makes easy
|
||||
|
||||
countLetters(String) and countDigits(String) both write into cache "shared".
|
||||
|
||||
countLetters("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
|
||||
countDigits("a1b2") -> letters=2 (repository calls: letters=1 digits=0)
|
||||
|
||||
countDigits never ran. It found the key "a1b2" already populated and
|
||||
returned the answer to a different question.
|
||||
|
||||
cache "shared":
|
||||
key a1b2 [String] -> letters=2
|
||||
|
||||
--- no-argument methods collide even harder ---
|
||||
currentBanner() -> banner-from-currentBanner
|
||||
currentFooter() -> banner-from-currentBanner <- both key on SimpleKey.EMPTY
|
||||
cache "noargs":
|
||||
key SimpleKey [] [SimpleKey] -> banner-from-currentBanner
|
||||
|
||||
--- the fix: put the method into the key ---
|
||||
countLettersScoped("a1b2") -> letters=2
|
||||
countDigitsScoped("a1b2") -> digits=2
|
||||
cache "scoped":
|
||||
key digits:a1b2 [String] -> digits=2
|
||||
key letters:a1b2 [String] -> letters=2
|
||||
@@ -0,0 +1,18 @@
|
||||
# A mutable argument is an entry you cannot find again
|
||||
|
||||
first call : byList([java]) -> tags=[java]
|
||||
cache "mutable":
|
||||
key [java] [ArrayList] -> tags=[java]
|
||||
|
||||
the caller mutates the same list it passed in: [java, spring]
|
||||
second call : byList([java, spring]) -> tags=[java, spring]
|
||||
|
||||
cache "mutable":
|
||||
key [java, spring] [ArrayList] -> tags=[java]
|
||||
key [java, spring] [ArrayList] -> tags=[java, spring]
|
||||
|
||||
Two entries, and their keys now print identically - because they are the
|
||||
same object. The caller mutated the list it had already handed over as a
|
||||
key, so the first entry sits in the map under a hashCode the map no longer
|
||||
agrees with. Nothing will find it again and nothing will evict it: a leak
|
||||
with a completely ordinary-looking cause.
|
||||
@@ -0,0 +1,17 @@
|
||||
# @CacheEvict runs after the method - unless you ask otherwise
|
||||
|
||||
price("sku-1") -> 100 (stored price is 100)
|
||||
|
||||
updatePrice("sku-1", 250, fail=true) threw after writing the new price.
|
||||
stored price now : 250
|
||||
price("sku-1") : 100 <- the cache still serves the old value
|
||||
reads of the real store: 1
|
||||
|
||||
--- beforeInvocation = true ---
|
||||
price("sku-2") -> 100
|
||||
updatePriceEvictFirst("sku-2", 250, fail=true) threw the same way.
|
||||
price("sku-2") : 250 <- the entry went first, so the next read is honest
|
||||
|
||||
--- @CachePut instead: write through, no miss ---
|
||||
after @CachePut, price("sku-3") -> 400
|
||||
reads of the real store: 1 -> 1 <- no reload was needed
|
||||
@@ -0,0 +1,7 @@
|
||||
# condition is checked before the call, unless after it
|
||||
|
||||
search("spring") twice, 6 characters -> 1 invocations
|
||||
search(25 chars) twice, condition false -> 2 invocations
|
||||
|
||||
condition = "#term.length() <= 8" is evaluated on the arguments before the
|
||||
method runs, so a false condition skips the lookup and the write.
|
||||
@@ -0,0 +1,16 @@
|
||||
# A cached null is a real entry called NullValue
|
||||
|
||||
searchCachingNulls("xyz") returns null, called twice -> 1 invocations
|
||||
|
||||
cache "nulls":
|
||||
key xyz -> null [org.springframework.cache.support.NullValue]
|
||||
|
||||
The abstraction stores org.springframework.cache.support.NullValue.INSTANCE
|
||||
so a hit on null is distinguishable from a miss. This is usually what you
|
||||
want - it is the cheap defence against a hot lookup for a row that is not
|
||||
there - and occasionally exactly what you do not want.
|
||||
|
||||
--- unless = "#result == null" ---
|
||||
searchNullable("xyz") twice -> 2 invocations <- the null was never stored
|
||||
cache "terms":
|
||||
(empty)
|
||||
@@ -0,0 +1,11 @@
|
||||
# sync = true is the difference between one slow call and sixteen
|
||||
|
||||
16 threads call the same key at the same instant, cold cache.
|
||||
The method sleeps 300 ms.
|
||||
|
||||
@Cacheable("reports") -> 16 invocations
|
||||
@Cacheable("syncedReports", sync = true) -> 1 invocation
|
||||
|
||||
Without sync, every thread that arrives during the 300 ms window misses and
|
||||
runs the method. That is a cache stampede, and it is worst exactly when the
|
||||
cache matters most - right after a restart or an eviction.
|
||||
@@ -0,0 +1,12 @@
|
||||
# @Cacheable on a CompletableFuture-returning method
|
||||
|
||||
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
setAsyncCacheMode(true) was called on it.
|
||||
|
||||
first -> async-report:q3
|
||||
second -> async-report:q3
|
||||
supplier invocations: 1
|
||||
|
||||
Since Spring Framework 6.1 the interceptor unwraps CompletableFuture and
|
||||
the reactive types. ConcurrentMapCacheManager adapts to future-based
|
||||
retrieval on its own; CaffeineCacheManager has to be told.
|
||||
@@ -0,0 +1,22 @@
|
||||
# A rollback does not roll the cache back
|
||||
|
||||
cacheManager : org.springframework.cache.concurrent.ConcurrentMapCacheManager
|
||||
|
||||
nameOf(1) -> Alice
|
||||
|
||||
An outer @Transactional method calls the @CachePut update, which succeeds,
|
||||
and then fails on the next step. The transaction rolls back.
|
||||
|
||||
what the cache serves : Alice Cooper
|
||||
what the database has : Alice
|
||||
|
||||
The cache is now holding a name that no transaction ever committed. Nothing
|
||||
will correct it until the entry expires or something evicts it.
|
||||
|
||||
--- the same shape with @CacheEvict ---
|
||||
nameOf(2) -> Bob
|
||||
after the rollback, nameOf(2) -> Bob
|
||||
database loads: 2 <- the entry was evicted, so this one reloaded
|
||||
|
||||
An eviction that fires too early is self-healing: the next read goes to the
|
||||
database and re-populates correctly. A @CachePut that fires too early is not.
|
||||
@@ -0,0 +1,16 @@
|
||||
# Caching an entity caches a detached object, lazy proxies and all
|
||||
|
||||
loadEntity(1) -> Alice (com.ankurm.caching.jpa.Customer)
|
||||
database loads: 1
|
||||
second call returns the same instance? true
|
||||
database loads: 1
|
||||
|
||||
--- touching the lazy collection outside the session ---
|
||||
org.hibernate.LazyInitializationException
|
||||
Cannot lazily initialize collection of role 'com.ankurm.caching.jpa.Customer.orders' with key '1' (no session)
|
||||
|
||||
This is the line between the two caches. Hibernate's second-level cache
|
||||
stores dehydrated entity state and rebuilds a managed entity inside a
|
||||
session, so lazy associations still work. The Spring cache abstraction
|
||||
stores the object your method returned, exactly as it was when the
|
||||
transaction ended - detached, with whatever its proxies were holding.
|
||||
@@ -0,0 +1,17 @@
|
||||
# TTL and size bounds are the provider's job, not the abstraction's
|
||||
|
||||
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
configured : expireAfterWrite=400ms, maximumSize=3, recordStats
|
||||
|
||||
two calls, same key, immediately -> 1 repository calls
|
||||
one more call 600 ms later -> 2 repository calls <- the entry expired
|
||||
|
||||
--- size bound ---
|
||||
five distinct keys written, maximumSize = 3
|
||||
estimated size after eviction settles : 3
|
||||
stats : hits=1 misses=5 evictions=3
|
||||
|
||||
The Spring cache abstraction has no TTL, no size limit and no eviction
|
||||
policy of its own - it is an interface over whatever you plug in. On the
|
||||
default simple provider, a ConcurrentHashMap, an entry stays until something
|
||||
evicts it by hand or the process ends.
|
||||
@@ -0,0 +1,20 @@
|
||||
# What @EnableCaching and Boot's auto-configuration put in the context
|
||||
|
||||
CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
caches known at startup : [asyncReports]
|
||||
|
||||
bean cacheInterceptor present=true
|
||||
bean cacheOperationSource present=true
|
||||
bean cacheAdvisor present=false
|
||||
bean org.springframework.cache.config.internalCacheAdvisor present=true
|
||||
|
||||
CacheInterceptor beans : [cacheInterceptor]
|
||||
KeyGenerator beans : []
|
||||
|
||||
--- where the auto-configuration class lives ---
|
||||
FOUND org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration
|
||||
absent org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration
|
||||
|
||||
Boot 4 split spring-boot-autoconfigure into per-technology modules. Caching
|
||||
auto-configuration now ships in spring-boot-cache, which the
|
||||
spring-boot-starter-cache starter pulls in.
|
||||
@@ -0,0 +1,10 @@
|
||||
# The same method on the auto-configured Caffeine manager
|
||||
|
||||
cacheManager : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
The application started cleanly. Nothing warned about anything.
|
||||
|
||||
buildAsync("q3") ->
|
||||
java.lang.IllegalStateException: No Caffeine AsyncCache available: set CaffeineCacheManager.setAsyncCacheMode(true)
|
||||
|
||||
Thrown on the first invocation, in production, at whatever hour that
|
||||
endpoint first gets traffic.
|
||||
@@ -0,0 +1,15 @@
|
||||
# Nothing in application.yml selects a provider. Something still chose one.
|
||||
|
||||
spring.cache.type : (not set)
|
||||
resolved CacheManager bean : org.springframework.cache.caffeine.CaffeineCacheManager
|
||||
|
||||
Caffeine is on this module's classpath because a later chapter needs TTL and
|
||||
size bounds. That single dependency moved every cache in the application off
|
||||
the ConcurrentHashMap-backed 'simple' provider, because Boot walks a fixed
|
||||
detection order and stops at the first provider it finds:
|
||||
|
||||
1 Generic 2 JCache 3 Hazelcast 4 Infinispan 5 Couchbase
|
||||
6 Redis 7 Caffeine 8 Cache2k 9 Simple
|
||||
|
||||
Nothing logs the decision at INFO. If a cache suddenly starts expiring
|
||||
entries, or stops, look at what changed in the dependency tree.
|
||||
@@ -0,0 +1,25 @@
|
||||
# TransactionAwareCacheManagerProxy, and what it does not cover
|
||||
|
||||
cacheManager : org.springframework.cache.transaction.TransactionAwareCacheManagerProxy
|
||||
|
||||
nameOf(1) -> Alice
|
||||
after the identical rollback, nameOf(1) -> Alice
|
||||
|
||||
The put was registered as a transaction synchronisation and dropped when the
|
||||
transaction rolled back instead of committing.
|
||||
|
||||
--- what it does not cover: beforeInvocation = true ---
|
||||
inside the same transaction, after an evict declared beforeInvocation=true,
|
||||
a re-read returns : Bobby
|
||||
|
||||
Not the stale value. The eviction was NOT deferred, and the re-read went to
|
||||
the database and saw the uncommitted row. The reason is in the bytecode:
|
||||
AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent()
|
||||
when immediate is true and evict() when it is false, and the decorator only
|
||||
registers a post-commit synchronisation in evict() - evictIfPresent()
|
||||
delegates straight to the target cache. See docs/output/22-decorator-bytecode.txt.
|
||||
|
||||
Two gaps do remain, and they are structural rather than measurable here:
|
||||
reads are never deferred, so a @Cacheable lookup inside the transaction sees
|
||||
whatever the shared cache holds; and outside a transaction the proxy is a
|
||||
pass-through that writes immediately.
|
||||
@@ -0,0 +1,29 @@
|
||||
# Declarations that are rejected, and how late you find out
|
||||
|
||||
1. key and keyGenerator together
|
||||
startup : FAILED - java.lang.IllegalStateException
|
||||
Invalid cache annotation configuration on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$BothKeyAndGenerator$Svc.call(java.lang.String)'. Both 'key' and 'keyGenerator' attributes have been set. These attributes are mutually exclusive: either set the SpEL expression used tocompute the key at runtime or set the name of the KeyGenerator bean to use.
|
||||
|
||||
--- 2. sync = true with unless ---
|
||||
startup : clean
|
||||
first call: java.lang.IllegalStateException
|
||||
A sync=true operation does not support the unless attribute on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncWithUnless$Svc.call(java.lang.String)] caches=[c] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='#result != null' | sync='true''
|
||||
|
||||
--- 3. sync = true across two caches ---
|
||||
startup : clean
|
||||
first call: java.lang.IllegalStateException
|
||||
A sync=true operation is restricted to a single cache on 'Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$SyncTwoCaches$Svc.call(java.lang.String)] caches=[c1, c2] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='true''
|
||||
|
||||
--- 4. @Cacheable and @CacheEvict on one method ---
|
||||
startup : clean
|
||||
first call: java.lang.IllegalStateException
|
||||
A sync=true operation cannot be combined with other cache operations on 'public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$CacheableAndEvict$Svc.call(java.lang.String)'
|
||||
|
||||
--- 5. a cache name that spring.cache.cache-names does not declare ---
|
||||
startup : clean
|
||||
first call: java.lang.IllegalArgumentException
|
||||
Cannot find cache named 'unknown' for Builder[public java.lang.String com.ankurm.caching.InvalidDeclarationsTest$UndeclaredCache$Svc.call(java.lang.String)] caches=[unknown] | key='' | keyGenerator='' | cacheManager='' | cacheResolver='' | condition='' | unless='' | sync='false'
|
||||
|
||||
Only the first of these is a compile-time-shaped mistake. The rest start a
|
||||
perfectly healthy application and throw on a code path that may not be hit
|
||||
for hours.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Why beforeInvocation=true is not deferred by TransactionAwareCacheManagerProxy
|
||||
|
||||
$ javap -c -p org.springframework.cache.interceptor.AbstractCacheInvoker # spring-context-7.0.9.jar
|
||||
protected void doEvict(org.springframework.cache.Cache, java.lang.Object, boolean);
|
||||
Code:
|
||||
0: iload_3
|
||||
1: ifeq 15
|
||||
4: aload_1
|
||||
5: aload_2
|
||||
6: invokeinterface #83, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
|
||||
11: pop
|
||||
12: goto 22
|
||||
15: aload_1
|
||||
16: aload_2
|
||||
17: invokeinterface #87, 2 // InterfaceMethod org/springframework/cache/Cache.evict:(Ljava/lang/Object;)V
|
||||
22: goto 40
|
||||
25: astore 4
|
||||
|
||||
$ javap -c -p org.springframework.cache.transaction.TransactionAwareCacheDecorator # spring-context-support-7.0.9.jar
|
||||
public void evict(java.lang.Object);
|
||||
Code:
|
||||
0: invokestatic #48 // Method org/springframework/transaction/support/TransactionSynchronizationManager.isSynchronizationActive:()Z
|
||||
3: ifeq 21
|
||||
6: new #71 // class org/springframework/cache/transaction/TransactionAwareCacheDecorator$2
|
||||
9: dup
|
||||
10: aload_0
|
||||
11: aload_1
|
||||
12: invokespecial #73 // Method org/springframework/cache/transaction/TransactionAwareCacheDecorator$2."<init>":(Lorg/springframework/cache/transaction/TransactionAwareCacheDecorator;Ljava/lang/Object;)V
|
||||
15: invokestatic #59 // Method org/springframework/transaction/support/TransactionSynchronizationManager.registerSynchronization:(Lorg/springframework/transaction/support/TransactionSynchronization;)V
|
||||
18: goto 31
|
||||
21: aload_0
|
||||
22: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
|
||||
25: aload_1
|
||||
public boolean evictIfPresent(java.lang.Object);
|
||||
Code:
|
||||
0: aload_0
|
||||
1: getfield #15 // Field targetCache:Lorg/springframework/cache/Cache;
|
||||
4: aload_1
|
||||
5: invokeinterface #80, 2 // InterfaceMethod org/springframework/cache/Cache.evictIfPresent:(Ljava/lang/Object;)Z
|
||||
10: ireturn
|
||||
|
||||
|
||||
doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache
|
||||
doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit
|
||||
|
||||
spring-framework#23192 reported beforeInvocation=true being swallowed by the
|
||||
transaction-aware decorator. On 7.0.9 it is not: the immediate path uses a method
|
||||
the decorator does not intercept. Note also that the decorator ships in
|
||||
spring-context-support, not spring-context.
|
||||
@@ -0,0 +1,34 @@
|
||||
# The live contents of every cache, keys included
|
||||
|
||||
$ curl -s localhost:8080/diag/warm
|
||||
warmed: books, shapes, nulls
|
||||
|
||||
$ curl -s localhost:8080/diag/caches | jq .
|
||||
{
|
||||
"cacheManager": "org.springframework.cache.concurrent.ConcurrentMapCacheManager",
|
||||
"caches": {
|
||||
"nulls": {
|
||||
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
|
||||
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
|
||||
"entries": {
|
||||
"xyz [String]": "null [NullValue]"
|
||||
}
|
||||
},
|
||||
"books": {
|
||||
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
|
||||
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
|
||||
"entries": {
|
||||
"978-0134685991 [String]": "Book[isbn=978-0134685991, title=Effective Java, year=2018] [Book]"
|
||||
}
|
||||
},
|
||||
"shapes": {
|
||||
"implementation": "org.springframework.cache.concurrent.ConcurrentMapCache",
|
||||
"nativeStore": "java.util.concurrent.ConcurrentHashMap",
|
||||
"entries": {
|
||||
"SimpleKey [] [SimpleKey]": "zero [String]",
|
||||
"SimpleKey [abc, 7] [SimpleKey]": "two:abc:7 [String]",
|
||||
"abc [String]": "one:abc [String]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user