Caching — Second-Level Cache, Query Cache & Redis
The first-level cache only lives for one request — the second-level cache and query cache share data across requests and application instances, trading some staleness risk for real, measurable database-load reduction.
Learning objectives
- Explain the difference between the first-level and second-level cache, and why the first-level cache alone can't help across separate requests.
- Choose between an in-process cache (EHCache/Caffeine) and a distributed cache (Redis) for a given deployment topology.
- Explain why the query cache's table-level invalidation can make it less effective than expected on frequently-written tables.
Chapter 4's first-level cache (the persistence context) only lives for one unit of work — it can't help two DIFFERENT users' requests avoid re-fetching the same rarely-changing data. This chapter covers the caching layers that persist ACROSS requests.
📖 Story
Imagine a library that re-buys a brand-new copy of a reference book every single time a different patron wants to read it, instead of keeping one copy on a shared shelf. Here's that "re-buying" mistake, applied to a country/currency lookup table that essentially never changes:
@Service public class ShippingService { public BigDecimal calculateShipping(Order order) { Country country = countryRepository.findByCode(order.getCountryCode()); // Runs a fresh SELECT ... EVERY single time, for data that // changes maybe once a year. return country.getBaseShippingRate(); } }
Here's the fix — Hibernate's second-level cache, configured once on the entity:
@Entity @Cacheable @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_ONLY) public class Country { @Id private String code; private BigDecimal baseShippingRate; }
Now, after the FIRST load of any given country, every SUBSEQUENT request — even from a completely different user, a completely different EntityManager — reads it from the shared cache instead of hitting the database again.
Second-level cache — a cache shared across persistence contexts (and, with Redis, across application instances), configured per-entity. Query cache — caches the RESULT of a specific query, separate from caching individual entities. Cache provider — the actual implementation plugged into Hibernate's cache abstraction; EHCache/Caffeine (in-process) or Redis (distributed) are common choices.
Let's extend this chapter's Country example to a real multi-instance deployment.
Why the first-level cache alone isn't enough
Chapter 4's persistence context helps if the SAME request loads Country twice — but it does nothing for a completely different user's request five minutes later, hitting a completely different EntityManager. This chapter's second-level cache is what actually shares that cached Country data across separate requests.
In-process (EHCache/Caffeine) vs. distributed (Redis)
# application.yml — configuring Redis as the second-level cache provider spring: jpa: properties: hibernate: cache: use_second_level_cache: true region: factory_class: org.redisson.hibernate.RedissonRegionFactory
If your ShippingService runs on THREE separate application instances behind a load balancer, and you used an in-process cache (Caffeine) instead of Redis, each instance would hold its OWN separate copy of Country — updating it on instance A wouldn't invalidate instance B's stale copy. Redis solves this by giving every instance a single shared view, at the cost of a network round-trip per cache access.
Query cache: caching the result, not just the entity
Hibernate's query cache stores the matching entity IDs for a specific query — but it invalidates aggressively: ANY write to a table involved in a cached query invalidates every cached result touching that table, which can make it far less effective than expected on frequently-written tables.
Hibernate's second-level cache sits between the persistence context and the database — when Country is requested and not in the first-level cache, Hibernate checks the second-level cache next, before falling back to an actual query; if found there, no SQL runs at all. Updating a Country evicts its second-level cache entry so subsequent reads don't see stale data for that specific entity.
- This chapter's country/shipping-rate example is a textbook second-level cache candidate — changes extremely rarely, read constantly.
- A multi-instance e-commerce deployment typically uses Redis (not Caffeine) specifically to keep all instances' view of cached product data consistent.
- A frequently-updated
inventory_countfield is a poor caching candidate on its own — caching it risks showing stale stock levels.
- Cache specifically the entities/queries read often AND changing rarely, like this chapter's
Countryexample — not "cache everything for speed." - Use Redis rather than an in-process cache for ANY multi-instance deployment.
- Set explicit TTLs even for "rarely changing" data — a cache with no expiration risks serving indefinitely stale data if an invalidation path is ever missed.
⚠️ Why this keeps happening
Caching is often reached for reflexively as a generic "make it faster" lever, without checking whether the specific data's read/write ratio actually justifies it.
- Caching frequently-updated data without a clear invalidation strategy, showing stale values.
- Using an in-process cache in a multi-instance deployment — exactly this chapter's three-instance example — without realizing each instance holds its OWN separate, inconsistent copy.
- Never measuring cache hit rate, caching data reflexively and never confirming the overhead is actually being recovered.
A well-chosen second-level cache, like this chapter's Country example, can eliminate a large fraction of database round-trips for that specific data. An in-process cache avoids the network round-trip Redis requires, at the real cost of multi-instance consistency risk.
Caching authorization-relevant data (a permission flag) introduces real risk if the cache's expiration isn't as strict as the source database — a stale cached permission serving an outdated, more-permissive decision is a genuine security risk.
Track cache hit rate, eviction rate, and staleness window as first-class metrics — a falling hit rate signals either a sizing problem or a shift in access patterns.
Document, per cached entity, an explicit staleness tolerance and invalidation strategy — "this can be up to 5 minutes stale, invalidated on write" — as a standing convention.
- Enable the second-level cache with Caffeine for this chapter's
Countryentity, load it from two separateEntityManagers, and confirm (via statistics) the second load hits the cache. - Simulate a two-instance deployment both using an in-process cache, update the entity via one instance, and observe the other instance's stale copy.
- Repeat using Redis instead, and confirm both instances stay consistent.
✓ Quick recap
- The first-level cache lives for one request; the second-level cache (this chapter's
Countryexample) is shared across requests and instances if using Redis. - In-process caches are fast but per-instance-inconsistent in multi-instance deployments; Redis is shared and consistent, at a network-round-trip cost.
- Cache data that's read often AND changes rarely; measure actual hit rate rather than assuming caching helps.
Want a visual for this concept?
Generate a diagram tailored to “Caching — Second-Level Cache, Query Cache & Redis” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.
Sign in to generate a visual →