Second-Level Cache in Hibernate — When It Helps and When It's a Trap
by Eric Hanson, Backend Developer at Clean Systems Consulting
The three levels of Hibernate caching
Hibernate has three distinct caching levels, each with different scope and lifetime:
First-level cache (L1) — the persistence context (EntityManager / Session). Every entity loaded within a transaction is cached here. Repeated calls to entityManager.find(Order.class, id) within the same transaction return the same instance. This cache is always active, per-transaction, and requires no configuration.
Second-level cache (L2) — a shared cache across sessions and transactions. When an entity is loaded and the session closes, the data remains in the L2 cache. The next session that requests the same entity by primary key finds it in the L2 cache without hitting the database. This requires explicit configuration and opt-in per entity.
Query cache — caches the result sets of named queries. Requires L2 to be active. When a query is run and the result is cached, subsequent identical queries return from cache. The query cache invalidates when any entity type in the query result is modified.
This article focuses on L2 — the configuration decisions with the most production consequences.
How the L2 cache works
When Hibernate loads an entity via find() or by navigating an association, it checks the L2 cache first. On a cache miss, it queries the database and stores the loaded entity data (not the entity object itself — Hibernate stores the column values) in the L2 cache under the entity's primary key.
Storing column values, not entity instances, is significant: the L2 cache is not a Java object cache. It stores the raw data; Hibernate reconstructs the entity when reading from the cache. This means the cache is safe across class loader boundaries and JVM restarts (for persistent providers like Redis or Ehcache with persistence).
The L2 cache is invalidated by Hibernate when:
- An entity is updated via Hibernate (
entityManager.persist,merge,save) - An entity is deleted via Hibernate
- A collection element (a
@OneToManyassociation) is added or removed
It is not invalidated by:
- Direct SQL updates (
UPDATE orders SET status = 'shipped' WHERE id = 123) @QuerywithnativeQuery = true- Database changes from other applications or batch tools
- Spring Data's
@Modifyingqueries (these require explicit@CacheEvictor cache invalidation)
This last point is the most common source of L2 cache stale data bugs.
Configuration — Ehcache and Redis providers
Spring Boot with Hibernate L2 requires a cache provider. Two production-worthy options:
EhCache (in-process, for single-node or when sharing isn't required):
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-jcache</artifactId>
</dependency>
<dependency>
<groupId>org.ehcache</groupId>
<artifactId>ehcache</artifactId>
<classifier>jakarta</classifier>
</dependency>
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
use_query_cache: true
region:
factory_class: org.hibernate.cache.jcache.JCacheRegionFactory
javax:
cache:
provider: org.ehcache.jsr107.EhcacheCachingProvider
uri: classpath:ehcache.xml
<!-- ehcache.xml -->
<config xmlns='http://www.ehcache.org/v3'>
<cache alias="com.example.Product">
<expiry>
<ttl unit="hours">1</ttl>
</expiry>
<heap unit="entries">10000</heap>
</cache>
<cache alias="com.example.Order">
<expiry>
<ttl unit="minutes">10</ttl>
</expiry>
<heap unit="entries">50000</heap>
</cache>
</config>
Redis (for distributed deployments):
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-hibernate-6</artifactId>
</dependency>
spring:
jpa:
properties:
hibernate:
cache:
use_second_level_cache: true
region:
factory_class: org.redisson.hibernate.RedissonRegionFactory
redisson:
config: redisson.yaml
Redisson is the standard Redis provider for Hibernate L2 — it handles serialization, eviction, and distributed invalidation. Alternatives like Hazelcast and Infinispan exist but Redisson is most commonly maintained.
Enabling caching per entity
The L2 cache is opt-in per entity. Annotate with @Cache:
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;
@Entity
@Table(name = "products")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Product {
@Id
private Long id;
private String name;
private BigDecimal price;
@OneToMany(mappedBy = "product")
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // cache the collection too
private List<ProductVariant> variants;
}
CacheConcurrencyStrategy determines how concurrent reads and writes are handled:
READ_ONLY — for entities that never change after creation. Configuration, reference data, enum-like entities. Highest performance, no write support.
READ_WRITE — for entities that change, with soft locking to prevent stale reads during updates. The standard choice for most mutable entities.
NONSTRICT_READ_WRITE — no locking, slightly stale reads possible during concurrent updates. Higher performance than READ_WRITE when occasional staleness is acceptable.
TRANSACTIONAL — full transactional cache (JTA required). Rarely used outside of JTA environments.
For most entities, READ_WRITE is the correct choice. READ_ONLY for immutable reference data (country codes, product categories that never change). Avoid NONSTRICT_READ_WRITE unless you've profiled that the locking overhead of READ_WRITE is a bottleneck — the stale read risk is usually not worth it.
The query cache — use with caution
The query cache stores query result sets keyed by the query string and parameters. When the same query runs again, Hibernate returns the cached result without executing SQL:
// Enable query cache for a specific query
@Query("SELECT p FROM Product p WHERE p.category = :category")
@QueryHints({@QueryHint(name = "org.hibernate.cacheable", value = "true"),
@QueryHint(name = "org.hibernate.cacheRegion", value = "product-queries")})
List<Product> findByCategory(@Param("category") String category);
The query cache stores entity primary keys, not entity data. Hibernate then loads each entity from L2 or the database. A cached query result for findByCategory("electronics") stores [1L, 2L, 5L, 42L] — the IDs. Retrieving the entities is a second step.
The query cache invalidation trap. The query cache for a region is invalidated whenever any entity in the query's result type is modified — regardless of whether the modification affects any of the cached results. A query cache region for Product queries is cleared every time any Product is saved, updated, or deleted. For tables with frequent writes, the query cache hit rate approaches zero.
The query cache is appropriate for:
- Queries on truly static reference data (country codes, configuration)
- Queries where the result set changes very infrequently relative to the read frequency
- Named queries with known, bounded parameters
For most application queries on actively-updated data, the query cache provides no benefit and adds overhead. Measure hit rate before concluding it's helping.
The distributed deployment problem
The L2 cache in a single JVM application is straightforward — one cache, one JVM, Hibernate manages all mutations. In a distributed deployment with multiple instances:
EhCache (in-process) does not invalidate across instances. Instance A updates a Product. Instance A's L2 cache is invalidated. Instance B's L2 cache still has the old value. Requests routed to Instance B return stale data.
This is not a bug — it's the defined behavior of in-process caches. EhCache can be configured with distributed capabilities (Terracotta cluster, JGroups), but this adds infrastructure complexity.
Redisson (Redis-backed) invalidates across instances. When Instance A updates a Product, Hibernate publishes an invalidation message. Redisson subscribes to these messages across all instances. Instance B receives the invalidation and removes the cached entry. Subsequent reads from Instance B fetch from the database.
For any deployment with more than one application instance, use Redis-backed L2 cache (Redisson) or disable L2 entirely. Running EhCache in a multi-instance deployment produces silent stale data.
When L2 cache helps
L2 cache provides meaningful benefit for:
Frequently read, infrequently updated reference data. Product catalogs, configuration entities, user roles, country/currency lookups — these are read thousands of times per request cycle and change rarely. A cache hit rate of 99%+ is achievable and the benefit is real.
Many-to-one associations traversed frequently. order.getProduct(), invoice.getUser(), lineItem.getCategory() — these associations are traversed on every response serialization for their parent entities. Without L2 cache, each traversal fires a query (unless JOIN FETCH'd). With L2 cache, the associated entity is returned from cache.
Entities loaded by primary key in hot paths. productRepository.findById(id) in a tight loop benefits from L2 — the first load queries the database, subsequent loads within the cache TTL return from cache.
When L2 cache is a trap
Frequently updated entities. An Order that updates status, payment state, and fulfillment data frequently will be invalidated constantly. The L2 cache churns without benefit — cache miss rate stays high, invalidation overhead is pure cost.
Entities loaded primarily via complex queries. If most reads go through findByStatusAndUserId() rather than findById(), the L2 entity cache provides no benefit — it only caches by primary key. The query cache would be needed, but its invalidation behavior (above) makes it impractical for frequently-updated entities.
Tables updated outside Hibernate. Any table that receives direct SQL updates, batch tool modifications, or changes from other applications will have persistent stale data in the L2 cache. The cache is never invalidated because Hibernate doesn't know about the updates.
Large entities with frequently-changing sub-graphs. An entity with a large @OneToMany collection that changes frequently will invalidate the collection cache on every change, causing constant cache churn on the collection while the entity root remains valid.
The practical decision
Enable L2 cache for entities that satisfy all three conditions:
- Loaded frequently by primary key
- Updated infrequently relative to read frequency
- Modified exclusively through Hibernate (no direct SQL, no external tools)
For everything else, the @Cacheable application-level cache on service methods gives more control over what's cached, how it's keyed, and when it's invalidated — without the silent-staleness risks of L2 in distributed deployments.
Measure before enabling. Add L2 cache to a staging environment with production-representative traffic, enable Hibernate's statistics:
spring:
jpa:
properties:
hibernate:
generate_statistics: true
Then check: SessionFactory.getStatistics().getSecondLevelCacheHitCount() vs miss count. A hit rate below 80% on an entity suggests L2 cache is not worth the configuration complexity for that entity.