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:
@@ -18,6 +18,7 @@ files.
|
|||||||
| [`kubernetes-deployment/`](kubernetes-deployment) | [Deploying Spring Boot 4 on Kubernetes](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/) | probe groups under a dependency outage, graceful shutdown under load four ways, JVM ergonomics per pod shape, CPU limits throttling GC, HPA on a Micrometer metric |
|
| [`kubernetes-deployment/`](kubernetes-deployment) | [Deploying Spring Boot 4 on Kubernetes](https://ankurm.com/spring-boot-4-kubernetes-probes-graceful-shutdown-cpu-limits-hpa/) | probe groups under a dependency outage, graceful shutdown under load four ways, JVM ergonomics per pod shape, CPU limits throttling GC, HPA on a Micrometer metric |
|
||||||
| [`problem-details/`](problem-details) | [Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/) | thirteen failures under five handling setups, validation errors, i18n, content negotiation, errors outside MVC, silent 500s, decoding on the client |
|
| [`problem-details/`](problem-details) | [Global Exception Handling with ProblemDetail (RFC 9457) in Spring Boot 4](https://ankurm.com/spring-boot-4-problemdetail-rfc-9457-global-exception-handling/) | thirteen failures under five handling setups, validation errors, i18n, content negotiation, errors outside MVC, silent 500s, decoding on the client |
|
||||||
| [`resilience/`](resilience) | [Spring Framework 7's Built-in Resilience: @Retryable, @ConcurrencyLimit, and What's Left for Resilience4j](https://ankurm.com/spring-framework-7-retryable-concurrencylimit-resilience4j/) | `@Retryable` and `@ConcurrencyLimit` counted invocation by invocation, retries inside transactions, where Resilience4j still earns its place, migrating from Spring Retry |
|
| [`resilience/`](resilience) | [Spring Framework 7's Built-in Resilience: @Retryable, @ConcurrencyLimit, and What's Left for Resilience4j](https://ankurm.com/spring-framework-7-retryable-concurrencylimit-resilience4j/) | `@Retryable` and `@ConcurrencyLimit` counted invocation by invocation, retries inside transactions, where Resilience4j still earns its place, migrating from Spring Retry |
|
||||||
|
| [`caching/`](caching) | [The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators and the Self-Invocation Trap](https://ankurm.com/) | the self-invocation trap measured four ways, the key collision `SimpleKeyGenerator` makes easy, eviction timing under a thrown exception, a rollback the cache keeps, and where this sits next to Hibernate's L2 cache |
|
||||||
|
|
||||||
Articles whose text is kept here rather than only on the blog have it under
|
Articles whose text is kept here rather than only on the blog have it under
|
||||||
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
`<directory>/post/` — `post.md` for the body and `meta.md` for the title, excerpt and
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
# caching
|
||||||
|
|
||||||
|
Companion project for **[The Spring Cache Abstraction: @Cacheable, @CacheEvict, Key Generators
|
||||||
|
and the Self-Invocation Trap](https://ankurm.com/)** on [ankurm.com](https://ankurm.com).
|
||||||
|
|
||||||
|
Every number, key dump, stack trace and error message quoted in the article came out of
|
||||||
|
`docs/output/`, and every one of those files is regenerated by one script. Most of them are
|
||||||
|
produced by the test suite, so if a claim stops being true the build goes red.
|
||||||
|
|
||||||
|
## Versions
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| Spring Boot | 4.1.1 |
|
||||||
|
| Spring Framework | 7.0.9 |
|
||||||
|
| Caffeine | managed by Boot |
|
||||||
|
| JDK | 25 (Temurin 25.0.4.1+1) |
|
||||||
|
| Maven | 3.9 |
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export JAVA_HOME=/path/to/jdk-25
|
||||||
|
mvn -DskipTests package
|
||||||
|
./scripts/run-all.sh # regenerates every file under docs/output/
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the application on its own to poke at the diagnostics endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
java -jar target/caching-1.0.0.jar --spring.cache.type=simple
|
||||||
|
curl -s localhost:8080/diag/warm
|
||||||
|
curl -s localhost:8080/diag/caches | jq .
|
||||||
|
```
|
||||||
|
|
||||||
|
## Profiles
|
||||||
|
|
||||||
|
| Profile | What it changes |
|
||||||
|
|---|---|
|
||||||
|
| *(none)* | Boot's auto-detected provider. Caffeine is on the classpath, so that is what you get |
|
||||||
|
| `caffeine` | An explicit `CaffeineCacheManager` with `expireAfterWrite=400ms`, `maximumSize=3`, `recordStats()` and `setAsyncCacheMode(true)` |
|
||||||
|
| `txaware` | `TransactionAwareCacheManagerProxy` over `ConcurrentMapCacheManager` |
|
||||||
|
|
||||||
|
Most tests pin `spring.cache.type=simple` so the key dumps show a real `ConcurrentHashMap`.
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
| Endpoint | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `GET /diag/warm` | Calls a few cached methods so there is something to look at |
|
||||||
|
| `GET /diag/caches` | Every cache, every key, with the runtime class of key and value |
|
||||||
|
| `GET /actuator/caches` | Boot's own view: cache names and their manager |
|
||||||
|
| `GET /actuator/metrics/cache.gets` | Hit/miss counters, where the provider reports them |
|
||||||
|
|
||||||
|
`/diag/*` is a debugging aid with no authorisation. Delete it before shipping anything.
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
| Chapter | Covers |
|
||||||
|
|---|---|
|
||||||
|
| [01 — What the cache abstraction is](docs/01-what-caching-is.md) | the interceptor and the `Cache` interface; when not to cache |
|
||||||
|
| [02 — The annotations, attribute by attribute](docs/02-the-three-annotations.md) | every attribute from `javap`; five declarations that are rejected and when |
|
||||||
|
| [03 — The self-invocation trap](docs/03-self-invocation.md) | why `this.method()` caches nothing, three fixes ranked, non-public methods, `@PostConstruct` |
|
||||||
|
| [04 — Keys](docs/04-keys.md) | `SimpleKeyGenerator`'s three rules, the collision it makes easy, the SpEL surface, mutable keys |
|
||||||
|
| [05 — Eviction](docs/05-eviction.md) | `beforeInvocation`, what a thrown exception does, `allEntries`, `CacheErrorHandler` |
|
||||||
|
| [06 — Conditions and nulls](docs/06-conditions-and-nulls.md) | `condition` vs `unless`, `NullValue`, cache penetration |
|
||||||
|
| [07 — `sync` and async](docs/07-sync-and-async.md) | the stampede measured, the four `sync=true` restrictions, `CompletableFuture` |
|
||||||
|
| [08 — Providers and TTL](docs/08-providers-and-ttl.md) | detection order, the dependency that silently changes your provider, per-provider properties |
|
||||||
|
| [09 — This is not the Hibernate L2 cache](docs/09-versus-hibernate-l2.md) | three layers compared; why caching an entity gives you a shared detached object |
|
||||||
|
| [10 — Transactions](docs/10-transactions.md) | a rollback that does not roll the cache back; `TransactionAwareCacheManagerProxy` and a correction to a widely repeated claim |
|
||||||
|
| [11 — Diagnostics](docs/11-diagnostics.md) | four ways to see what the cache is doing |
|
||||||
|
| [12 — Before production](docs/12-production-checklist.md) | the checklist, and whether to cache at all |
|
||||||
|
|
||||||
|
## Captured output
|
||||||
|
|
||||||
|
| File | What it shows |
|
||||||
|
|---|---|
|
||||||
|
| [`01-basics.txt`](docs/output/01-basics.txt) | 200 ms, then 0 ms, with the invocation counter |
|
||||||
|
| [`02-put-evict-clear.txt`](docs/output/02-put-evict-clear.txt) | the three annotations against one cache |
|
||||||
|
| [`03-self-invocation.txt`](docs/output/03-self-invocation.txt) | 4 vs 2 repository calls, four call styles |
|
||||||
|
| [`04-non-public-and-postconstruct.txt`](docs/output/04-non-public-and-postconstruct.txt) | a protected method silently uncached; `@EnableCaching`'s real attributes |
|
||||||
|
| [`05-key-shapes.txt`](docs/output/05-key-shapes.txt) | zero, one and two arguments in one map |
|
||||||
|
| [`06-key-collision.txt`](docs/output/06-key-collision.txt) | one method serving another method's answer |
|
||||||
|
| [`07-mutable-key.txt`](docs/output/07-mutable-key.txt) | two entries, one unreachable |
|
||||||
|
| [`08-evict-timing.txt`](docs/output/08-evict-timing.txt) | a failed update leaving a stale entry |
|
||||||
|
| [`09-conditions.txt`](docs/output/09-conditions.txt) | `condition` vetoing before the call |
|
||||||
|
| [`10-nulls.txt`](docs/output/10-nulls.txt) | `NullValue` in the map |
|
||||||
|
| [`11-stampede.txt`](docs/output/11-stampede.txt) | 16 invocations vs 1 |
|
||||||
|
| [`12-async-return-types.txt`](docs/output/12-async-return-types.txt) | `CompletableFuture` with async cache mode on |
|
||||||
|
| [`13-transactions.txt`](docs/output/13-transactions.txt) | the cache keeping a rolled-back value |
|
||||||
|
| [`14-cached-entity.txt`](docs/output/14-cached-entity.txt) | `LazyInitializationException` from a cached entity |
|
||||||
|
| [`15-providers-and-ttl.txt`](docs/output/15-providers-and-ttl.txt) | Caffeine expiry, size bound and stats |
|
||||||
|
| [`16-autoconfiguration.txt`](docs/output/16-autoconfiguration.txt) | what Boot wired, and where the auto-configuration lives in Boot 4 |
|
||||||
|
| [`17-async-cache-mode-missing.txt`](docs/output/17-async-cache-mode-missing.txt) | the runtime error a clean startup hides |
|
||||||
|
| [`18-provider-detection.txt`](docs/output/18-provider-detection.txt) | the provider nobody chose |
|
||||||
|
| [`19-transaction-aware.txt`](docs/output/19-transaction-aware.txt) | the deferred put, and the evict that is not deferred |
|
||||||
|
| [`20-invalid-declarations.txt`](docs/output/20-invalid-declarations.txt) | five rejected declarations, four of them at the first call |
|
||||||
|
| [`22-decorator-bytecode.txt`](docs/output/22-decorator-bytecode.txt) | `javap` proving why `beforeInvocation` is not deferred |
|
||||||
|
| [`23-diagnostics.txt`](docs/output/23-diagnostics.txt) | every cache, every key, live |
|
||||||
|
|
||||||
|
## Licence
|
||||||
|
|
||||||
|
MIT — see [LICENSE](../LICENSE).
|
||||||
@@ -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]"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<parent>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
|
<version>4.1.1</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.ankurm</groupId>
|
||||||
|
<artifactId>caching</artifactId>
|
||||||
|
<version>1.0.0</version>
|
||||||
|
<name>caching</name>
|
||||||
|
<description>The Spring cache abstraction: Cacheable, CacheEvict, key generators and the self-invocation trap</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>25</java.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-webmvc</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-cache</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Caffeine is the provider used for TTL, size bounds, stats and async cache mode.
|
||||||
|
Its version is managed by spring-boot-dependencies. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||||
|
<artifactId>caffeine</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JPA is here for exactly one reason: to show where the Spring cache abstraction sits
|
||||||
|
relative to the Hibernate second-level cache, and what a cached detached entity does. -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.h2database</groupId>
|
||||||
|
<artifactId>h2</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-maven-plugin</artifactId>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
Executable
+31
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Disassembles the two methods that decide whether a beforeInvocation=true eviction is deferred.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
M2="${HOME}/.m2/repository/org/springframework"
|
||||||
|
V=$(mvn -B -q help:evaluate -Dexpression=spring-framework.version -DforceStdout 2>/dev/null | tail -1)
|
||||||
|
CTX="${M2}/spring-context/${V}/spring-context-${V}.jar"
|
||||||
|
CS="${M2}/spring-context-support/${V}/spring-context-support-${V}.jar"
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Why beforeInvocation=true is not deferred by TransactionAwareCacheManagerProxy"
|
||||||
|
echo
|
||||||
|
echo "\$ javap -c -p org.springframework.cache.interceptor.AbstractCacheInvoker # spring-context-${V}.jar"
|
||||||
|
javap -c -p -cp "$CTX" org.springframework.cache.interceptor.AbstractCacheInvoker \
|
||||||
|
| grep -v '^Picked up' | awk '/protected void doEvict/,/^$/' | head -14
|
||||||
|
echo
|
||||||
|
echo "\$ javap -c -p org.springframework.cache.transaction.TransactionAwareCacheDecorator # spring-context-support-${V}.jar"
|
||||||
|
javap -c -p -cp "$CS" org.springframework.cache.transaction.TransactionAwareCacheDecorator \
|
||||||
|
| grep -v '^Picked up' | awk '/public void evict\(java.lang.Object\)/,/^$/' | head -14
|
||||||
|
javap -c -p -cp "$CS" org.springframework.cache.transaction.TransactionAwareCacheDecorator \
|
||||||
|
| grep -v '^Picked up' | awk '/public boolean evictIfPresent/,/^$/' | head -8
|
||||||
|
echo
|
||||||
|
echo "doEvict(cache, key, true) -> Cache.evictIfPresent -> straight to the target cache"
|
||||||
|
echo "doEvict(cache, key, false) -> Cache.evict -> registerSynchronization, runs after commit"
|
||||||
|
echo
|
||||||
|
echo "spring-framework#23192 reported beforeInvocation=true being swallowed by the"
|
||||||
|
echo "transaction-aware decorator. On ${V} it is not: the immediate path uses a method"
|
||||||
|
echo "the decorator does not intercept. Note also that the decorator ships in"
|
||||||
|
echo "spring-context-support, not spring-context."
|
||||||
|
} > docs/output/22-decorator-bytecode.txt
|
||||||
|
echo "wrote docs/output/22-decorator-bytecode.txt"
|
||||||
Executable
+32
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Starts the application on the simple provider, exercises a few cached methods, and dumps the
|
||||||
|
# live contents of every cache through /diag/caches.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
PORT=8080
|
||||||
|
PIDFILE=/tmp/caching-demo.pid
|
||||||
|
|
||||||
|
mvn -B -q -DskipTests package
|
||||||
|
nohup java -jar target/caching-1.0.0.jar \
|
||||||
|
--spring.cache.type=simple --server.port=${PORT} > /tmp/caching-demo.log 2>&1 &
|
||||||
|
echo $! > "$PIDFILE"
|
||||||
|
|
||||||
|
for _ in $(seq 1 60); do
|
||||||
|
curl -sf "http://localhost:${PORT}/actuator/health" >/dev/null 2>&1 && break
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# The live contents of every cache, keys included"
|
||||||
|
echo
|
||||||
|
echo "\$ curl -s localhost:${PORT}/diag/warm"
|
||||||
|
curl -s "http://localhost:${PORT}/diag/warm"
|
||||||
|
echo
|
||||||
|
echo
|
||||||
|
echo "\$ curl -s localhost:${PORT}/diag/caches | jq ."
|
||||||
|
curl -s "http://localhost:${PORT}/diag/caches" | python3 -m json.tool
|
||||||
|
} > docs/output/23-diagnostics.txt
|
||||||
|
|
||||||
|
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||||
|
rm -f "$PIDFILE"
|
||||||
|
echo "wrote docs/output/23-diagnostics.txt"
|
||||||
Executable
+23
@@ -0,0 +1,23 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Regenerates every file under docs/output/.
|
||||||
|
#
|
||||||
|
# ./scripts/run-all.sh
|
||||||
|
#
|
||||||
|
# Needs a JDK 25 and Maven 3.9. Everything except the two javap transcripts and the live
|
||||||
|
# diagnostics dump comes out of the test suite, which is the point: the numbers in the article
|
||||||
|
# are assertions that fail the build if they stop being true.
|
||||||
|
set -euo pipefail
|
||||||
|
cd "$(dirname "$0")/.."
|
||||||
|
|
||||||
|
echo "== test suite (transcripts 01-20)"
|
||||||
|
mvn -B test
|
||||||
|
|
||||||
|
echo "== javap transcripts (22)"
|
||||||
|
./scripts/capture-bytecode.sh
|
||||||
|
|
||||||
|
echo "== live diagnostics endpoint (23)"
|
||||||
|
./scripts/capture-diagnostics.sh
|
||||||
|
|
||||||
|
echo
|
||||||
|
echo "docs/output:"
|
||||||
|
ls -1 docs/output
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import org.springframework.aop.config.AopConfigUtils;
|
||||||
|
import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
|
||||||
|
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Primary;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
|
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCacheManager;
|
||||||
|
import org.springframework.cache.transaction.TransactionAwareCacheManagerProxy;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turns the cache annotations on.
|
||||||
|
*
|
||||||
|
* <p>Note what {@code @EnableCaching} does <em>not</em> accept. It has exactly three attributes
|
||||||
|
* — {@code proxyTargetClass}, {@code mode} and {@code order} — verified with
|
||||||
|
* {@code javap} and captured in {@code docs/output/03-enablecaching-attributes.txt}. There is no
|
||||||
|
* {@code exposeProxy}, so the widely-copied {@code @EnableCaching(exposeProxy = true)} does not
|
||||||
|
* compile. Turning the ThreadLocal on takes the post-processor below.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
public class CacheConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes {@link org.springframework.aop.framework.AopContext#currentProxy()} work, which is
|
||||||
|
* one of the three ways out of the self-invocation trap in
|
||||||
|
* {@link com.ankurm.caching.selfinvocation.CatalogService}. {@code @EnableAspectJAutoProxy(
|
||||||
|
* exposeProxy = true)} is the usual advice, but it pulls in AspectJ; this does the same job
|
||||||
|
* by flipping the flag on the auto-proxy creator {@code @EnableCaching} already registered.
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
static BeanFactoryPostProcessor exposeCachingProxy() {
|
||||||
|
return beanFactory -> {
|
||||||
|
if (beanFactory instanceof BeanDefinitionRegistry registry) {
|
||||||
|
AopConfigUtils.registerAutoProxyCreatorIfNecessary(registry);
|
||||||
|
AopConfigUtils.forceAutoProxyCreatorToExposeProxy(registry);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A Caffeine manager used only by the {@code caffeine} profile, so the TTL and size-bound
|
||||||
|
* demonstrations have a provider that actually implements them. Without a profile the
|
||||||
|
* application runs on Boot's auto-configured {@code simple} provider — a
|
||||||
|
* {@code ConcurrentHashMap} with no expiry at all.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/08-providers-and-ttl.md">docs/08-providers-and-ttl.md</a>
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
@Profile("caffeine")
|
||||||
|
public CaffeineCacheManager caffeineCacheManager() {
|
||||||
|
CaffeineCacheManager manager = new CaffeineCacheManager();
|
||||||
|
manager.setCaffeine(Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(Duration.ofMillis(400))
|
||||||
|
.maximumSize(3)
|
||||||
|
.recordStats());
|
||||||
|
// Required before @Cacheable on a CompletableFuture-returning method will work.
|
||||||
|
manager.setAsyncCacheMode(true);
|
||||||
|
return manager;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Defers every put and evict to after the transaction commits, so a rollback takes the cache
|
||||||
|
* write with it. Reads are <em>not</em> deferred, and it only helps inside a transaction.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../docs/10-transactions.md">docs/10-transactions.md</a>
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
@Primary
|
||||||
|
@Profile("txaware")
|
||||||
|
public CacheManager transactionAwareCacheManager() {
|
||||||
|
return new TransactionAwareCacheManagerProxy(new ConcurrentMapCacheManager());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Companion application for the ankurm.com article on the Spring cache abstraction.
|
||||||
|
*
|
||||||
|
* <p>Note what is <em>not</em> here: {@code @EnableCaching}. Spring Boot's reference
|
||||||
|
* documentation explicitly advises against putting it on the main application class,
|
||||||
|
* because that makes caching mandatory for every test slice too. It lives on
|
||||||
|
* {@link com.ankurm.caching.CacheConfig} instead.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../docs/01-what-caching-is.md">docs/01-what-caching-is.md</a>
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
public class CachingDemoApplication {
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CachingDemoApplication.class, args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package com.ankurm.caching.basics;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A value object. Records give you {@code equals} and {@code hashCode} for free, which matters
|
||||||
|
* more than it looks: the default key generator puts method arguments straight into a hash map.
|
||||||
|
*/
|
||||||
|
public record Book(String isbn, String title, int year) implements Serializable {
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.ankurm.caching.basics;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stands in for whatever is actually slow — a database, an HTTP call, a report.
|
||||||
|
* Every lookup sleeps and increments a counter, which is how every claim in the article
|
||||||
|
* about "the method did not run" is measured rather than asserted.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/01-what-caching-is.md">docs/01-what-caching-is.md</a>
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class BookRepositoryStub {
|
||||||
|
|
||||||
|
/** Roughly what a cold index lookup over a network costs. */
|
||||||
|
public static final long LOOKUP_MILLIS = 200;
|
||||||
|
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
|
||||||
|
private static final Map<String, Book> DATA = Map.of(
|
||||||
|
"978-0134685991", new Book("978-0134685991", "Effective Java", 2018),
|
||||||
|
"978-1617294945", new Book("978-1617294945", "Spring in Action", 2022),
|
||||||
|
"978-0596009205", new Book("978-0596009205", "Head First Design Patterns", 2004));
|
||||||
|
|
||||||
|
public Book load(String isbn) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
sleep();
|
||||||
|
return DATA.get(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int callCount() {
|
||||||
|
return calls.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void reset() {
|
||||||
|
calls.set(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void sleep() {
|
||||||
|
try {
|
||||||
|
Thread.sleep(LOOKUP_MILLIS);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package com.ankurm.caching.basics;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.CachePut;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The smallest useful caching example, and the three annotations that do 95% of the work.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/02-the-three-annotations.md">docs/02-the-three-annotations.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class BookService {
|
||||||
|
|
||||||
|
private final BookRepositoryStub repository;
|
||||||
|
|
||||||
|
public BookService(BookRepositoryStub repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache {@code books}, key = the single argument, because SimpleKeyGenerator says so. */
|
||||||
|
@Cacheable("books")
|
||||||
|
public Book findBook(String isbn) {
|
||||||
|
return repository.load(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Always runs, then writes the result into the cache under the same key. */
|
||||||
|
@CachePut(cacheNames = "books", key = "#book.isbn")
|
||||||
|
public Book save(Book book) {
|
||||||
|
return book;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Removes one entry. The method body can be empty; the annotation is the point. */
|
||||||
|
@CacheEvict(cacheNames = "books", key = "#isbn")
|
||||||
|
public void delete(String isbn) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears the whole region in one operation instead of key by key. */
|
||||||
|
@CacheEvict(cacheNames = "books", allEntries = true)
|
||||||
|
public void reload() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.ankurm.caching.conditions;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code condition} is evaluated before the method runs and can veto both the lookup and the
|
||||||
|
* write. {@code unless} is evaluated after, sees {@code #result}, and can only veto the write.
|
||||||
|
*
|
||||||
|
* <p>Also here: what happens to {@code null}. The abstraction stores a {@code NullValue}
|
||||||
|
* sentinel by default, so "not found" is cached like any other answer — which is usually what
|
||||||
|
* you want for a hot miss, and occasionally exactly what you do not want.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/06-conditions-and-nulls.md">docs/06-conditions-and-nulls.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LookupService {
|
||||||
|
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
|
||||||
|
/** Long search terms are one-off; caching them only evicts the useful entries. */
|
||||||
|
@Cacheable(cacheNames = "terms", condition = "#term.length() <= 8")
|
||||||
|
public String search(String term) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return "hits-for-" + term;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cache the answer unless it is empty. */
|
||||||
|
@Cacheable(cacheNames = "terms", unless = "#result == null")
|
||||||
|
public String searchNullable(String term) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return term.startsWith("x") ? null : "hits-for-" + term;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No {@code unless}: the null is cached as NullValue and the method never runs again. */
|
||||||
|
@Cacheable("nulls")
|
||||||
|
public String searchCachingNulls(String term) {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return term.startsWith("x") ? null : "hits-for-" + term;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int calls() { return calls.get(); }
|
||||||
|
public void reset() { calls.set(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
package com.ankurm.caching.diag;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.BookService;
|
||||||
|
import com.ankurm.caching.conditions.LookupService;
|
||||||
|
import com.ankurm.caching.keys.KeyShapeService;
|
||||||
|
import org.springframework.cache.Cache;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCache;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.TreeMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints what is actually in the cache, key object by key object, with the runtime class of each
|
||||||
|
* key. Almost every confusing caching bug becomes obvious the moment you can see the keys:
|
||||||
|
* a {@code SimpleKey []} where you expected a string, two methods writing into one key space,
|
||||||
|
* or a {@code NullValue} sitting where a record should be.
|
||||||
|
*
|
||||||
|
* <p>Delete this before shipping. It exposes cached data over HTTP with no authorisation.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/11-diagnostics.md">docs/11-diagnostics.md</a>
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/diag")
|
||||||
|
public class CacheDiagnosticsController {
|
||||||
|
|
||||||
|
private final CacheManager cacheManager;
|
||||||
|
private final BookService books;
|
||||||
|
private final KeyShapeService shapes;
|
||||||
|
private final LookupService lookups;
|
||||||
|
|
||||||
|
public CacheDiagnosticsController(CacheManager cacheManager, BookService books,
|
||||||
|
KeyShapeService shapes, LookupService lookups) {
|
||||||
|
this.cacheManager = cacheManager;
|
||||||
|
this.books = books;
|
||||||
|
this.shapes = shapes;
|
||||||
|
this.lookups = lookups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Calls a handful of cached methods so {@code /diag/caches} has something to show. */
|
||||||
|
@GetMapping("/warm")
|
||||||
|
public String warm() {
|
||||||
|
books.findBook("978-0134685991");
|
||||||
|
shapes.zeroArgs();
|
||||||
|
shapes.oneArg("abc");
|
||||||
|
shapes.twoArgs("abc", 7);
|
||||||
|
lookups.searchCachingNulls("xyz");
|
||||||
|
return "warmed: books, shapes, nulls";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/caches")
|
||||||
|
public Map<String, Object> caches() {
|
||||||
|
Map<String, Object> report = new LinkedHashMap<>();
|
||||||
|
report.put("cacheManager", cacheManager.getClass().getName());
|
||||||
|
Map<String, Object> caches = new LinkedHashMap<>();
|
||||||
|
for (String name : cacheManager.getCacheNames()) {
|
||||||
|
caches.put(name, describe(cacheManager.getCache(name)));
|
||||||
|
}
|
||||||
|
report.put("caches", caches);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> describe(Cache cache) {
|
||||||
|
Map<String, Object> info = new LinkedHashMap<>();
|
||||||
|
if (cache == null) {
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
info.put("implementation", cache.getClass().getName());
|
||||||
|
Object native_ = cache.getNativeCache();
|
||||||
|
info.put("nativeStore", native_.getClass().getName());
|
||||||
|
if (cache instanceof ConcurrentMapCache map) {
|
||||||
|
Map<String, String> entries = new TreeMap<>();
|
||||||
|
map.getNativeCache().forEach((k, v) -> entries.put(
|
||||||
|
k + " [" + k.getClass().getSimpleName() + "]",
|
||||||
|
v + " [" + v.getClass().getSimpleName() + "]"));
|
||||||
|
info.put("entries", entries);
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.ankurm.caching.eviction;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.CachePut;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eviction timing. {@code @CacheEvict} defaults to <em>after</em> a successful invocation, which
|
||||||
|
* means a method that throws leaves the stale entry in place — and the next reader gets a value
|
||||||
|
* the database no longer has.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/05-eviction.md">docs/05-eviction.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class PriceService {
|
||||||
|
|
||||||
|
private final AtomicInteger reads = new AtomicInteger();
|
||||||
|
private int storedPrice = 100;
|
||||||
|
|
||||||
|
@Cacheable("prices")
|
||||||
|
public int price(String sku) {
|
||||||
|
reads.incrementAndGet();
|
||||||
|
return storedPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default timing: evict after the method returns normally. */
|
||||||
|
@CacheEvict(cacheNames = "prices", key = "#sku")
|
||||||
|
public void updatePrice(String sku, int newPrice, boolean fail) {
|
||||||
|
storedPrice = newPrice;
|
||||||
|
if (fail) {
|
||||||
|
throw new IllegalStateException("audit log write failed after the price was updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Evict first, whatever happens next. Costs a cache miss; buys correctness on failure. */
|
||||||
|
@CacheEvict(cacheNames = "prices", key = "#sku", beforeInvocation = true)
|
||||||
|
public void updatePriceEvictFirst(String sku, int newPrice, boolean fail) {
|
||||||
|
storedPrice = newPrice;
|
||||||
|
if (fail) {
|
||||||
|
throw new IllegalStateException("audit log write failed after the price was updated");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writes through instead of evicting: one fewer miss, but the value must be the real one. */
|
||||||
|
@CachePut(cacheNames = "prices", key = "#sku")
|
||||||
|
public int updatePriceWriteThrough(String sku, int newPrice) {
|
||||||
|
storedPrice = newPrice;
|
||||||
|
return newPrice;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int reads() { return reads.get(); }
|
||||||
|
public void reset(int price) { reads.set(0); storedPrice = price; }
|
||||||
|
public int storedPrice() { return storedPrice; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A deliberately ordinary entity with one lazy collection, used to show the boundary between
|
||||||
|
* the Spring cache abstraction (which caches whatever object a method returned) and the
|
||||||
|
* Hibernate second-level cache (which caches entity state Hibernate can rehydrate).
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/09-versus-hibernate-l2.md">docs/09-versus-hibernate-l2.md</a>
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
public class Customer {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "customer", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
|
||||||
|
private List<Order> orders = new ArrayList<>();
|
||||||
|
|
||||||
|
protected Customer() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Customer(Long id, String name) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getName() { return name; }
|
||||||
|
public void setName(String name) { this.name = name; }
|
||||||
|
public List<Order> getOrders() { return orders; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface CustomerRepository extends JpaRepository<Customer, Long> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.CachePut;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Caching and transactions. The caching interceptor runs inside the transaction interceptor,
|
||||||
|
* so a cache write happens at method exit — before the commit, and regardless of whether the
|
||||||
|
* commit succeeds.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/10-transactions.md">docs/10-transactions.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CustomerService {
|
||||||
|
|
||||||
|
private final CustomerRepository repository;
|
||||||
|
private final AtomicInteger loads = new AtomicInteger();
|
||||||
|
|
||||||
|
public CustomerService(CustomerRepository repository) {
|
||||||
|
this.repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("customers")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public String nameOf(Long id) {
|
||||||
|
loads.incrementAndGet();
|
||||||
|
return repository.findById(id).map(Customer::getName).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An ordinary, correct-looking write-through update. It succeeds; the caller is what fails.
|
||||||
|
* Joins the caller's transaction, so the row is rolled back with it.
|
||||||
|
*/
|
||||||
|
@CachePut(cacheNames = "customers", key = "#id")
|
||||||
|
@Transactional
|
||||||
|
public String rename(Long id, String newName) {
|
||||||
|
Customer customer = repository.findById(id).orElseThrow();
|
||||||
|
customer.setName(newName);
|
||||||
|
repository.saveAndFlush(customer);
|
||||||
|
return newName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts before the body runs. Under a transaction-aware cache manager the evict is still
|
||||||
|
* deferred to commit, which is measured in {@code docs/output/19-transaction-aware.txt}.
|
||||||
|
*/
|
||||||
|
@CacheEvict(cacheNames = "customers", key = "#id", beforeInvocation = true)
|
||||||
|
@Transactional
|
||||||
|
public void renameEvictingFirst(Long id, String newName) {
|
||||||
|
Customer customer = repository.findById(id).orElseThrow();
|
||||||
|
customer.setName(newName);
|
||||||
|
repository.saveAndFlush(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The same update expressed as an eviction rather than a write-through. */
|
||||||
|
@CacheEvict(cacheNames = "customers", key = "#id")
|
||||||
|
@Transactional
|
||||||
|
public void renameEvicting(Long id, String newName) {
|
||||||
|
Customer customer = repository.findById(id).orElseThrow();
|
||||||
|
customer.setName(newName);
|
||||||
|
repository.saveAndFlush(customer);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Returns a managed entity that becomes detached the moment the transaction ends. */
|
||||||
|
@Cacheable("entities")
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Customer loadEntity(Long id) {
|
||||||
|
loads.incrementAndGet();
|
||||||
|
return repository.findById(id).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int loads() { return loads.get(); }
|
||||||
|
public void reset() { loads.set(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The outer transaction. {@code rename} is a perfectly ordinary cached write that succeeds; the
|
||||||
|
* work after it fails. The database change is rolled back and the cache write is not, because
|
||||||
|
* the caching interceptor sits inside the transaction interceptor and fires at method exit.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/10-transactions.md">docs/10-transactions.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CustomerWorkflow {
|
||||||
|
|
||||||
|
private final CustomerService customers;
|
||||||
|
|
||||||
|
public CustomerWorkflow(CustomerService customers) {
|
||||||
|
this.customers = customers;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void renameAndThenFail(Long id, String newName) {
|
||||||
|
customers.rename(id, newName);
|
||||||
|
throw new IllegalStateException("the step after the rename failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void evictAndThenFail(Long id, String newName) {
|
||||||
|
customers.renameEvicting(id, newName);
|
||||||
|
throw new IllegalStateException("the step after the rename failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evicts before the inner method body, then reads the cache again while still inside the
|
||||||
|
* same transaction. On a plain cache manager the entry is already gone; on a
|
||||||
|
* transaction-aware one it is not, because the evict was deferred to commit.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public String evictFirstThenReadInSameTransaction(Long id, String newName) {
|
||||||
|
customers.renameEvictingFirst(id, newName);
|
||||||
|
return customers.nameOf(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two customers and two orders, so the JPA demonstrations have something to load.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class DataSeeder {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
ApplicationRunner seed(CustomerRepository repository) {
|
||||||
|
return args -> seedData(repository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void seedData(CustomerRepository repository) {
|
||||||
|
if (repository.count() > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Customer alice = new Customer(1L, "Alice");
|
||||||
|
alice.getOrders().add(new Order(10L, "keyboard", alice));
|
||||||
|
alice.getOrders().add(new Order(11L, "monitor", alice));
|
||||||
|
repository.save(alice);
|
||||||
|
repository.save(new Customer(2L, "Bob"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ankurm.caching.jpa;
|
||||||
|
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "orders")
|
||||||
|
public class Order {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
private Long id;
|
||||||
|
private String item;
|
||||||
|
|
||||||
|
@ManyToOne
|
||||||
|
private Customer customer;
|
||||||
|
|
||||||
|
protected Order() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Order(Long id, String item, Customer customer) {
|
||||||
|
this.id = id;
|
||||||
|
this.item = item;
|
||||||
|
this.customer = customer;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() { return id; }
|
||||||
|
public String getItem() { return item; }
|
||||||
|
public Customer getCustomer() { return customer; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package com.ankurm.caching.keys;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The default key generator uses the arguments and <em>nothing else</em> — not the method name,
|
||||||
|
* not the declaring class. Two methods that share a cache name and take the same argument types
|
||||||
|
* therefore share a key space, and the second one silently serves the first one's values.
|
||||||
|
*
|
||||||
|
* <p>{@code countLetters} and {@code countDigits} below are deliberately obvious. The real bug
|
||||||
|
* looks like {@code findByIsbn} and {@code findByTitle} sitting next to each other in a service.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/04-keys.md">docs/04-keys.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CollidingService {
|
||||||
|
|
||||||
|
private final AtomicInteger letterCalls = new AtomicInteger();
|
||||||
|
private final AtomicInteger digitCalls = new AtomicInteger();
|
||||||
|
private final AtomicInteger noArgCalls = new AtomicInteger();
|
||||||
|
|
||||||
|
@Cacheable("shared")
|
||||||
|
public String countLetters(String input) {
|
||||||
|
letterCalls.incrementAndGet();
|
||||||
|
return "letters=" + input.chars().filter(Character::isLetter).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("shared")
|
||||||
|
public String countDigits(String input) {
|
||||||
|
digitCalls.incrementAndGet();
|
||||||
|
return "digits=" + input.chars().filter(Character::isDigit).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No arguments means the key is {@code SimpleKey.EMPTY} — a single shared constant. */
|
||||||
|
@Cacheable("noargs")
|
||||||
|
public String currentBanner() {
|
||||||
|
noArgCalls.incrementAndGet();
|
||||||
|
return "banner-from-currentBanner";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Also no arguments, also {@code SimpleKey.EMPTY}, also in cache {@code noargs}. */
|
||||||
|
@Cacheable("noargs")
|
||||||
|
public String currentFooter() {
|
||||||
|
noArgCalls.incrementAndGet();
|
||||||
|
return "footer-from-currentFooter";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The fix: make the key say which method it belongs to. */
|
||||||
|
@Cacheable(cacheNames = "scoped", key = "'letters:' + #input")
|
||||||
|
public String countLettersScoped(String input) {
|
||||||
|
letterCalls.incrementAndGet();
|
||||||
|
return "letters=" + input.chars().filter(Character::isLetter).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable(cacheNames = "scoped", key = "'digits:' + #input")
|
||||||
|
public String countDigitsScoped(String input) {
|
||||||
|
digitCalls.incrementAndGet();
|
||||||
|
return "digits=" + input.chars().filter(Character::isDigit).count();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int letterCalls() { return letterCalls.get(); }
|
||||||
|
public int digitCalls() { return digitCalls.get(); }
|
||||||
|
public int noArgCalls() { return noArgCalls.get(); }
|
||||||
|
public void reset() { letterCalls.set(0); digitCalls.set(0); noArgCalls.set(0); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.ankurm.caching.keys;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prints the key object the abstraction actually built, for zero, one and several arguments,
|
||||||
|
* and shows what a mutable argument does to a key.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/04-keys.md">docs/04-keys.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class KeyShapeService {
|
||||||
|
|
||||||
|
@Cacheable("shapes")
|
||||||
|
public String zeroArgs() {
|
||||||
|
return "zero";
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("shapes")
|
||||||
|
public String oneArg(String a) {
|
||||||
|
return "one:" + a;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("shapes")
|
||||||
|
public String twoArgs(String a, int b) {
|
||||||
|
return "two:" + a + ":" + b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mutable argument is a key you can lose. */
|
||||||
|
@Cacheable("mutable")
|
||||||
|
public String byList(List<String> tags) {
|
||||||
|
return "tags=" + tags;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.ankurm.caching.selfinvocation;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.Book;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix 3, and the one worth reaching for: the loop lives in a different bean, so the call to
|
||||||
|
* {@code lookup} is an ordinary external call and goes through the proxy like any other.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CatalogReader {
|
||||||
|
|
||||||
|
private final CatalogService catalog;
|
||||||
|
|
||||||
|
public CatalogReader(CatalogService catalog) {
|
||||||
|
this.catalog = catalog;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Book> byCollaborator(List<String> isbns) {
|
||||||
|
return isbns.stream().map(catalog::lookup).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package com.ankurm.caching.selfinvocation;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.Book;
|
||||||
|
import com.ankurm.caching.basics.BookRepositoryStub;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import org.springframework.aop.framework.AopContext;
|
||||||
|
import org.springframework.beans.factory.ObjectProvider;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Four ways to call a {@code @Cacheable} method from inside the same bean. One of them caches
|
||||||
|
* nothing, and it is the one everybody writes first.
|
||||||
|
*
|
||||||
|
* <p>The mechanism: {@code @EnableCaching} does not rewrite {@code CatalogService}. It puts a
|
||||||
|
* proxy in front of it, and the caching interceptor lives in the proxy. {@code this.lookup(..)}
|
||||||
|
* is a plain virtual call on the target object; it never crosses the proxy, so no interceptor
|
||||||
|
* runs.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/03-self-invocation.md">docs/03-self-invocation.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class CatalogService {
|
||||||
|
|
||||||
|
private final BookRepositoryStub repository;
|
||||||
|
/** A provider, not the bean itself: injecting the proxy into its own constructor is a cycle. */
|
||||||
|
private final ObjectProvider<CatalogService> self;
|
||||||
|
|
||||||
|
public CatalogService(BookRepositoryStub repository, ObjectProvider<CatalogService> self) {
|
||||||
|
this.repository = repository;
|
||||||
|
this.self = self;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable("catalog")
|
||||||
|
public Book lookup(String isbn) {
|
||||||
|
return repository.load(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Broken: {@code this.lookup} bypasses the proxy, so every ISBN hits the repository. */
|
||||||
|
public List<Book> byInternalCall(List<String> isbns) {
|
||||||
|
return isbns.stream().map(this::lookup).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fix 1: go back out through the proxy that the container is holding. */
|
||||||
|
public List<Book> bySelfInjection(List<String> isbns) {
|
||||||
|
CatalogService proxy = self.getObject();
|
||||||
|
return isbns.stream().map(proxy::lookup).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fix 2: {@code @EnableCaching(exposeProxy = true)} binds the current proxy to a ThreadLocal.
|
||||||
|
* Works, but it couples the code to Spring AOP and only inside an intercepted call.
|
||||||
|
*/
|
||||||
|
public List<Book> byExposedProxy(List<String> isbns) {
|
||||||
|
CatalogService proxy = (CatalogService) AopContext.currentProxy();
|
||||||
|
return isbns.stream().map(proxy::lookup).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Silently uncached for a second, independent reason: in proxy mode the annotation is only
|
||||||
|
* honoured on public methods. No warning is logged.
|
||||||
|
*/
|
||||||
|
@Cacheable("catalog")
|
||||||
|
protected Book protectedLookup(String isbn) {
|
||||||
|
return repository.load(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Book callProtected(String isbn) {
|
||||||
|
return protectedLookup(isbn);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The proxy is not in place yet during {@code @PostConstruct}. Documented, still surprising. */
|
||||||
|
@PostConstruct
|
||||||
|
void warmUpThatDoesNotWarmAnything() {
|
||||||
|
lookup("978-0134685991");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.ankurm.caching.sync;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.CompletableFuture;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Since Spring Framework 6.1 the cache annotations understand {@code CompletableFuture} and
|
||||||
|
* reactive return types. The cache has to support future-based retrieval: {@code
|
||||||
|
* ConcurrentMapCacheManager} adapts automatically, {@code CaffeineCacheManager} needs
|
||||||
|
* {@code setAsyncCacheMode(true)}.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/07-sync-and-async.md">docs/07-sync-and-async.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class AsyncReportService {
|
||||||
|
|
||||||
|
private final AtomicInteger calls = new AtomicInteger();
|
||||||
|
|
||||||
|
@Cacheable("asyncReports")
|
||||||
|
public CompletableFuture<String> buildAsync(String name) {
|
||||||
|
return CompletableFuture.supplyAsync(() -> {
|
||||||
|
calls.incrementAndGet();
|
||||||
|
return "async-report:" + name;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public int calls() { return calls.get(); }
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.ankurm.caching.sync;
|
||||||
|
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache stampede. Without {@code sync = true}, N threads that miss at the same instant all run
|
||||||
|
* the method; with it, one runs and the rest block on the same computation.
|
||||||
|
*
|
||||||
|
* @see <a href="../../../../../../docs/07-sync-and-async.md">docs/07-sync-and-async.md</a>
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class ReportService {
|
||||||
|
|
||||||
|
private final AtomicInteger unsyncedCalls = new AtomicInteger();
|
||||||
|
private final AtomicInteger syncedCalls = new AtomicInteger();
|
||||||
|
|
||||||
|
@Cacheable("reports")
|
||||||
|
public String buildReport(String name) {
|
||||||
|
unsyncedCalls.incrementAndGet();
|
||||||
|
sleep(300);
|
||||||
|
return "report:" + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Cacheable(cacheNames = "syncedReports", sync = true)
|
||||||
|
public String buildReportSynced(String name) {
|
||||||
|
syncedCalls.incrementAndGet();
|
||||||
|
sleep(300);
|
||||||
|
return "report:" + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int unsyncedCalls() { return unsyncedCalls.get(); }
|
||||||
|
public int syncedCalls() { return syncedCalls.get(); }
|
||||||
|
|
||||||
|
private static void sleep(long ms) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(ms);
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: caching
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: create-drop
|
||||||
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
cache:
|
||||||
|
# Explicit: this module is about the *application* cache, not Hibernate's L2.
|
||||||
|
# See docs/09-versus-hibernate-l2.md for what the difference actually buys you.
|
||||||
|
use_second_level_cache: false
|
||||||
|
sql:
|
||||||
|
init:
|
||||||
|
mode: never
|
||||||
|
|
||||||
|
management:
|
||||||
|
endpoints:
|
||||||
|
web:
|
||||||
|
exposure:
|
||||||
|
include: caches,metrics,health
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.springframework.cache: INFO
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.sync.AsyncReportService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same method on Boot's auto-configured {@code CaffeineCacheManager}, which does not have
|
||||||
|
* async cache mode enabled. The application starts cleanly and fails at the first call.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class AsyncCacheModeOffTest {
|
||||||
|
|
||||||
|
@Autowired AsyncReportService asyncReports;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void failsAtTheFirstCallNotAtStartup() {
|
||||||
|
try (Transcript t = new Transcript("17-async-cache-mode-missing.txt",
|
||||||
|
"The same method on the auto-configured Caffeine manager")) {
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("The application started cleanly. Nothing warned about anything.");
|
||||||
|
t.line("");
|
||||||
|
t.line("buildAsync(\"q3\") ->");
|
||||||
|
assertThatThrownBy(() -> asyncReports.buildAsync("q3"))
|
||||||
|
.isInstanceOf(IllegalStateException.class)
|
||||||
|
.satisfies(e -> t.line(" %s: %s", e.getClass().getName(), e.getMessage()));
|
||||||
|
t.line("");
|
||||||
|
t.line("Thrown on the first invocation, in production, at whatever hour that");
|
||||||
|
t.line("endpoint first gets traffic.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.sync.AsyncReportService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@code @Cacheable} on a {@code CompletableFuture}-returning method, with a cache that supports
|
||||||
|
* future-based retrieval.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("caffeine")
|
||||||
|
class AsyncCacheModeOnTest {
|
||||||
|
|
||||||
|
@Autowired AsyncReportService asyncReports;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void worksWhenAsyncCacheModeIsOn() throws Exception {
|
||||||
|
try (Transcript t = new Transcript("12-async-return-types.txt",
|
||||||
|
"@Cacheable on a CompletableFuture-returning method")) {
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("setAsyncCacheMode(true) was called on it.");
|
||||||
|
t.line("");
|
||||||
|
String first = asyncReports.buildAsync("q3").get(5, TimeUnit.SECONDS);
|
||||||
|
String second = asyncReports.buildAsync("q3").get(5, TimeUnit.SECONDS);
|
||||||
|
t.line("first -> %s", first);
|
||||||
|
t.line("second -> %s", second);
|
||||||
|
t.line("supplier invocations: %d", asyncReports.calls());
|
||||||
|
t.line("");
|
||||||
|
t.line("Since Spring Framework 6.1 the interceptor unwraps CompletableFuture and");
|
||||||
|
t.line("the reactive types. ConcurrentMapCacheManager adapts to future-based");
|
||||||
|
t.line("retrieval on its own; CaffeineCacheManager has to be told.");
|
||||||
|
assertThat(asyncReports.calls()).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.interceptor.CacheInterceptor;
|
||||||
|
import org.springframework.cache.interceptor.KeyGenerator;
|
||||||
|
import org.springframework.context.ApplicationContext;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What Spring Boot actually put in the context, and where its caching auto-configuration lives
|
||||||
|
* in Boot 4 - it moved out of {@code org.springframework.boot.autoconfigure.cache} into its own
|
||||||
|
* {@code spring-boot-cache} module.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class AutoConfigurationTest {
|
||||||
|
|
||||||
|
@Autowired ApplicationContext context;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whatBootWired() {
|
||||||
|
try (Transcript t = new Transcript("16-autoconfiguration.txt",
|
||||||
|
"What @EnableCaching and Boot's auto-configuration put in the context")) {
|
||||||
|
|
||||||
|
t.line("CacheManager bean : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("caches known at startup : %s", cacheManager.getCacheNames());
|
||||||
|
t.line("");
|
||||||
|
for (String name : new String[]{"cacheInterceptor", "cacheOperationSource",
|
||||||
|
"cacheAdvisor", "org.springframework.cache.config.internalCacheAdvisor"}) {
|
||||||
|
t.line("bean %-52s present=%b", name, context.containsBean(name));
|
||||||
|
}
|
||||||
|
t.line("");
|
||||||
|
t.line("CacheInterceptor beans : %s",
|
||||||
|
Arrays.toString(context.getBeanNamesForType(CacheInterceptor.class)));
|
||||||
|
t.line("KeyGenerator beans : %s",
|
||||||
|
Arrays.toString(context.getBeanNamesForType(KeyGenerator.class)));
|
||||||
|
|
||||||
|
t.section("where the auto-configuration class lives");
|
||||||
|
Class<?> autoConfig = null;
|
||||||
|
for (String candidate : new String[]{
|
||||||
|
"org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration",
|
||||||
|
"org.springframework.boot.autoconfigure.cache.CacheAutoConfiguration"}) {
|
||||||
|
try {
|
||||||
|
autoConfig = Class.forName(candidate);
|
||||||
|
t.line("FOUND %s", candidate);
|
||||||
|
} catch (ClassNotFoundException e) {
|
||||||
|
t.line("absent %s", candidate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.line("");
|
||||||
|
t.line("Boot 4 split spring-boot-autoconfigure into per-technology modules. Caching");
|
||||||
|
t.line("auto-configuration now ships in spring-boot-cache, which the");
|
||||||
|
t.line("spring-boot-starter-cache starter pulls in.");
|
||||||
|
assertThat(autoConfig).isNotNull();
|
||||||
|
assertThat(autoConfig.getName())
|
||||||
|
.isEqualTo("org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.Book;
|
||||||
|
import com.ankurm.caching.basics.BookRepositoryStub;
|
||||||
|
import com.ankurm.caching.basics.BookService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 1 and 2: does it cache at all, and what the three annotations do.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class BasicsTest {
|
||||||
|
|
||||||
|
@Autowired BookService books;
|
||||||
|
@Autowired BookRepositoryStub repository;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
private static final String ISBN = "978-0134685991";
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
repository.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void secondCallDoesNotRunTheMethod() {
|
||||||
|
try (Transcript t = new Transcript("01-basics.txt",
|
||||||
|
"A cache hit is a method that did not run")) {
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("repository latency : %d ms per lookup", BookRepositoryStub.LOOKUP_MILLIS);
|
||||||
|
|
||||||
|
t.section("first call (miss)");
|
||||||
|
long t1 = System.nanoTime();
|
||||||
|
Book first = books.findBook(ISBN);
|
||||||
|
long ms1 = (System.nanoTime() - t1) / 1_000_000;
|
||||||
|
t.line("returned : %s", first);
|
||||||
|
t.line("elapsed : %d ms", ms1);
|
||||||
|
t.line("repository calls : %d", repository.callCount());
|
||||||
|
|
||||||
|
t.section("second call (hit)");
|
||||||
|
long t2 = System.nanoTime();
|
||||||
|
Book second = books.findBook(ISBN);
|
||||||
|
long ms2 = (System.nanoTime() - t2) / 1_000_000;
|
||||||
|
t.line("returned : %s", second);
|
||||||
|
t.line("elapsed : %d ms", ms2);
|
||||||
|
t.line("repository calls : %d <- still 1, the method body never ran", repository.callCount());
|
||||||
|
t.line("same object? : %b", first == second);
|
||||||
|
|
||||||
|
assertThat(repository.callCount()).isEqualTo(1);
|
||||||
|
assertThat(first).isSameAs(second);
|
||||||
|
assertThat(ms2).isLessThan(BookRepositoryStub.LOOKUP_MILLIS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void putEvictAndClear() {
|
||||||
|
try (Transcript t = new Transcript("02-put-evict-clear.txt",
|
||||||
|
"@Cacheable, @CachePut and @CacheEvict on the same cache")) {
|
||||||
|
|
||||||
|
books.findBook(ISBN);
|
||||||
|
t.line("after findBook : repository calls = %d", repository.callCount());
|
||||||
|
|
||||||
|
Book patched = new Book(ISBN, "Effective Java (3rd ed.)", 2018);
|
||||||
|
books.save(patched);
|
||||||
|
t.line("@CachePut wrote : %s", patched);
|
||||||
|
t.line("next findBook returns : %s", books.findBook(ISBN));
|
||||||
|
t.line("repository calls : %d <- @CachePut refreshed the entry, no reload", repository.callCount());
|
||||||
|
assertThat(books.findBook(ISBN).title()).isEqualTo("Effective Java (3rd ed.)");
|
||||||
|
assertThat(repository.callCount()).isEqualTo(1);
|
||||||
|
|
||||||
|
books.delete(ISBN);
|
||||||
|
t.line("");
|
||||||
|
t.line("after @CacheEvict : findBook -> %s", books.findBook(ISBN));
|
||||||
|
t.line("repository calls : %d <- the entry was gone, so the method ran again", repository.callCount());
|
||||||
|
assertThat(repository.callCount()).isEqualTo(2);
|
||||||
|
|
||||||
|
books.findBook("978-1617294945");
|
||||||
|
books.reload();
|
||||||
|
books.findBook(ISBN);
|
||||||
|
books.findBook("978-1617294945");
|
||||||
|
t.line("");
|
||||||
|
t.line("after allEntries=true : repository calls = %d <- both entries were dropped",
|
||||||
|
repository.callCount());
|
||||||
|
assertThat(repository.callCount()).isEqualTo(5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.conditions.LookupService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCache;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 6: condition vs unless, and what a cached null looks like on the inside.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class ConditionsAndNullsTest {
|
||||||
|
|
||||||
|
@Autowired LookupService lookups;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
lookups.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void conditionVetoesBeforeUnlessVetoesAfter() {
|
||||||
|
try (Transcript t = new Transcript("09-conditions.txt",
|
||||||
|
"condition is checked before the call, unless after it")) {
|
||||||
|
|
||||||
|
lookups.search("spring");
|
||||||
|
lookups.search("spring");
|
||||||
|
t.line("search(\"spring\") twice, 6 characters -> %d invocations", lookups.calls());
|
||||||
|
assertThat(lookups.calls()).isEqualTo(1);
|
||||||
|
|
||||||
|
lookups.reset();
|
||||||
|
lookups.search("a-very-long-search-phrase");
|
||||||
|
lookups.search("a-very-long-search-phrase");
|
||||||
|
t.line("search(25 chars) twice, condition false -> %d invocations", lookups.calls());
|
||||||
|
t.line("");
|
||||||
|
t.line("condition = \"#term.length() <= 8\" is evaluated on the arguments before the");
|
||||||
|
t.line("method runs, so a false condition skips the lookup and the write.");
|
||||||
|
assertThat(lookups.calls()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void nullIsCachedAsASentinelUnlessYouSayOtherwise() {
|
||||||
|
try (Transcript t = new Transcript("10-nulls.txt",
|
||||||
|
"A cached null is a real entry called NullValue")) {
|
||||||
|
|
||||||
|
lookups.reset();
|
||||||
|
lookups.searchCachingNulls("xyz");
|
||||||
|
lookups.searchCachingNulls("xyz");
|
||||||
|
t.line("searchCachingNulls(\"xyz\") returns null, called twice -> %d invocations",
|
||||||
|
lookups.calls());
|
||||||
|
t.line("");
|
||||||
|
dump(t, "nulls");
|
||||||
|
t.line("");
|
||||||
|
t.line("The abstraction stores org.springframework.cache.support.NullValue.INSTANCE");
|
||||||
|
t.line("so a hit on null is distinguishable from a miss. This is usually what you");
|
||||||
|
t.line("want - it is the cheap defence against a hot lookup for a row that is not");
|
||||||
|
t.line("there - and occasionally exactly what you do not want.");
|
||||||
|
assertThat(lookups.calls()).isEqualTo(1);
|
||||||
|
|
||||||
|
t.section("unless = \"#result == null\"");
|
||||||
|
lookups.reset();
|
||||||
|
cacheManager.getCache("terms").clear();
|
||||||
|
lookups.searchNullable("xyz");
|
||||||
|
lookups.searchNullable("xyz");
|
||||||
|
t.line("searchNullable(\"xyz\") twice -> %d invocations <- the null was never stored",
|
||||||
|
lookups.calls());
|
||||||
|
dump(t, "terms");
|
||||||
|
assertThat(lookups.calls()).isEqualTo(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dump(Transcript t, String cacheName) {
|
||||||
|
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache(cacheName);
|
||||||
|
t.line(" cache \"%s\":", cacheName);
|
||||||
|
if (cache.getNativeCache().isEmpty()) {
|
||||||
|
t.line(" (empty)");
|
||||||
|
}
|
||||||
|
cache.getNativeCache().forEach((k, v) -> t.line(" key %-10s -> %s [%s]",
|
||||||
|
k, v, v.getClass().getName()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.eviction.PriceService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 5: when the eviction actually happens, and what a thrown exception does to it.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class EvictionTimingTest {
|
||||||
|
|
||||||
|
@Autowired PriceService prices;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
prices.reset(100);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aFailedUpdateLeavesTheStaleEntryInPlace() {
|
||||||
|
try (Transcript t = new Transcript("08-evict-timing.txt",
|
||||||
|
"@CacheEvict runs after the method - unless you ask otherwise")) {
|
||||||
|
|
||||||
|
t.line("price(\"sku-1\") -> %d (stored price is %d)", prices.price("sku-1"), prices.storedPrice());
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> prices.updatePrice("sku-1", 250, true))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
t.line("");
|
||||||
|
t.line("updatePrice(\"sku-1\", 250, fail=true) threw after writing the new price.");
|
||||||
|
t.line("stored price now : %d", prices.storedPrice());
|
||||||
|
t.line("price(\"sku-1\") : %d <- the cache still serves the old value", prices.price("sku-1"));
|
||||||
|
t.line("reads of the real store: %d", prices.reads());
|
||||||
|
assertThat(prices.price("sku-1")).isEqualTo(100);
|
||||||
|
assertThat(prices.reads()).isEqualTo(1);
|
||||||
|
|
||||||
|
t.section("beforeInvocation = true");
|
||||||
|
prices.reset(100);
|
||||||
|
cacheManager.getCache("prices").clear();
|
||||||
|
t.line("price(\"sku-2\") -> %d", prices.price("sku-2"));
|
||||||
|
assertThatThrownBy(() -> prices.updatePriceEvictFirst("sku-2", 250, true))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
t.line("updatePriceEvictFirst(\"sku-2\", 250, fail=true) threw the same way.");
|
||||||
|
t.line("price(\"sku-2\") : %d <- the entry went first, so the next read is honest",
|
||||||
|
prices.price("sku-2"));
|
||||||
|
assertThat(prices.price("sku-2")).isEqualTo(250);
|
||||||
|
|
||||||
|
t.section("@CachePut instead: write through, no miss");
|
||||||
|
prices.reset(100);
|
||||||
|
cacheManager.getCache("prices").clear();
|
||||||
|
prices.price("sku-3");
|
||||||
|
int readsBefore = prices.reads();
|
||||||
|
prices.updatePriceWriteThrough("sku-3", 400);
|
||||||
|
t.line("after @CachePut, price(\"sku-3\") -> %d", prices.price("sku-3"));
|
||||||
|
t.line("reads of the real store: %d -> %d <- no reload was needed",
|
||||||
|
readsBefore, prices.reads());
|
||||||
|
assertThat(prices.price("sku-3")).isEqualTo(400);
|
||||||
|
assertThat(prices.reads()).isEqualTo(readsBefore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||||
|
import org.springframework.boot.cache.autoconfigure.CacheAutoConfiguration;
|
||||||
|
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||||
|
import org.springframework.cache.annotation.CacheEvict;
|
||||||
|
import org.springframework.cache.annotation.Cacheable;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Declarations the abstraction rejects, and when it tells you. Some of these fail while the
|
||||||
|
* context is still starting, which is the good case; others wait for the first call.
|
||||||
|
*
|
||||||
|
* <p>Every message below is the framework's own, captured from a real failed context.
|
||||||
|
*/
|
||||||
|
class InvalidDeclarationsTest {
|
||||||
|
|
||||||
|
private final ApplicationContextRunner runner = new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
|
||||||
|
.withPropertyValues("spring.cache.type=simple");
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void whatTheAbstractionRefusesAndWhen() {
|
||||||
|
try (Transcript t = new Transcript("20-invalid-declarations.txt",
|
||||||
|
"Declarations that are rejected, and how late you find out")) {
|
||||||
|
|
||||||
|
t.line("1. key and keyGenerator together");
|
||||||
|
runner.withUserConfiguration(BothKeyAndGenerator.class).run(context -> {
|
||||||
|
report(t, context.getStartupFailure());
|
||||||
|
if (context.getStartupFailure() == null) {
|
||||||
|
probe(t, context::getBean, BothKeyAndGenerator.Svc.class);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
t.section("2. sync = true with unless");
|
||||||
|
runner.withUserConfiguration(SyncWithUnless.class).run(context -> {
|
||||||
|
report(t, context.getStartupFailure());
|
||||||
|
probe(t, context::getBean, SyncWithUnless.Svc.class);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.section("3. sync = true across two caches");
|
||||||
|
runner.withUserConfiguration(SyncTwoCaches.class).run(context -> {
|
||||||
|
report(t, context.getStartupFailure());
|
||||||
|
probe(t, context::getBean, SyncTwoCaches.Svc.class);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.section("4. @Cacheable and @CacheEvict on one method");
|
||||||
|
runner.withUserConfiguration(CacheableAndEvict.class).run(context -> {
|
||||||
|
report(t, context.getStartupFailure());
|
||||||
|
probe(t, context::getBean, CacheableAndEvict.Svc.class);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.section("5. a cache name that spring.cache.cache-names does not declare");
|
||||||
|
new ApplicationContextRunner()
|
||||||
|
.withConfiguration(AutoConfigurations.of(CacheAutoConfiguration.class))
|
||||||
|
.withPropertyValues("spring.cache.type=simple", "spring.cache.cache-names=known")
|
||||||
|
.withUserConfiguration(UndeclaredCache.class)
|
||||||
|
.run(context -> {
|
||||||
|
report(t, context.getStartupFailure());
|
||||||
|
probe(t, context::getBean, UndeclaredCache.Svc.class);
|
||||||
|
});
|
||||||
|
|
||||||
|
t.line("");
|
||||||
|
t.line("Only the first of these is a compile-time-shaped mistake. The rest start a");
|
||||||
|
t.line("perfectly healthy application and throw on a code path that may not be hit");
|
||||||
|
t.line("for hours.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void report(Transcript t, Throwable startupFailure) {
|
||||||
|
if (startupFailure == null) {
|
||||||
|
t.line(" startup : clean");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Throwable cause = root(startupFailure);
|
||||||
|
t.line(" startup : FAILED - %s", cause.getClass().getName());
|
||||||
|
t.line(" %s", cause.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Throwable root(Throwable t) {
|
||||||
|
Throwable cause = t;
|
||||||
|
while (cause.getCause() != null && cause.getCause() != cause) {
|
||||||
|
cause = cause.getCause();
|
||||||
|
}
|
||||||
|
return cause;
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> void probe(Transcript t, java.util.function.Function<Class<T>, T> lookup, Class<T> type) {
|
||||||
|
try {
|
||||||
|
T bean = lookup.apply(type);
|
||||||
|
Object result = type.getMethod("call", String.class).invoke(bean, "k");
|
||||||
|
t.line(" first call: returned %s", result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
Throwable cause = root(e);
|
||||||
|
t.line(" first call: %s", cause.getClass().getName());
|
||||||
|
t.line(" %s", cause.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
static class BothKeyAndGenerator {
|
||||||
|
@Bean Svc svc() { return new Svc(); }
|
||||||
|
static class Svc {
|
||||||
|
@Cacheable(cacheNames = "c", key = "#a", keyGenerator = "simpleKeyGenerator")
|
||||||
|
public String call(String a) { return "v:" + a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
static class SyncWithUnless {
|
||||||
|
@Bean Svc svc() { return new Svc(); }
|
||||||
|
static class Svc {
|
||||||
|
@Cacheable(cacheNames = "c", sync = true, unless = "#result != null")
|
||||||
|
public String call(String a) { return "v:" + a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
static class SyncTwoCaches {
|
||||||
|
@Bean Svc svc() { return new Svc(); }
|
||||||
|
static class Svc {
|
||||||
|
@Cacheable(cacheNames = {"c1", "c2"}, sync = true)
|
||||||
|
public String call(String a) { return "v:" + a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
static class CacheableAndEvict {
|
||||||
|
@Bean Svc svc() { return new Svc(); }
|
||||||
|
static class Svc {
|
||||||
|
@Cacheable(cacheNames = "c", sync = true)
|
||||||
|
@CacheEvict(cacheNames = "c")
|
||||||
|
public String call(String a) { return "v:" + a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableCaching
|
||||||
|
static class UndeclaredCache {
|
||||||
|
@Bean Svc svc() { return new Svc(); }
|
||||||
|
static class Svc {
|
||||||
|
@Cacheable("unknown")
|
||||||
|
public String call(String a) { return "v:" + a; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.keys.CollidingService;
|
||||||
|
import com.ankurm.caching.keys.KeyShapeService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.concurrent.ConcurrentMapCache;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 4: what the default key generator builds, and the collision it makes easy.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class KeyGenerationTest {
|
||||||
|
|
||||||
|
@Autowired CollidingService colliding;
|
||||||
|
@Autowired KeyShapeService shapes;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
colliding.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void theDefaultKeyIsBuiltFromArgumentsAlone() {
|
||||||
|
try (Transcript t = new Transcript("05-key-shapes.txt",
|
||||||
|
"What SimpleKeyGenerator actually puts in the map")) {
|
||||||
|
|
||||||
|
shapes.zeroArgs();
|
||||||
|
shapes.oneArg("abc");
|
||||||
|
shapes.twoArgs("abc", 7);
|
||||||
|
|
||||||
|
t.line("cache \"shapes\" after three calls with 0, 1 and 2 arguments:");
|
||||||
|
t.line("");
|
||||||
|
dump(t, "shapes");
|
||||||
|
t.line("");
|
||||||
|
t.line("Zero arguments -> the SimpleKey.EMPTY constant, printed as []");
|
||||||
|
t.line("One argument -> that argument itself, unwrapped");
|
||||||
|
t.line("Two or more -> a SimpleKey holding all of them");
|
||||||
|
t.line("");
|
||||||
|
t.line("The method name and the declaring class appear nowhere in the key.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void twoMethodsSharingACacheNameServeEachOthersValues() {
|
||||||
|
try (Transcript t = new Transcript("06-key-collision.txt",
|
||||||
|
"The collision the default key generator makes easy")) {
|
||||||
|
|
||||||
|
t.line("countLetters(String) and countDigits(String) both write into cache \"shared\".");
|
||||||
|
t.line("");
|
||||||
|
String letters = colliding.countLetters("a1b2");
|
||||||
|
t.line("countLetters(\"a1b2\") -> %s (repository calls: letters=%d digits=%d)",
|
||||||
|
letters, colliding.letterCalls(), colliding.digitCalls());
|
||||||
|
|
||||||
|
String digits = colliding.countDigits("a1b2");
|
||||||
|
t.line("countDigits(\"a1b2\") -> %s (repository calls: letters=%d digits=%d)",
|
||||||
|
digits, colliding.letterCalls(), colliding.digitCalls());
|
||||||
|
t.line("");
|
||||||
|
t.line("countDigits never ran. It found the key \"a1b2\" already populated and");
|
||||||
|
t.line("returned the answer to a different question.");
|
||||||
|
t.line("");
|
||||||
|
dump(t, "shared");
|
||||||
|
|
||||||
|
assertThat(digits).isEqualTo("letters=2");
|
||||||
|
assertThat(colliding.digitCalls()).isZero();
|
||||||
|
|
||||||
|
t.section("no-argument methods collide even harder");
|
||||||
|
String banner = colliding.currentBanner();
|
||||||
|
String footer = colliding.currentFooter();
|
||||||
|
t.line("currentBanner() -> %s", banner);
|
||||||
|
t.line("currentFooter() -> %s <- both key on SimpleKey.EMPTY", footer);
|
||||||
|
dump(t, "noargs");
|
||||||
|
assertThat(footer).isEqualTo("banner-from-currentBanner");
|
||||||
|
|
||||||
|
t.section("the fix: put the method into the key");
|
||||||
|
colliding.reset();
|
||||||
|
String l2 = colliding.countLettersScoped("a1b2");
|
||||||
|
String d2 = colliding.countDigitsScoped("a1b2");
|
||||||
|
t.line("countLettersScoped(\"a1b2\") -> %s", l2);
|
||||||
|
t.line("countDigitsScoped(\"a1b2\") -> %s", d2);
|
||||||
|
dump(t, "scoped");
|
||||||
|
assertThat(d2).isEqualTo("digits=2");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aMutableKeyLosesItsEntry() {
|
||||||
|
try (Transcript t = new Transcript("07-mutable-key.txt",
|
||||||
|
"A mutable argument is an entry you cannot find again")) {
|
||||||
|
|
||||||
|
List<String> tags = new ArrayList<>(List.of("java"));
|
||||||
|
t.line("first call : byList(%s) -> %s", tags, shapes.byList(tags));
|
||||||
|
dump(t, "mutable");
|
||||||
|
|
||||||
|
tags.add("spring");
|
||||||
|
t.line("");
|
||||||
|
t.line("the caller mutates the same list it passed in: %s", tags);
|
||||||
|
t.line("second call : byList(%s) -> %s", tags, shapes.byList(tags));
|
||||||
|
t.line("");
|
||||||
|
dump(t, "mutable");
|
||||||
|
t.line("");
|
||||||
|
t.line("Two entries, and their keys now print identically - because they are the");
|
||||||
|
t.line("same object. The caller mutated the list it had already handed over as a");
|
||||||
|
t.line("key, so the first entry sits in the map under a hashCode the map no longer");
|
||||||
|
t.line("agrees with. Nothing will find it again and nothing will evict it: a leak");
|
||||||
|
t.line("with a completely ordinary-looking cause.");
|
||||||
|
|
||||||
|
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache("mutable");
|
||||||
|
assertThat(cache.getNativeCache()).hasSize(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void dump(Transcript t, String cacheName) {
|
||||||
|
ConcurrentMapCache cache = (ConcurrentMapCache) cacheManager.getCache(cacheName);
|
||||||
|
t.line(" cache \"%s\":", cacheName);
|
||||||
|
Map<Object, Object> store = cache.getNativeCache();
|
||||||
|
if (store.isEmpty()) {
|
||||||
|
t.line(" (empty)");
|
||||||
|
}
|
||||||
|
store.forEach((k, v) -> t.line(" key %-22s [%s] -> %s",
|
||||||
|
k, k.getClass().getSimpleName(), v));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Which provider you get when you say nothing at all. The answer is decided by what is on the
|
||||||
|
* classpath, in a fixed order, and adding a library for an unrelated reason changes it.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
class ProviderDetectionTest {
|
||||||
|
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void classpathDecidesTheProvider() {
|
||||||
|
try (Transcript t = new Transcript("18-provider-detection.txt",
|
||||||
|
"Nothing in application.yml selects a provider. Something still chose one.")) {
|
||||||
|
|
||||||
|
t.line("spring.cache.type : (not set)");
|
||||||
|
t.line("resolved CacheManager bean : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("");
|
||||||
|
t.line("Caffeine is on this module's classpath because a later chapter needs TTL and");
|
||||||
|
t.line("size bounds. That single dependency moved every cache in the application off");
|
||||||
|
t.line("the ConcurrentHashMap-backed 'simple' provider, because Boot walks a fixed");
|
||||||
|
t.line("detection order and stops at the first provider it finds:");
|
||||||
|
t.line("");
|
||||||
|
t.line(" 1 Generic 2 JCache 3 Hazelcast 4 Infinispan 5 Couchbase");
|
||||||
|
t.line(" 6 Redis 7 Caffeine 8 Cache2k 9 Simple");
|
||||||
|
t.line("");
|
||||||
|
t.line("Nothing logs the decision at INFO. If a cache suddenly starts expiring");
|
||||||
|
t.line("entries, or stops, look at what changed in the dependency tree.");
|
||||||
|
assertThat(cacheManager.getClass().getName())
|
||||||
|
.isEqualTo("org.springframework.cache.caffeine.CaffeineCacheManager");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.BookRepositoryStub;
|
||||||
|
import com.ankurm.caching.basics.BookService;
|
||||||
|
import com.github.benmanes.caffeine.cache.stats.CacheStats;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.caffeine.CaffeineCache;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 8: what a real provider adds. The default provider has no expiry and no size bound;
|
||||||
|
* Caffeine has both, and reports whether any of it is working.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("caffeine")
|
||||||
|
class ProvidersAndTtlTest {
|
||||||
|
|
||||||
|
@Autowired BookService books;
|
||||||
|
@Autowired BookRepositoryStub repository;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void caffeineExpiresAndBoundsWhereTheDefaultProviderDoesNeither() throws Exception {
|
||||||
|
try (Transcript t = new Transcript("15-providers-and-ttl.txt",
|
||||||
|
"TTL and size bounds are the provider's job, not the abstraction's")) {
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("configured : expireAfterWrite=400ms, maximumSize=3, recordStats");
|
||||||
|
t.line("");
|
||||||
|
|
||||||
|
repository.reset();
|
||||||
|
cacheManager.getCache("books").clear();
|
||||||
|
|
||||||
|
books.findBook("978-0134685991");
|
||||||
|
books.findBook("978-0134685991");
|
||||||
|
t.line("two calls, same key, immediately -> %d repository calls", repository.callCount());
|
||||||
|
assertThat(repository.callCount()).isEqualTo(1);
|
||||||
|
|
||||||
|
Thread.sleep(600);
|
||||||
|
books.findBook("978-0134685991");
|
||||||
|
t.line("one more call 600 ms later -> %d repository calls <- the entry expired",
|
||||||
|
repository.callCount());
|
||||||
|
assertThat(repository.callCount()).isEqualTo(2);
|
||||||
|
|
||||||
|
t.section("size bound");
|
||||||
|
cacheManager.getCache("books").clear();
|
||||||
|
repository.reset();
|
||||||
|
for (String isbn : new String[]{"978-0134685991", "978-1617294945", "978-0596009205"}) {
|
||||||
|
books.findBook(isbn);
|
||||||
|
}
|
||||||
|
books.save(new com.ankurm.caching.basics.Book("x-1", "Filler One", 2020));
|
||||||
|
books.save(new com.ankurm.caching.basics.Book("x-2", "Filler Two", 2020));
|
||||||
|
Thread.sleep(120);
|
||||||
|
CaffeineCache cache = (CaffeineCache) cacheManager.getCache("books");
|
||||||
|
long size = cache.getNativeCache().estimatedSize();
|
||||||
|
CacheStats stats = cache.getNativeCache().stats();
|
||||||
|
t.line("five distinct keys written, maximumSize = 3");
|
||||||
|
t.line("estimated size after eviction settles : %d", size);
|
||||||
|
t.line("stats : hits=%d misses=%d evictions=%d",
|
||||||
|
stats.hitCount(), stats.missCount(), stats.evictionCount());
|
||||||
|
t.line("");
|
||||||
|
t.line("The Spring cache abstraction has no TTL, no size limit and no eviction");
|
||||||
|
t.line("policy of its own - it is an interface over whatever you plug in. On the");
|
||||||
|
t.line("default simple provider, a ConcurrentHashMap, an entry stays until something");
|
||||||
|
t.line("evicts it by hand or the process ends.");
|
||||||
|
assertThat(size).isLessThanOrEqualTo(3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.basics.BookRepositoryStub;
|
||||||
|
import com.ankurm.caching.selfinvocation.CatalogReader;
|
||||||
|
import com.ankurm.caching.selfinvocation.CatalogService;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.aop.support.AopUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.cache.annotation.EnableCaching;
|
||||||
|
|
||||||
|
import java.lang.reflect.Method;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 3: the self-invocation trap, measured four ways plus two silent variants.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class SelfInvocationTest {
|
||||||
|
|
||||||
|
@Autowired CatalogService catalog;
|
||||||
|
@Autowired CatalogReader reader;
|
||||||
|
@Autowired BookRepositoryStub repository;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
private static final List<String> ISBNS =
|
||||||
|
List.of("978-0134685991", "978-1617294945", "978-0134685991", "978-1617294945");
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
repository.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void internalCallSkipsTheCacheAndThreeFixesDoNot() {
|
||||||
|
try (Transcript t = new Transcript("03-self-invocation.txt",
|
||||||
|
"Four ways to call a @Cacheable method, one of which caches nothing")) {
|
||||||
|
|
||||||
|
t.line("injected bean class : %s", catalog.getClass().getName());
|
||||||
|
t.line("is an AOP proxy? : %b", AopUtils.isAopProxy(catalog));
|
||||||
|
t.line("is a CGLIB proxy? : %b", AopUtils.isCglibProxy(catalog));
|
||||||
|
t.line("target class : %s", AopUtils.getTargetClass(catalog).getName());
|
||||||
|
t.line("");
|
||||||
|
t.line("Four ISBNs, two of them repeats. A working cache does 2 lookups, not 4.");
|
||||||
|
|
||||||
|
repository.reset();
|
||||||
|
catalog.byInternalCall(ISBNS);
|
||||||
|
int internal = repository.callCount();
|
||||||
|
t.line("");
|
||||||
|
t.line("this.lookup(..) -> %d repository calls <- no caching at all", internal);
|
||||||
|
|
||||||
|
cacheManager.getCache("catalog").clear();
|
||||||
|
repository.reset();
|
||||||
|
catalog.bySelfInjection(ISBNS);
|
||||||
|
int selfInjected = repository.callCount();
|
||||||
|
t.line("self.getObject().lookup(..) -> %d repository calls", selfInjected);
|
||||||
|
|
||||||
|
cacheManager.getCache("catalog").clear();
|
||||||
|
repository.reset();
|
||||||
|
catalog.byExposedProxy(ISBNS);
|
||||||
|
int exposed = repository.callCount();
|
||||||
|
t.line("AopContext.currentProxy() -> %d repository calls", exposed);
|
||||||
|
|
||||||
|
cacheManager.getCache("catalog").clear();
|
||||||
|
repository.reset();
|
||||||
|
reader.byCollaborator(ISBNS);
|
||||||
|
int collaborator = repository.callCount();
|
||||||
|
t.line("a second bean calls lookup(..) -> %d repository calls", collaborator);
|
||||||
|
|
||||||
|
assertThat(internal).isEqualTo(4);
|
||||||
|
assertThat(selfInjected).isEqualTo(2);
|
||||||
|
assertThat(exposed).isEqualTo(2);
|
||||||
|
assertThat(collaborator).isEqualTo(2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void protectedMethodIsSilentlyNotCached() {
|
||||||
|
try (Transcript t = new Transcript("04-non-public-and-postconstruct.txt",
|
||||||
|
"Two more places the annotation is ignored without a warning")) {
|
||||||
|
|
||||||
|
repository.reset();
|
||||||
|
catalog.callProtected("978-0596009205");
|
||||||
|
catalog.callProtected("978-0596009205");
|
||||||
|
t.line("@Cacheable on a protected method, called twice -> %d repository calls",
|
||||||
|
repository.callCount());
|
||||||
|
t.line("No warning is logged. In proxy mode the annotation is only honoured on");
|
||||||
|
t.line("public methods; a protected one is simply never advised.");
|
||||||
|
assertThat(repository.callCount()).isEqualTo(2);
|
||||||
|
|
||||||
|
t.section("@EnableCaching attributes, as the class file declares them");
|
||||||
|
Method[] attrs = EnableCaching.class.getDeclaredMethods();
|
||||||
|
Arrays.sort(attrs, (a, b) -> a.getName().compareTo(b.getName()));
|
||||||
|
for (Method m : attrs) {
|
||||||
|
t.line(" %s %s()", m.getReturnType().getSimpleName(), m.getName());
|
||||||
|
}
|
||||||
|
t.line("");
|
||||||
|
t.line("There is no exposeProxy attribute, so @EnableCaching(exposeProxy = true)");
|
||||||
|
t.line("- which a lot of answers recommend - does not compile.");
|
||||||
|
assertThat(Arrays.stream(attrs).map(Method::getName))
|
||||||
|
.containsExactlyInAnyOrder("proxyTargetClass", "mode", "order");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.sync.ReportService;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 7: sync = true, and CompletableFuture support.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class StampedeTest {
|
||||||
|
|
||||||
|
@Autowired ReportService reports;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
private static final int THREADS = 16;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncTrueCollapsesConcurrentMisses() throws Exception {
|
||||||
|
try (Transcript t = new Transcript("11-stampede.txt",
|
||||||
|
"sync = true is the difference between one slow call and sixteen")) {
|
||||||
|
|
||||||
|
t.line("%d threads call the same key at the same instant, cold cache.", THREADS);
|
||||||
|
t.line("The method sleeps 300 ms.");
|
||||||
|
t.line("");
|
||||||
|
|
||||||
|
int unsynced = race(() -> reports.buildReport("q3"));
|
||||||
|
t.line("@Cacheable(\"reports\") -> %d invocations", unsynced);
|
||||||
|
|
||||||
|
int synced = race(() -> reports.buildReportSynced("q3"));
|
||||||
|
t.line("@Cacheable(\"syncedReports\", sync = true) -> %d invocation%s",
|
||||||
|
synced, synced == 1 ? "" : "s");
|
||||||
|
t.line("");
|
||||||
|
t.line("Without sync, every thread that arrives during the 300 ms window misses and");
|
||||||
|
t.line("runs the method. That is a cache stampede, and it is worst exactly when the");
|
||||||
|
t.line("cache matters most - right after a restart or an eviction.");
|
||||||
|
|
||||||
|
assertThat(unsynced).isGreaterThan(1);
|
||||||
|
assertThat(synced).isEqualTo(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private int race(Runnable call) throws Exception {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(THREADS);
|
||||||
|
int unsyncedBefore = reports.unsyncedCalls();
|
||||||
|
int syncedBefore = reports.syncedCalls();
|
||||||
|
try (ExecutorService pool = Executors.newFixedThreadPool(THREADS)) {
|
||||||
|
for (int i = 0; i < THREADS; i++) {
|
||||||
|
pool.submit(() -> {
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
call.run();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
start.countDown();
|
||||||
|
done.await(30, TimeUnit.SECONDS);
|
||||||
|
}
|
||||||
|
int unsyncedDelta = reports.unsyncedCalls() - unsyncedBefore;
|
||||||
|
int syncedDelta = reports.syncedCalls() - syncedBefore;
|
||||||
|
return unsyncedDelta + syncedDelta;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.jpa.CustomerService;
|
||||||
|
import com.ankurm.caching.jpa.CustomerWorkflow;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The same rollback, with the cache manager wrapped in
|
||||||
|
* {@link org.springframework.cache.transaction.TransactionAwareCacheManagerProxy}.
|
||||||
|
*/
|
||||||
|
@SpringBootTest
|
||||||
|
@ActiveProfiles("txaware")
|
||||||
|
class TransactionAwareTest {
|
||||||
|
|
||||||
|
@Autowired CustomerService customers;
|
||||||
|
@Autowired CustomerWorkflow workflow;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void deferringThePutToAfterCommitFixesIt() {
|
||||||
|
try (Transcript t = new Transcript("19-transaction-aware.txt",
|
||||||
|
"TransactionAwareCacheManagerProxy, and what it does not cover")) {
|
||||||
|
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
customers.reset();
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("");
|
||||||
|
t.line("nameOf(1) -> %s", customers.nameOf(1L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> workflow.renameAndThenFail(1L, "Alice Cooper"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
|
||||||
|
String cached = customers.nameOf(1L);
|
||||||
|
t.line("after the identical rollback, nameOf(1) -> %s", cached);
|
||||||
|
t.line("");
|
||||||
|
t.line("The put was registered as a transaction synchronisation and dropped when the");
|
||||||
|
t.line("transaction rolled back instead of committing.");
|
||||||
|
assertThat(cached).isEqualTo("Alice");
|
||||||
|
|
||||||
|
t.section("what it does not cover: beforeInvocation = true");
|
||||||
|
cacheManager.getCache("customers").clear();
|
||||||
|
customers.reset();
|
||||||
|
customers.nameOf(2L);
|
||||||
|
String seenInsideTx = workflow.evictFirstThenReadInSameTransaction(2L, "Bobby");
|
||||||
|
t.line("inside the same transaction, after an evict declared beforeInvocation=true,");
|
||||||
|
t.line("a re-read returns : %s", seenInsideTx);
|
||||||
|
t.line("");
|
||||||
|
t.line("Not the stale value. The eviction was NOT deferred, and the re-read went to");
|
||||||
|
t.line("the database and saw the uncommitted row. The reason is in the bytecode:");
|
||||||
|
t.line("AbstractCacheInvoker.doEvict(cache, key, immediate) calls evictIfPresent()");
|
||||||
|
t.line("when immediate is true and evict() when it is false, and the decorator only");
|
||||||
|
t.line("registers a post-commit synchronisation in evict() - evictIfPresent()");
|
||||||
|
t.line("delegates straight to the target cache. See docs/output/22-decorator-bytecode.txt.");
|
||||||
|
t.line("");
|
||||||
|
t.line("Two gaps do remain, and they are structural rather than measurable here:");
|
||||||
|
t.line("reads are never deferred, so a @Cacheable lookup inside the transaction sees");
|
||||||
|
t.line("whatever the shared cache holds; and outside a transaction the proxy is a");
|
||||||
|
t.line("pass-through that writes immediately.");
|
||||||
|
assertThat(seenInsideTx).isEqualTo("Bobby");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import com.ankurm.caching.jpa.Customer;
|
||||||
|
import com.ankurm.caching.jpa.CustomerService;
|
||||||
|
import com.ankurm.caching.jpa.CustomerWorkflow;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.cache.CacheManager;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chapter 10: the caching interceptor runs inside the transaction interceptor, so a cache write
|
||||||
|
* happens before the commit and survives a rollback.
|
||||||
|
*/
|
||||||
|
@SpringBootTest(properties = "spring.cache.type=simple")
|
||||||
|
class TransactionsTest {
|
||||||
|
|
||||||
|
@Autowired CustomerService customers;
|
||||||
|
@Autowired CustomerWorkflow workflow;
|
||||||
|
@Autowired CacheManager cacheManager;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void clear() {
|
||||||
|
cacheManager.getCacheNames().forEach(n -> cacheManager.getCache(n).clear());
|
||||||
|
customers.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aRolledBackTransactionLeavesTheCacheUpdated() {
|
||||||
|
try (Transcript t = new Transcript("13-transactions.txt",
|
||||||
|
"A rollback does not roll the cache back")) {
|
||||||
|
|
||||||
|
t.line("cacheManager : %s", cacheManager.getClass().getName());
|
||||||
|
t.line("");
|
||||||
|
t.line("nameOf(1) -> %s", customers.nameOf(1L));
|
||||||
|
|
||||||
|
assertThatThrownBy(() -> workflow.renameAndThenFail(1L, "Alice Cooper"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
|
||||||
|
t.line("");
|
||||||
|
t.line("An outer @Transactional method calls the @CachePut update, which succeeds,");
|
||||||
|
t.line("and then fails on the next step. The transaction rolls back.");
|
||||||
|
t.line("");
|
||||||
|
String cached = customers.nameOf(1L);
|
||||||
|
cacheManager.getCache("customers").clear();
|
||||||
|
String inDatabase = customers.nameOf(1L);
|
||||||
|
t.line("what the cache serves : %s", cached);
|
||||||
|
t.line("what the database has : %s", inDatabase);
|
||||||
|
t.line("");
|
||||||
|
t.line("The cache is now holding a name that no transaction ever committed. Nothing");
|
||||||
|
t.line("will correct it until the entry expires or something evicts it.");
|
||||||
|
|
||||||
|
assertThat(cached).isEqualTo("Alice Cooper");
|
||||||
|
assertThat(inDatabase).isEqualTo("Alice");
|
||||||
|
|
||||||
|
t.section("the same shape with @CacheEvict");
|
||||||
|
cacheManager.getCache("customers").clear();
|
||||||
|
customers.reset();
|
||||||
|
t.line("nameOf(2) -> %s", customers.nameOf(2L));
|
||||||
|
assertThatThrownBy(() -> workflow.evictAndThenFail(2L, "Bobby"))
|
||||||
|
.isInstanceOf(IllegalStateException.class);
|
||||||
|
t.line("after the rollback, nameOf(2) -> %s", customers.nameOf(2L));
|
||||||
|
t.line("database loads: %d <- the entry was evicted, so this one reloaded",
|
||||||
|
customers.loads());
|
||||||
|
t.line("");
|
||||||
|
t.line("An eviction that fires too early is self-healing: the next read goes to the");
|
||||||
|
t.line("database and re-populates correctly. A @CachePut that fires too early is not.");
|
||||||
|
assertThat(customers.nameOf(2L)).isEqualTo("Bob");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void aCachedEntityIsDetachedAndItsLazyCollectionIsGone() {
|
||||||
|
try (Transcript t = new Transcript("14-cached-entity.txt",
|
||||||
|
"Caching an entity caches a detached object, lazy proxies and all")) {
|
||||||
|
|
||||||
|
Customer first = customers.loadEntity(1L);
|
||||||
|
t.line("loadEntity(1) -> %s (%s)", first.getName(), first.getClass().getName());
|
||||||
|
t.line("database loads: %d", customers.loads());
|
||||||
|
|
||||||
|
Customer second = customers.loadEntity(1L);
|
||||||
|
t.line("second call returns the same instance? %b", first == second);
|
||||||
|
t.line("database loads: %d", customers.loads());
|
||||||
|
assertThat(second).isSameAs(first);
|
||||||
|
assertThat(customers.loads()).isEqualTo(1);
|
||||||
|
|
||||||
|
t.section("touching the lazy collection outside the session");
|
||||||
|
try {
|
||||||
|
int size = second.getOrders().size();
|
||||||
|
t.line("orders.size() -> %d", size);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
t.line("%s", e.getClass().getName());
|
||||||
|
t.line(" %s", e.getMessage());
|
||||||
|
}
|
||||||
|
t.line("");
|
||||||
|
t.line("This is the line between the two caches. Hibernate's second-level cache");
|
||||||
|
t.line("stores dehydrated entity state and rebuilds a managed entity inside a");
|
||||||
|
t.line("session, so lazy associations still work. The Spring cache abstraction");
|
||||||
|
t.line("stores the object your method returned, exactly as it was when the");
|
||||||
|
t.line("transaction ended - detached, with whatever its proxies were holding.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.ankurm.caching;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.PrintWriter;
|
||||||
|
import java.io.StringWriter;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Writes a numbered transcript under {@code docs/output/} and echoes it to the console.
|
||||||
|
* Every console block quoted in the article comes out of one of these files verbatim.
|
||||||
|
*/
|
||||||
|
public final class Transcript implements AutoCloseable {
|
||||||
|
|
||||||
|
private final Path path;
|
||||||
|
private final StringWriter buffer = new StringWriter();
|
||||||
|
private final PrintWriter out = new PrintWriter(buffer);
|
||||||
|
|
||||||
|
public Transcript(String fileName, String title) {
|
||||||
|
this.path = Path.of("docs", "output", fileName);
|
||||||
|
out.println("# " + title);
|
||||||
|
out.println();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transcript line(String format, Object... args) {
|
||||||
|
out.println(args.length == 0 ? format : String.format(format, args));
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transcript blank() {
|
||||||
|
out.println();
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Transcript section(String heading) {
|
||||||
|
out.println();
|
||||||
|
out.println("--- " + heading + " ---");
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
out.flush();
|
||||||
|
try {
|
||||||
|
Files.createDirectories(path.getParent());
|
||||||
|
Files.writeString(path, buffer.toString());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new IllegalStateException("could not write " + path, e);
|
||||||
|
}
|
||||||
|
System.out.print(buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user