The Second-Level Cache
The Second-Level Cache
Every EntityManager already keeps a first-level cache — the persistence context — so that repeated calls to find(Order.class, 1L) within the same session return the same in-memory object without a database roundtrip. That cache is scoped to a single session: it is gone the moment you close the EntityManager. The second-level cache (L2 cache) lives at the SessionFactory / EntityManagerFactory level and is therefore shared across all sessions, all threads, and the entire lifetime of the application. Hit the L2 cache and you may avoid a database roundtrip entirely, no matter how many sessions have come and gone.
Why the L2 Cache Matters
Consider a product catalogue with 10,000 items. Each HTTP request creates a fresh EntityManager, looks up several products by ID, and then closes. Without an L2 cache every lookup hits the database. With an L2 cache the rows are deserialized from the database on the first access, stored in the shared cache, and subsequent lookups — across all threads — are served from memory. For read-heavy reference data (countries, categories, permission sets) this can reduce database load by 80–95 %.
Choosing a Cache Provider
Hibernate 6 delegates L2 caching to a pluggable cache provider. The two main choices for Spring Boot 3 applications are:
- Ehcache 3 (via JCache / JSR-107): Mature, embedded (no separate process), rich eviction strategies. Best for single-node or small-cluster deployments.
- Redis (via Redisson or Spring Cache): Distributed, survives restarts, scales horizontally. Required when multiple application nodes must share the same cache.
For most backend services Ehcache 3 is the easiest starting point. Add the dependency:
Enabling the Cache in Spring Boot
Three application.properties lines activate Hibernate's L2 cache support and point it at the JCache provider backed by Ehcache:
Without these lines, any @Cache annotations on your entities are silently ignored.
Marking an Entity as Cacheable
Enabling the factory is not enough — you must opt each entity in with @Cache (Hibernate) plus the standard JPA @Cacheable:
READ_ONLY for immutable reference data (currency codes, country names) — it is fastest. Use READ_WRITE for mutable entities that are updated occasionally. Reserve NONSTRICT_READ_WRITE for high-write entities where a brief stale read is acceptable.
Concurrency Strategies Explained
- READ_ONLY: No update support. Hibernate throws an exception if you try to update a cached entity. Fastest possible strategy — no locking, no invalidation overhead.
- NONSTRICT_READ_WRITE: Invalidates the cache entry on update but does not use soft locks. A narrow window exists where another thread could read a stale value. Suitable when brief inconsistency is acceptable.
- READ_WRITE: Uses soft locks to prevent stale reads during an update. Safe for most transactional applications. Has a small overhead compared to the strategies above.
- TRANSACTIONAL: Full JTA-aware coordination. Required only when your JPA provider is inside a distributed transaction manager (rare in modern Spring Boot apps).
Configuring Cache Regions with Ehcache
Each entity type gets its own cache region named by default <fully-qualified-class-name>. You define per-region limits in an Ehcache XML file:
Point Hibernate to that file:
Caching Associations and Collections
Entity associations (one-to-many collections, many-to-one references) are not cached automatically even if the owning entity is. You must annotate each collection or association separately:
Product, Hibernate evicts that product's entry from the L2 cache. But if you update a product via a bulk JPQL UPDATE (UPDATE Product p SET p.price = ...) Hibernate does NOT automatically evict affected entries — the cache goes stale. After bulk operations you must manually evict: entityManager.getEntityManagerFactory().getCache().evict(Product.class).
Verifying Cache Hits with Statistics
Enable Hibernate statistics to confirm the cache is working:
The log output will include lines like:
A healthy L2 cache shows a high hit ratio (hits / (hits + misses)). If you see mostly misses, either TTL is too short, the heap limit is too small, or the access pattern is too random.
When Not to Use the L2 Cache
The L2 cache is not free. It consumes heap memory, adds complexity to cache invalidation logic, and can serve stale data if misconfigured. Avoid it for:
- High-write entities — frequent updates evict entries constantly, giving near-zero hit rate while adding overhead on every write.
- Large, unique result sets — entity identity queries (by PK) benefit; arbitrary bulk SELECTs are better served by a query cache (covered in the next lesson).
- Security-sensitive data — user credentials, tokens, or PII cached in a shared heap can leak across sessions if cache regions are misconfigured.
Summary
The second-level cache is a shared, cross-session entity store powered by a pluggable provider (Ehcache 3 is the most common embedded choice). You enable it in application.properties, opt each entity in with @Cacheable and @Cache, and configure per-region limits in an Ehcache XML file. Picking the right concurrency strategy — READ_ONLY for immutable data, READ_WRITE for mutable — determines both correctness and performance. Monitor cache statistics to validate your configuration, and remember that bulk JPQL updates bypass automatic eviction and require manual cache eviction.