Hibernate Second-Level Caching: The Good, The Bad, and The Production Nightmares
A deep dive into Hibernate second-level caching with Spring Boot 3.2+ and Hibernate 6.3.
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
- ✓Java 17+
- ✓Spring Boot 3.2+
- ✓Hibernate 6.3+ (comes with Spring Boot 3.2)
- ✓Redis 7.x (for cache store)
- ✓Basic understanding of Hibernate first-level cache
- ✓Familiarity with Spring Cache abstraction
Hibernate second-level caching is a session-factory-scoped cache that stores entities, collections, and query results across sessions, reducing database round-trips. But here's the hard truth: most teams get this wrong by enabling it without a strategy, leading to stale data, memory leaks, and inexplicable failures in production.
Think of Hibernate second-level caching as a shared notebook for your application. Instead of each user asking the database the same question repeatedly, the first user writes down the answer in the notebook. Subsequent users check the notebook first. If the notebook is outdated, you get wrong answers. Managing that notebook — when to update, when to clear, and what to write — is the hard part.
Let me be blunt: if you think Hibernate second-level caching is a magic performance boost you just turn on with a single property, you are in for a world of pain. I've seen this blow up in production when a team enabled caching on an entire entity hierarchy without considering invalidation. The result? Users saw stale account balances for 10 minutes during a Black Friday sale. The CEO's phone rang off the hook. The fix? A full cache flush and a hot-patch deploy. That's not a good day.
In this advanced guide, we'll cover Hibernate 6.3's second-level cache (L2) with Spring Boot 3.2+, using Redis as the cache store. We'll go beyond the happy-path tutorials and talk about the real-world gotchas: cache stampedes, write-through vs. write-behind, query cache invalidation, and debugging strategies. By the end, you'll know when to use L2 caching, when to avoid it, and how to implement it without waking up at 3 AM.
Setting Up Hibernate L2 Cache with Spring Boot 3.2+ and Redis
Stop doing XML-based cache configuration in 2024. Spring Boot 3.2+ with Hibernate 6.3 makes L2 cache setup almost trivial, but you need to understand what each property does.
First, add the dependencies: spring-boot-starter-cache, spring-boot-starter-data-redis, and hibernate-jcache (the JCache bridge). Hibernate 6.3 removed the deprecated Ehcache 2.x support, so you must use JCache-compatible providers.
Configure your application.yml with spring.cache.type=redis and Hibernate-specific properties like hibernate.cache.use_second_level_cache=true, hibernate.cache.region.factory_class=jcache, and hibernate.cache.use_query_cache=true. The query cache is a trap that will burn you — I'll explain why in section 2.
Here's a basic entity configuration. Notice the @Cache annotation with CacheConcurrencyStrategy.READ_WRITE. This is the most common strategy, but it's not safe for all scenarios.
@Cache on collections. This leads to massive memory bloat. Always test cache region sizes with a memory profiler.@Cache with a specific region to avoid cache pollution across entity types.What the Official Docs Won't Tell You
Let me be blunt: the official Hibernate documentation is great for explaining how the cache works in isolation, but it glosses over the real-world integration nightmares. Here's what the docs won't tell you:
- Query cache is a trap. The query cache caches query results, not the entities themselves. If you cache a query that returns
Productentities, Hibernate caches the entity identifiers. Then, it loads each entity from the L2 cache (or database). If the query cache is stale, you get stale identifiers — and if those entities are not in L2, you hit the database anyway. The query cache is only useful for read-only, frequently repeated queries with identical parameters. In my experience, it causes more invalidation headaches than performance gains. - Cache invalidation is not transactional. When you update an entity within a transaction, Hibernate evicts the cache entry at commit time. But if the transaction rolls back, the eviction still happened — you now have a stale cache entry for the old value. There is no rollback mechanism for the L2 cache.
- RegionFactory matters. The default
jcacheregion factory in Hibernate 6.3 works, but it treats each cache region as a separate Redis key. If you don't configure TTLs per region, you can accidentally cache sensitive data (e.g., user passwords) for hours.
I've seen this blow up in production when a team enabled query caching on a search endpoint without considering that the underlying data changed frequently. Users saw outdated search results for 5 minutes. The fix was to disable query caching entirely and rely on entity caching with short TTLs.
Configuring Cache Concurrency Strategies
Hibernate 6.3 offers four concurrency strategies: READ_ONLY, READ_WRITE, NONSTRICT_READ_WRITE, and TRANSACTIONAL. Here's the blunt truth: only READ_ONLY is truly safe. Everything else is a compromise.
- READ_ONLY: Use for reference data (country codes, product categories). No updates allowed. Fastest and safest.
- READ_WRITE: Uses soft locks and versioning. Works well under moderate concurrency but can lead to stale reads under high contention. Hibernate uses a timestamp-based mechanism to detect conflicts.
- NONSTRICT_READ_WRITE: No soft locks. Cache entries are updated asynchronously (write-behind). If two transactions update the same entity concurrently, the last writer wins — but the cache may have the previous writer's value for a short window. This is what you want for high-write entities where eventual consistency is acceptable.
- TRANSACTIONAL: Requires JTA and a cache provider that supports XA transactions (e.g., Infinispan). Overkill for most applications.
Stop using READ_WRITE for high-write entities. I've seen this blow up in production when a team used READ_WRITE on a UserSession entity that was updated on every request. The soft lock contention caused database deadlocks and cascading failures.
Cache Stampede Prevention and Mitigation
A cache stampede (or thundering herd) occurs when a cached entry expires and multiple concurrent requests try to regenerate it simultaneously, all hitting the database. This can bring down your database in seconds.
Hibernate's L2 cache does not have built-in stampede protection. You need to implement it yourself or use the cache provider's features. Redis, for example, doesn't have native stampede prevention, but you can use a distributed lock or a probabilistic early expiration.
The best approach is to use a cache-aside pattern with a mutex lock for regeneration. Alternatively, use early expiration with a background refresh. Spring's @Cacheable supports sync=true, which blocks concurrent accessors until the first one completes. However, this only works for single-node caches; in a multi-node environment, you need a distributed lock.
Here's a production-proven pattern using Redis distributed locks with Redisson.
Invalidation Strategies: When and How to Evict
If you're doing cache invalidation by restarting the application, you're doing it wrong. Cache invalidation must be programmatic and precise.
Hibernate's L2 cache eviction can be done via the SessionFactory.evict() methods. However, in a Spring-managed environment, you typically use @CacheEvict annotations on repository or service methods.
But here's the critical nuance: @CacheEvict only works on Spring Cache abstraction, not directly on Hibernate's L2 cache. If you use @CacheEvict on a method that updates an entity, Spring will evict the cache entry from the Spring Cache (which may be Redis). But Hibernate's L2 cache (also Redis) will still have the old value if you didn't configure them to share the same cache store.
Solution: Use a single cache manager. Configure Spring Cache to use the same Redis instance as Hibernate's L2 cache. Or, better yet, use Hibernate's @Cache annotations and let Hibernate manage eviction via CacheConcurrencyStrategy. But as we discussed, that's not reliable.
My recommendation: Use explicit eviction in your service layer with @CacheEvict and also call SessionFactory.evict() programmatically for critical entities.
@CacheEvict with key="#invoice.id" and a background job to evict stale entries every 5 minutes.Monitoring and Debugging L2 Cache in Production
You can't fix what you can't see. Hibernate provides statistics via hibernate.generate_statistics=true and hibernate.cache.use_structured_entries=true. In Spring Boot, you can expose these via Actuator endpoints.
But here's the hard truth: most teams enable statistics, look at the numbers once, and never check again. You need to set up alerts on cache hit ratio, eviction count, and put count. A sudden drop in hit ratio often indicates a cache stampede or a misconfigured eviction.
Use JMX to monitor Hibernate cache regions in real-time. Connect with JConsole or VisualVM. Look for CacheRegionStatistics MBeans. If you see HitCount dropping while MissCount rising, investigate immediately.
Also, enable slow query logging in Redis to see which cache keys are being accessed most. Use redis-cli --bigkeys to find large cache entries that might indicate entity bloat.
@CacheEvict(allEntries=true) on a frequently called method. We rolled back the change and added a code review checklist for cache annotations.Advanced: Custom Cache Region Factories and TTLs
Hibernate 6.3 allows you to implement a custom RegionFactory to control how cache regions are created and configured. This is useful when you need per-region TTLs, serialization strategies, or custom eviction policies.
For example, you might want a 5-minute TTL for product entities, 1-hour for reference data, and 30 seconds for user sessions. The default jcache region factory does not support per-region configuration via properties; you have to configure it programmatically.
Here's how to create a custom RegionFactory that reads TTLs from application.yml and applies them to each region. This is an advanced pattern that gives you fine-grained control.
Testing and Validating Cache Behavior
Stop testing caching in unit tests with in-memory databases. You need integration tests with Redis (or your cache provider) to catch real issues like serialization failures, cache stampedes, and TTL expiration.
Use Testcontainers to spin up a Redis container in your tests. Write tests that verify cache hits and misses, concurrent access, and eviction. Also test the edge case where the cache provider is down — your application should degrade gracefully, not throw exceptions.
Here's a sample test that verifies the L2 cache is working and that eviction happens correctly.
The 10-Minute Stale Balance Black Friday Disaster
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) would handle concurrent modifications correctly.READ_WRITE strategy uses soft locks and a timestamp-based mechanism. Under high concurrency, the cache entry was updated with a stale version because the database transaction isolation level (READ_COMMITTED) allowed non-repeatable reads. The cache had a 10-minute expiry configured as a fallback, leading to prolonged staleness.NONSTRICT_READ_WRITE with a low expiry (30 seconds). 2. Implemented a cache-aside pattern with explicit eviction on balance updates. 3. Added a @CacheEvict on all mutation methods. 4. Reduced cache TTL to 60 seconds with a background refresh.- Never trust cache invalidation solely to Hibernate's built-in strategies under high concurrency.
- Always have a short TTL and explicit eviction.
- Test with production-like load.
- Monitor cache hit/miss ratios and stale entry counts.
| File | Command / Code | Purpose |
|---|---|---|
| Product.java | @Entity | Setting Up Hibernate L2 Cache with Spring Boot 3.2+ and Redi |
| application.yml | spring: | What the Official Docs Won't Tell You |
| UserSession.java | @Entity | Configuring Cache Concurrency Strategies |
| CacheStampedeService.java | @Service | Cache Stampede Prevention and Mitigation |
| ProductService.java | @Service | Invalidation Strategies |
| application.yml (statistics config) | spring: | Monitoring and Debugging L2 Cache in Production |
| CustomRegionFactory.java | public class CustomRegionFactory extends JCacheRegionFactory { | Advanced |
| CacheIntegrationTest.java | @SpringBootTest | Testing and Validating Cache Behavior |
Key takeaways
Interview Questions on This Topic
Explain the difference between Hibernate first-level and second-level cache. When would you use each?
Frequently Asked Questions
20+ years shipping production Java in banking & fintech. Lessons pulled from things that broke in production.
That's Hibernate & JPA. Mark it forged?
5 min read · try the examples if you haven't