Spring’s cache abstraction lets you put a cache in front of a slow method with one annotation, and swap the cache implementation (an in-process map, Caffeine, Redis, Hazelcast) without touching the code. That convenience hides a handful of decisions you still have to make: where entries live, when they expire, what the key is, and when an update makes them stale. Interviewers like this topic because nearly every candidate knows @Cacheable, and far fewer can explain why a cache served a stale price for an hour.
The Java and configuration here target Spring Boot 3.x on Java 17+. The code is illustrative and wasn’t compiled for this article; the stale-read race is checked with a small simulation.
The four annotations and what each one does
Enable caching once with @EnableCaching on a configuration class. Then:
@Cacheablechecks the cache first. On a hit it returns the cached value without running the method. On a miss it runs the method and stores the result.@CachePutalways runs the method and stores the result. Use it on an update method that returns the new state.@CacheEvictremoves an entry (or, withallEntries = true, everything in the cache) after the method runs.beforeInvocation = trueevicts first instead.@Cachinggroups several of these on one method.
@Service
public class ProductService {
private final ProductRepository repo;
public ProductService(ProductRepository repo) { this.repo = repo; }
@Cacheable(cacheNames = "products", key = "#id", unless = "#result == null")
public ProductView getProduct(long id) {
return repo.findById(id).map(ProductView::from).orElse(null);
}
@CachePut(cacheNames = "products", key = "#result.id")
@Transactional
public ProductView rename(long id, String name) {
Product p = repo.findById(id).orElseThrow();
p.setName(name);
return ProductView.from(p);
}
@CacheEvict(cacheNames = "products", key = "#id")
@Transactional
public void delete(long id) { repo.deleteById(id); }
}unless = "#result == null" stops a lookup for a missing product from caching null. Whether caching nulls is good depends on traffic: if bots hammer nonexistent ids, a short-lived cached null protects the database. Spring also unwraps Optional return values, caching the contained value.
Two things interviewers listen for. First, the annotations work through a proxy, exactly like @Transactional and @Async. If getProducts(ids) loops over this.getProduct(id), none of those inner calls hit the cache. Second, cache what you’d be happy to return to another caller: a small, immutable view object, not a JPA entity with lazy collections, which will throw LazyInitializationException when read from the cache outside a session.
Picking a provider, because the default has no limits
Boot looks at the classpath and configures a CacheManager for the first provider it finds (JCache, Hazelcast, Couchbase, Infinispan, Redis, Caffeine, Cache2k), falling back to a simple one backed by ConcurrentHashMap. The fallback is fine for a demo and dangerous in production: no maximum size, no expiry. A cache keyed by search query grows until the heap is full.
For a single instance, Caffeine is the usual choice:
spring.cache.cache-names=products,prices
spring.cache.caffeine.spec=maximumSize=10000,expireAfterWrite=10mFor several instances that should share entries, or a cache that should survive restarts, use Redis:
spring.cache.type=redis
spring.cache.redis.time-to-live=10m
spring.cache.redis.key-prefix=catalog:Each choice has a cost to mention. A local cache per instance means each instance has its own copy, so an eviction on instance A leaves instance B serving the old value until its TTL expires. Redis keeps one copy, but every hit is a network round trip, and values are serialized. Spring Data Redis’s default for cache values is JDK serialization, which requires Serializable and breaks after a deploy that changes the class (InvalidClassException on read). Configuring a JSON serializer through a RedisCacheConfiguration bean avoids that.
The TTL is your safety net for everything that can go wrong with invalidation. Pick it by asking how stale the data is allowed to be. A product description can be ten minutes old. A stock level probably shouldn’t be cached at all, or only for seconds.
Keys: the defaults and how they bite
With no key attribute, Spring’s SimpleKeyGenerator builds the key from the arguments: the argument itself if there’s one, a SimpleKey of all of them if there are several, SimpleKey.EMPTY if there are none. The method name isn’t part of it.
That means two methods sharing a cache name and the same argument types share entries. If getProduct(long id) and getProductSummary(long id) both use cacheNames = "products", whichever runs first for id 42 fills the entry, and the other gets the wrong type back, usually as a ClassCastException. Give each its own cache name, or include something distinguishing in an explicit key.
Also check what should be in the key. A price that depends on the customer’s region needs key = "#id + ':' + #region"; leave the region out and one region’s price is served to everyone. Arguments with poor equals/hashCode (arrays, mutable objects) make keys that never match, so every call misses.
Under load, a popular entry expiring causes a stampede: hundreds of threads miss at once and all call the database. @Cacheable(sync = true) makes concurrent callers for the same key wait for one computation. The cache managers that ship with Spring support it, third-party ones may not, and sync can’t be combined with unless.
Evicting inside a transaction: the stale-read race
This is the bug that produces “the cache kept the old price for an hour”. @CacheEvict runs when the method returns, but if the method runs inside a transaction that commits later (because a caller further up started it, or the advice ordering puts the eviction inside the transaction), there’s a window where the cache is empty and the database still holds the committed old value. A concurrent reader fills the cache in that window.
Here’s the interleaving as a small Node 22 simulation, with a read-committed database and one cached product:
function run(evictAfterCommit) {
const db = { committed: 10, pending: null }; const cache = new Map([['p42', 10]]); const log = [];
const read = (who) => { // @Cacheable getPrice(42)
if (cache.has('p42')) { log.push(`${who} cache hit -> ${cache.get('p42')}`); return; }
const v = db.committed; cache.set('p42', v); log.push(`${who} cache miss, DB returns committed ${v}, cache.put(${v})`);
};
log.push('T1 begin tx; UPDATE price = 12 (uncommitted)'); db.pending = 12;
if (!evictAfterCommit) { cache.delete('p42'); log.push('T1 @CacheEvict runs as the method returns, tx still open'); }
read('T2');
db.committed = db.pending; log.push('T1 commit');
if (evictAfterCommit) { cache.delete('p42'); log.push('T1 evict after commit'); }
read('T3');
return log.join('\n');
}
console.log('--- evict inside the transaction\n' + run(false));
console.log('--- transaction-aware cache (evict after commit)\n' + run(true));--- evict inside the transaction
T1 begin tx; UPDATE price = 12 (uncommitted)
T1 @CacheEvict runs as the method returns, tx still open
T2 cache miss, DB returns committed 10, cache.put(10)
T1 commit
T3 cache hit -> 10
--- transaction-aware cache (evict after commit)
T1 begin tx; UPDATE price = 12 (uncommitted)
T2 cache hit -> 10
T1 commit
T1 evict after commit
T3 cache miss, DB returns committed 12, cache.put(12)In the first run, T3 reads the old price after the update committed, and it will keep doing so until the TTL expires. In the second, T2 still sees the old price (correct, the update hadn’t committed), and T3 gets the new one.
Spring has the second behaviour built in. TransactionAwareCacheDecorator defers puts and evictions until after a successful commit, and TransactionAwareCacheManagerProxy wraps a whole cache manager with it. RedisCacheManager’s builder has a transactionAware() option, which you can switch on from a RedisCacheManagerBuilderCustomizer bean in Boot. Even then a narrower race remains (a reader that loaded the old value just before commit can put it just after the eviction), which is why the TTL stays.
Interview question: the stale catalogue after scaling out
A catalogue service caches products with @Cacheable and the default Boot setup. It ran on one instance for a year without complaints. After scaling to four instances, editors report that price changes “sometimes” don’t show up, and the pods’ memory keeps climbing until they restart. Explain both symptoms and fix them.
Answer, step by step
With no cache library on the classpath, Boot is using its simple ConcurrentHashMap cache: one per instance, no size limit, no expiry. That explains memory, since every product ever viewed stays in every pod’s heap. It also explains the “sometimes”: the editor’s update lands on one pod, whose @CacheEvict clears only its own map. The other three keep serving the old price, forever, because nothing expires. A user’s request reaching the updated pod sees the new price; one reaching any other pod doesn’t.
Fix it in two layers. Add a bounded cache with a TTL so nothing lives forever: Caffeine with maximumSize and expireAfterWrite=5m gives a worst case of five minutes of staleness per pod. If editors need changes visible quickly on every instance, move to a shared cache in Redis so there’s one copy to evict, or keep Caffeine locally and broadcast evictions (a Redis pub/sub message or a Kafka topic that each instance listens to). Either way, make the eviction transaction-aware so it happens after commit, and check that getProduct isn’t being called via this anywhere, since those calls never touched the cache to begin with.