Caching with @Cacheable, @CachePut and @CacheEvict
Spring's cache abstraction as a proxy around a method — how keys are built, when the method body is skipped, and the stale and colliding entries that follow.
After this lesson you can
- Say what @Cacheable, @CachePut and @CacheEvict each do to the method call
- Predict the default cache key and spot two methods that collide
- Avoid caching nulls, stale entries and self-invocation misses
Spring's cache abstraction is declarative. Enable it with
@EnableCaching, annotate methods, and a proxy around the bean consults
a cache before or after the call:
@Service
class ProductService {
@Cacheable(cacheNames = "products", unless = "#result == null")
public Product find(long id) { return repository.findById(id).orElse(null); }
@CachePut(cacheNames = "products", key = "#product.id")
public Product update(Product product) { return repository.save(product); }
@CacheEvict(cacheNames = "products", key = "#id")
public void delete(long id) { repository.deleteById(id); }
}
@Cacheable— look the key up first. On a hit the method body does not run at all; on a miss it runs and its result is stored.@CachePut— the method always runs, and its result replaces the cached entry. Use it to keep the cache current on writes.@CacheEvict— remove one entry (orallEntries = truefor the whole cache), by default after the method returns.
condition is evaluated before the call and decides whether the cache
is used at all; unless is evaluated after, against #result, and
vetoes storing it. Without unless = "#result == null", a null result
is cached like any other value, so "not found" is remembered too.
The default key
Without a key attribute, SimpleKeyGenerator builds the key from the
arguments only: no arguments gives an empty key, one argument is
the key, several are wrapped together. The method name is not part of
it. Two methods that share a cache name and take the same argument
therefore read and write the same entry.
The model below builds the proxy with java.lang.reflect.Proxy, the JDK
mechanism Spring can use for an interface-based bean: a handler reads a
stand-in annotation for the cache name, uses the single argument as the
key, and calls the real bean only on a miss. One version of the
interface puts both methods in one cache, the other gives each its own:
import java.lang.annotation.*;import java.lang.reflect.*;import java.util.*; public class Solution { // A stand-in for @Cacheable: the proxy reads it at call time. @Retention(RetentionPolicy.RUNTIME) @Target(ElementType.METHOD) @interface Cached { String value(); } interface Shared { @Cached("catalog") String title(long isbn); @Cached("catalog") Integer copiesInStock(long isbn); } interface Separate { @Cached("titles") String title(long isbn); @Cached("stock") Integer copiesInStock(long isbn); } // The real bean: these bodies are what a cache hit skips. static class Books implements Shared, Separate { public String title(long isbn) { return "Dune (" + isbn + ")"; } public Integer copiesInStock(long isbn) { return 3; } } // Default key for a one-argument method: the argument itself. static <T> T cachingProxy(Class<T> type, Object target, List<String> bodyRuns) { Map<String, Map<Object, Object>> caches = new HashMap<>(); InvocationHandler handler = (proxy, method, args) -> { String cacheName = method.getAnnotation(Cached.class).value(); Map<Object, Object> cache = caches.computeIfAbsent(cacheName, n -> new HashMap<>()); Object key = args[0]; if (cache.containsKey(key)) return cache.get(key); bodyRuns.add(method.getName()); Object result = method.invoke(target, args); cache.put(key, result); return result; }; return type.cast(Proxy.newProxyInstance(type.getClassLoader(), new Class<?>[] { type }, handler)); } static String attempt(java.util.function.Supplier<Object> call) { try { return String.valueOf(call.get()); } catch (ClassCastException e) { return "ClassCastException"; } } public static List<String> run() { List<String> sharedRuns = new ArrayList<>(); Shared shared = cachingProxy(Shared.class, new Books(), sharedRuns); String s1 = attempt(() -> shared.title(42L)); String s2 = attempt(() -> shared.title(42L)); String s3 = attempt(() -> shared.copiesInStock(42L)); List<String> separateRuns = new ArrayList<>(); Separate separate = cachingProxy(Separate.class, new Books(), separateRuns); String p1 = attempt(() -> separate.title(42L)); String p2 = attempt(() -> separate.copiesInStock(42L)); return List.of( "one cache: title=" + s1 + ", title=" + s2 + ", stock=" + s3 + ", bodies run " + sharedRuns, "two caches: title=" + p1 + ", stock=" + p2 + ", bodies run " + separateRuns); }}When the two methods return different types, the collision does not
even produce a plausible wrong value: the cached String comes back
where an Integer is declared, and the caller gets a
ClassCastException. Give each kind of data its own cache name, or
write the key explicitly.
Where the entries live
Spring Boot picks a CacheManager from what is on the classpath —
Caffeine, Redis, JCache and others — and falls back to a simple
ConcurrentHashMap-based one. The fallback has no expiry and no size
limit: fine for a demo, a memory leak in production. With Caffeine
you set spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10m.
The two expiry styles differ in what restarts the clock:
expireAfterWrite counts from when the value was stored, so even a hot
entry is reloaded regularly; expireAfterAccess counts from the last
read or write, so an entry read often enough never expires and can
stay stale indefinitely.
A local cache is per instance. With three instances behind a load balancer, an eviction on one leaves the other two serving the old value until their entries expire. Either accept bounded staleness with a short expiry, or use a shared cache such as Redis.
Because caching is done by a proxy, everything from the
@Transactional lesson applies: a cached method called through this
is not cached. And @Cacheable(sync = true) makes concurrent misses
for the same key wait for one computation instead of all hitting the
database (provider permitting).
Common mistakes
- Two methods caching into the same cache with the same argument, as in the example: one returns the other's value, or fails with a when their return types differ.
Exercise · Pro
Expire after write, or after access
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade