Caching
Definition
Caching keeps the result of an expensive operation so the next request can reuse it. Spring adds @Cacheable, @CachePut and @CacheEvict, and works with Caffeine (inside one JVM) or Redis (shared).
Why it exists
Repeating the same slow query or remote call on every request wastes time and load. A cache answers repeats from memory.
How it works
Layers of caching in a Spring application
| Layer | Example | Scope |
|---|---|---|
| Browser / CDN / HTTP | Cache-Control, ETag | Per client / edge |
| Application (Spring Cache) | @Cacheable with Caffeine or Redis | Per JVM or shared across instances |
| Hibernate first-level cache | Persistence context | One transaction (always on) |
| Hibernate second-level cache | Ehcache/Infinispan/Redis via JCache | Whole SessionFactory |
| Database | Buffer pool, query cache | Database server |
Hibernate caching
- First-level cache is the persistence context: automatic, per transaction, not shared. It is why a second
findByIdin the same transaction runs no SQL. - Second-level cache is shared across sessions and stores entity state (and optionally collections and query results) in a region cache. It suits read-mostly reference data (countries, product categories). Enable per entity:
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
class Country { ... }
Rules of thumb: do not cache frequently changing entities; remember that bulk updates and writes from other applications bypass the cache; query cache is rarely worth it. In a cluster, use a provider that supports distributed invalidation or accept staleness bounded by TTL.
Example code
Spring Cache abstraction
Enable it once and annotate methods; the implementation (Caffeine, Redis, Ehcache...) is pluggable. It uses the same proxy mechanism as @Transactional, so self-invocation and private methods are not intercepted.
@Configuration
@EnableCaching
class CacheConfig { }
@Service
class PriceService {
@Cacheable(cacheNames = "prices", key = "#sku")
public BigDecimal price(String sku) {
return expensiveLookup(sku); // runs only on a cache miss
}
@CachePut(cacheNames = "prices", key = "#sku")
public BigDecimal updatePrice(String sku, BigDecimal value) {
save(sku, value);
return value; // always runs; result replaces the cached value
}
@CacheEvict(cacheNames = "prices", key = "#sku")
public void evict(String sku) { } // remove one entry (allEntries = true removes all)
}
A test shows the behaviour: two calls to price("A") run the method once; after evict("A") the next call runs it again.
| Annotation | Behaviour |
|---|---|
@Cacheable | Return cached value if present; otherwise call and store |
@CachePut | Always call and store the result |
@CacheEvict | Remove entries |
@Caching | Combine several operations |
condition / unless | Decide whether to cache (unless = "#result == null") |
Cache keys default to the method arguments; define keys explicitly when arguments are complex objects, and make sure they implement equals/hashCode properly.
System design considerations
- Set TTLs and size limits, and monitor hit rate and evictions
- Protect against stampedes with per-key locking or request coalescing
- Plan cold starts: a restart empties a local cache and floods the database
Invalidation strategies
| Strategy | How | Trade-off |
|---|---|---|
| TTL (expiry) | Entry expires after N minutes | Simple; bounded staleness |
| Write-through / evict on write | Update or evict when data changes | Fresher; must cover every write path |
| Event-based | Publish an event on change, consumers evict | Works across services, adds infrastructure |
| Versioned keys | Include a version/etag in the key | Old entries just age out |
Always set a TTL as a safety net even when you also evict explicitly.
When to use it, and when not
Use it when
- Reads are frequent, expensive and tolerate slightly stale data
- You protect a slow or rate-limited dependency
- Data is immutable or changes rarely
Think twice when
- Data must be strongly consistent every time
- The key space is huge and the hit rate would be low
- You have no invalidation strategy
Caffeine (local) vs Redis (distributed)
Caffeine: in-process, extremely fast, no network, configurable size and expiry.
spring:
cache:
type: caffeine
caffeine:
spec: maximumSize=10000,expireAfterWrite=10m
cache-names: prices
Downside: each instance has its own copy, so after an update one instance may serve stale data until expiry or an eviction event.
Redis: shared by all instances, survives restarts, supports TTL per cache, but adds network latency and a dependency, and requires serialisable values.
spring:
cache:
type: redis
redis:
time-to-live: 10m
cache-null-values: false
A common pattern is a two-level cache: Caffeine in front of Redis for hot keys, with Redis pub/sub to invalidate local copies.
What to cache, and what not to
Good candidates: expensive reads that are read far more often than written, reference data, results of slow remote calls (with a circuit breaker fallback to stale data). Poor candidates: highly dynamic data, per-user sensitive data without careful keys, anything where staleness is unacceptable (account balances at the moment of payment).
Common mistakes
Common problems
- Cache stampede (thundering herd): a popular key expires and hundreds of requests recompute it at once. Mitigate with
@Cacheable(sync = true)(one caller computes, others wait), jittered TTLs, or refresh-ahead (CaffeinerefreshAfterWrite). - Cache penetration: repeated requests for non-existent keys always miss. Cache a "not found" marker briefly or use a Bloom filter.
- Stale data: updated in the database but not evicted; leads to confusing bugs. Prefer short TTLs for important data.
- Caching inside transactions: an eviction may run before the transaction commits, and another request refills the cache with old data. Evict after commit when it matters (
@TransactionalEventListener(phase = AFTER_COMMIT)). - Mutable cached objects: returning the same cached instance lets callers mutate shared state. Cache immutable objects (records).
- Serialisation changes with Redis: a deployment that changes a class can break reading old entries. Version the cache names or use JSON with tolerant readers.
- Caching with no expiry and no eviction.
- Self-invocation, so annotations silently do nothing.
- Mutable cached objects.
- Unbounded cache size causing OutOfMemoryError.
- Caching null or error results unintentionally.
- Treating the cache as a source of truth.
- Reading a field on a proxied bean in tests (use a getter; the CGLIB proxy's own fields are not initialised).
Interview questions
1. How does @Cacheable work?
A proxy intercepts the call, builds a key from the arguments, checks the cache and returns the hit; on a miss it invokes the method and stores the result. Like @Transactional, it does not apply to self-invocation.
2. @Cacheable vs @CachePut?
@Cacheable skips the method on a hit. @CachePut always runs the method and refreshes the cache entry.
3. Local vs distributed cache?
Local (Caffeine) is fastest but per instance and can be inconsistent across instances; distributed (Redis) is consistent for all instances but slower and a dependency.
4. What are the first-level and second-level caches in Hibernate?
First-level: per-session persistence context, always on. Second-level: optional, shared across sessions, for read-mostly entities, needs a provider.
5. How do you keep a cache consistent with the database?
Evict or update on every write path, set TTLs as a safety net, evict after commit, and for multi-instance setups publish invalidation events or use a shared cache.
6. What is a cache stampede and how do you prevent it?
Many concurrent recomputations of an expired hot key. Use sync = true, request coalescing, jittered expiry or background refresh.
7. How do you decide the TTL?
From how stale the business can tolerate, and how expensive recomputation is. Start short, measure the hit ratio and adjust.
8. What metrics do you watch?
Hit ratio, miss rate, eviction count, load time, and cache size. Spring Boot exposes cache metrics through Micrometer (cache.gets, cache.puts, cache.evictions).
Check yourself
1Which annotation stores a method result and skips the method on later calls with the same key?
2When is Redis a better cache than Caffeine?
3What is a cache stampede?
Key takeaways
- Spring Cache is a proxy-based abstraction; pick Caffeine for local, Redis for shared.
- Always define TTL and a size limit, and plan invalidation for every write path.
- Beware stampedes, stale data and mutable cached values.
- Use Hibernate's second-level cache only for read-mostly reference data.
Finished this page? Track your progress (saved only in your browser).